mermaid_cli/providers/model/
mod.rs1pub mod anthropic;
10pub mod gemini;
11pub mod meta;
12pub mod ollama;
13pub mod openai_compat;
14
15use std::sync::Arc;
16
17use async_trait::async_trait;
18
19use mermaid_domain::{ChatRequest, TurnId};
20use mermaid_model::models::adapters::ModelLimits;
21use mermaid_model::models::adapters::ollama_sizing::NumCtxSource;
22use mermaid_model::models::{ModelError, Result, TokenUsage};
23use mermaid_runtime::NewProviderProbe;
24
25use super::ctx::{FinalResponse, StreamContext, StreamEvent};
26use mermaid_model::models::ModelCapabilities;
27
28#[derive(Debug, Clone, Copy, Default)]
35pub struct ContextSizing {
36 pub model_max: Option<usize>,
37 pub effective: Option<usize>,
38 pub source: Option<NumCtxSource>,
40 pub max_output: Option<usize>,
45}
46
47#[derive(Debug, Clone, Copy)]
52pub struct ModelPlacement {
53 pub size_vram_bytes: u64,
54 pub total_bytes: u64,
55 pub suggested_num_ctx: Option<u32>,
59}
60
61#[async_trait]
65pub trait ModelProvider: Send + Sync {
66 fn capabilities(&self) -> &ModelCapabilities;
70
71 async fn resolve_context_window(&self, request: &ChatRequest) -> ContextSizing {
80 let _ = request;
81 let max = self.capabilities().max_context_tokens;
82 ContextSizing {
83 model_max: max,
84 effective: max,
85 source: None,
86 max_output: self.capabilities().max_output_tokens,
87 }
88 }
89
90 async fn verify_placement(&self, current_num_ctx: Option<usize>) -> Option<ModelPlacement> {
95 let _ = current_num_ctx;
96 None
97 }
98
99 async fn supports_vision(&self) -> Option<bool> {
105 None
106 }
107
108 async fn chat(&self, request: ChatRequest, ctx: StreamContext) -> Result<FinalResponse>;
118}
119
120pub(crate) async fn collect_text(
127 provider: Arc<dyn ModelProvider>,
128 turn: TurnId,
129 request: ChatRequest,
130 token: tokio_util::sync::CancellationToken,
131) -> Result<(String, Option<TokenUsage>)> {
132 let (stream_tx, mut stream_rx) = tokio::sync::mpsc::channel::<StreamEvent>(128);
133 let ctx = StreamContext::new(token, stream_tx, turn);
134 let collector = tokio::task::spawn(async move {
135 let mut text = String::new();
136 let mut usage = None;
137 while let Some(event) = stream_rx.recv().await {
138 match event {
139 StreamEvent::Text(chunk) => text.push_str(&chunk),
140 StreamEvent::Done {
141 usage: done_usage, ..
142 } => usage = done_usage,
143 StreamEvent::Reasoning(_) | StreamEvent::ToolCall(_) | StreamEvent::Status(_) => {},
146 }
147 }
148 (text, usage)
149 });
150
151 let response = provider.chat(request, ctx).await;
152 let (text, stream_usage) = collector
153 .await
154 .map_err(|err| ModelError::StreamError(format!("collect_text collector failed: {err}")))?;
155 match response {
156 Ok(final_response) => Ok((text, final_response.usage.or(stream_usage))),
157 Err(err) => Err(err),
158 }
159}
160
161pub use anthropic::AnthropicProvider;
162pub use gemini::GeminiProvider;
163pub use meta::MetaProvider;
164pub use ollama::OllamaProvider;
165pub use openai_compat::OpenAICompatProvider;
166
167pub(crate) fn probe_is_stale(probed_at: &str) -> bool {
171 use chrono::{DateTime, Utc};
172 match DateTime::parse_from_rfc3339(probed_at) {
173 Ok(t) => {
174 Utc::now()
175 .signed_duration_since(t.with_timezone(&Utc))
176 .num_days()
177 >= mermaid_model::constants::PROVIDER_PROBE_TTL_DAYS
178 },
179 Err(_) => true,
181 }
182}
183
184#[derive(serde::Serialize, serde::Deserialize)]
190pub(crate) struct CachedLimits {
191 pub(crate) max_context_tokens: Option<usize>,
192 pub(crate) max_output_tokens: Option<usize>,
193}
194
195pub(crate) const LIMITS_PROBE_KEY: &str = "limits_probe";
196
197pub(crate) async fn load_limits_from_db(provider: String, model: String) -> Option<CachedLimits> {
199 tokio::task::spawn_blocking(move || {
200 let rec = mermaid_runtime::with_shared_store(|store| {
201 store
202 .provider_probes()
203 .get(&provider, &model, LIMITS_PROBE_KEY)
204 })
205 .ok()??;
206 if probe_is_stale(&rec.probed_at) {
207 return None;
208 }
209 serde_json::from_str::<CachedLimits>(&rec.capability_value).ok()
210 })
211 .await
212 .ok()
213 .flatten()
214}
215
216pub(crate) async fn save_limits_to_db(provider: String, model: String, limits: &CachedLimits) {
218 let value = match serde_json::to_string(limits) {
219 Ok(v) => v,
220 Err(_) => return,
221 };
222 let _ = tokio::task::spawn_blocking(move || -> Option<()> {
223 mermaid_runtime::with_shared_store(|store| {
224 store.provider_probes().upsert(NewProviderProbe {
225 provider,
226 model_id: model,
227 capability_key: LIMITS_PROBE_KEY.into(),
228 capability_value: value,
229 confidence: "probed".into(),
230 error: None,
231 })
232 })
233 .ok()?;
234 Some(())
235 })
236 .await;
237}
238
239pub(crate) async fn resolve_limits_cached<F, Fut>(
245 provider: &str,
246 model: &str,
247 fetch: F,
248) -> Option<CachedLimits>
249where
250 F: FnOnce() -> Fut,
251 Fut: std::future::Future<Output = Result<ModelLimits>>,
252{
253 if let Some(cached) = load_limits_from_db(provider.to_string(), model.to_string()).await {
254 return Some(cached);
255 }
256 match fetch().await {
257 Ok(limits) => {
258 let cached = CachedLimits {
259 max_context_tokens: limits.max_context_tokens,
260 max_output_tokens: limits.max_output_tokens,
261 };
262 save_limits_to_db(provider.to_string(), model.to_string(), &cached).await;
263 Some(cached)
264 },
265 Err(_) => None,
267 }
268}
269
270pub(crate) fn parse_output_cap_message(body: &str) -> Option<usize> {
284 let cap = if let Some(rest) = text_after(body, "exceeds model's maximum output tokens") {
285 leading_integer(rest)
286 } else if body.contains("max_tokens is too large") {
287 text_after(body, "supports at most").and_then(leading_integer)
288 } else {
289 None
290 }?;
291 (1_024..10_000_000).contains(&cap).then_some(cap)
292}
293
294fn text_after<'a>(haystack: &'a str, marker: &str) -> Option<&'a str> {
296 haystack.find(marker).map(|i| &haystack[i + marker.len()..])
297}
298
299fn leading_integer(s: &str) -> Option<usize> {
303 let start = s.find(|c: char| c.is_ascii_digit()).filter(|&i| i <= 8)?;
304 s[start..]
305 .chars()
306 .take_while(char::is_ascii_digit)
307 .collect::<String>()
308 .parse()
309 .ok()
310}
311
312pub(crate) fn retry_cap(requested: usize, learned: usize) -> Option<usize> {
318 (requested == 0 || requested > learned).then_some(learned)
319}
320
321pub(crate) fn output_cap_from_error(err: &ModelError) -> Option<usize> {
324 match err {
325 ModelError::Backend(mermaid_model::models::BackendError::HttpError {
326 status: 400,
327 message,
328 ..
329 }) => parse_output_cap_message(message),
330 _ => None,
331 }
332}
333
334pub(crate) async fn learn_output_cap(provider: String, model: String, cap: usize) {
339 let _ = tokio::task::spawn_blocking(move || -> Option<()> {
340 let existing = mermaid_runtime::with_shared_store(|store| {
341 store
342 .provider_probes()
343 .get(&provider, &model, LIMITS_PROBE_KEY)
344 })
345 .ok()
346 .flatten()
347 .and_then(|rec| serde_json::from_str::<CachedLimits>(&rec.capability_value).ok());
348 let merged = CachedLimits {
349 max_context_tokens: existing.and_then(|l| l.max_context_tokens),
350 max_output_tokens: Some(cap),
351 };
352 let value = serde_json::to_string(&merged).ok()?;
353 mermaid_runtime::with_shared_store(|store| {
354 store.provider_probes().upsert(NewProviderProbe {
355 provider,
356 model_id: model,
357 capability_key: LIMITS_PROBE_KEY.into(),
358 capability_value: value,
359 confidence: "probed".into(),
360 error: None,
361 })
362 })
363 .ok()?;
364 Some(())
365 })
366 .await;
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372
373 const MINIMAX_RAW: &str =
375 "max_tokens (521276) exceeds model's maximum output tokens (131072) for model minimax-m3";
376 const MINIMAX_JSON: &str = r#"{"error":"max_tokens (521276) exceeds model's maximum output tokens (131072) for model minimax-m3 (ref: a05c9ffb-168f)"}"#;
377 const OPENAI_STYLE: &str = r#"{"error":{"message":"max_tokens is too large: 200000. This model supports at most 16384 completion tokens, whereas you provided 200000.","type":"invalid_request_error"}}"#;
378
379 #[test]
380 fn parse_output_cap_matches_documented_wordings() {
381 assert_eq!(parse_output_cap_message(MINIMAX_RAW), Some(131_072));
382 assert_eq!(parse_output_cap_message(MINIMAX_JSON), Some(131_072));
383 assert_eq!(parse_output_cap_message(OPENAI_STYLE), Some(16_384));
384 }
385
386 #[test]
387 fn parse_output_cap_never_matches_context_limit_wordings() {
388 for body in [
391 "prompt is too long: 210000 tokens > 200000 maximum",
392 "This model's maximum context length is 128000 tokens",
393 "input length and max_tokens exceed context limit: 190000 + 20000 > 200000",
394 "the request exceeds the maximum context window of 131072 tokens",
395 "rate limit exceeded, try again in 20s",
396 "",
397 ] {
398 assert_eq!(parse_output_cap_message(body), None, "matched: {body}");
399 }
400 }
401
402 #[test]
403 fn parse_output_cap_rejects_nonsense_values() {
404 assert_eq!(
406 parse_output_cap_message("exceeds model's maximum output tokens (512)"),
407 None
408 );
409 assert_eq!(
410 parse_output_cap_message("exceeds model's maximum output tokens (99999999999)"),
411 None
412 );
413 assert_eq!(
415 parse_output_cap_message(
416 "exceeds model's maximum output tokens for this deployment tier which is 131072"
417 ),
418 None
419 );
420 }
421
422 #[test]
423 fn retry_cap_triple() {
424 assert_eq!(retry_cap(0, 131_072), Some(131_072));
426 assert_eq!(retry_cap(521_276, 131_072), Some(131_072));
428 assert_eq!(retry_cap(4_096, 131_072), None);
430 }
431
432 #[test]
433 fn output_cap_from_error_gates_on_http_400() {
434 let err_400 = ModelError::Backend(mermaid_model::models::BackendError::HttpError {
435 status: 400,
436 message: MINIMAX_JSON.to_string(),
437 debug: Default::default(),
438 });
439 assert_eq!(output_cap_from_error(&err_400), Some(131_072));
440 let err_500 = ModelError::Backend(mermaid_model::models::BackendError::HttpError {
442 status: 500,
443 message: MINIMAX_JSON.to_string(),
444 debug: Default::default(),
445 });
446 assert_eq!(output_cap_from_error(&err_500), None);
447 assert_eq!(output_cap_from_error(&ModelError::Cancelled), None);
448 }
449}