1pub 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::learning::{ParamMemory, Rejections};
22use mermaid_model::models::adapters::ollama_sizing::NumCtxSource;
23use mermaid_model::models::{FinishReason, ModelError, Result, TokenUsage};
24use mermaid_runtime::{NewProviderProbe, RuntimeStore};
25
26use super::ctx::{FinalResponse, StreamContext, StreamEvent};
27use mermaid_model::models::ModelCapabilities;
28
29#[derive(Debug, Clone, Copy, Default)]
36pub struct ContextSizing {
37 pub model_max: Option<usize>,
38 pub effective: Option<usize>,
39 pub source: Option<NumCtxSource>,
41 pub max_output: Option<usize>,
46 pub compacts_natively: bool,
49}
50
51#[derive(Debug, Clone, Copy)]
56pub struct ModelPlacement {
57 pub size_vram_bytes: u64,
58 pub total_bytes: u64,
59 pub suggested_num_ctx: Option<u32>,
63}
64
65#[async_trait]
69pub trait ModelProvider: Send + Sync {
70 fn capabilities(&self) -> &ModelCapabilities;
74
75 async fn resolve_context_window(&self, request: &ChatRequest) -> ContextSizing {
84 let _ = request;
85 let max = self.capabilities().max_context_tokens;
86 ContextSizing {
87 model_max: max,
88 effective: max,
89 source: None,
90 max_output: self.capabilities().max_output_tokens,
91 compacts_natively: false,
92 }
93 }
94
95 async fn verify_placement(&self, current_num_ctx: Option<usize>) -> Option<ModelPlacement> {
100 let _ = current_num_ctx;
101 None
102 }
103
104 async fn supports_vision(&self) -> Option<bool> {
110 None
111 }
112
113 async fn chat(&self, request: ChatRequest, ctx: StreamContext) -> Result<FinalResponse>;
123}
124
125#[derive(Debug, Clone, Default)]
127pub struct CollectedText {
128 pub text: String,
129 pub usage: Option<TokenUsage>,
130 pub stop_reason: Option<FinishReason>,
131 pub reasoning: Option<String>,
132}
133
134pub(crate) async fn collect_text(
141 provider: Arc<dyn ModelProvider>,
142 turn: TurnId,
143 request: ChatRequest,
144 token: tokio_util::sync::CancellationToken,
145) -> Result<CollectedText> {
146 let (stream_tx, mut stream_rx) = tokio::sync::mpsc::channel::<StreamEvent>(128);
147 let ctx = StreamContext::new(token, stream_tx, turn);
148 let collector = tokio::task::spawn(async move {
149 let mut text = String::new();
150 let mut reasoning = String::new();
151 let mut usage = None;
152 let mut stop_reason = None;
153 while let Some(event) = stream_rx.recv().await {
154 match event {
155 StreamEvent::Text(chunk) => text.push_str(&chunk),
156 StreamEvent::Reasoning(chunk) => reasoning.push_str(&chunk.text),
157 StreamEvent::Done {
158 usage: done_usage,
159 stop_reason: done_stop,
160 ..
161 } => {
162 usage = done_usage;
163 stop_reason = done_stop;
164 },
165 StreamEvent::ToolCall(_) | StreamEvent::Status(_) => {},
168 }
169 }
170 (
171 text,
172 (!reasoning.is_empty()).then_some(reasoning),
173 usage,
174 stop_reason,
175 )
176 });
177
178 let response = provider.chat(request, ctx).await;
179 let (text, reasoning, stream_usage, stream_stop_reason) = collector
180 .await
181 .map_err(|err| ModelError::StreamError(format!("collect_text collector failed: {err}")))?;
182 match response {
183 Ok(final_response) => Ok(CollectedText {
184 text,
185 usage: final_response.usage.or(stream_usage),
186 stop_reason: final_response.stop_reason.or(stream_stop_reason),
187 reasoning,
188 }),
189 Err(err) => Err(err),
190 }
191}
192
193pub use anthropic::AnthropicProvider;
194pub use gemini::GeminiProvider;
195pub use meta::MetaProvider;
196pub use ollama::OllamaProvider;
197pub use openai_compat::OpenAICompatProvider;
198
199pub(crate) fn probe_is_stale(probed_at: &str) -> bool {
203 use chrono::{DateTime, Utc};
204 match DateTime::parse_from_rfc3339(probed_at) {
205 Ok(t) => {
206 Utc::now()
207 .signed_duration_since(t.with_timezone(&Utc))
208 .num_days()
209 >= mermaid_model::constants::PROVIDER_PROBE_TTL_DAYS
210 },
211 Err(_) => true,
213 }
214}
215
216#[derive(serde::Serialize, serde::Deserialize)]
222pub(crate) struct CachedLimits {
223 pub(crate) max_context_tokens: Option<usize>,
224 pub(crate) max_output_tokens: Option<usize>,
225}
226
227pub(crate) const LIMITS_PROBE_KEY: &str = "limits_probe";
228
229pub(crate) async fn load_limits_from_db(provider: String, model: String) -> Option<CachedLimits> {
231 tokio::task::spawn_blocking(move || {
232 let rec = mermaid_runtime::with_shared_store(|store| {
233 store
234 .provider_probes()
235 .get(&provider, &model, LIMITS_PROBE_KEY)
236 })
237 .ok()??;
238 if probe_is_stale(&rec.probed_at) {
239 return None;
240 }
241 serde_json::from_str::<CachedLimits>(&rec.capability_value).ok()
242 })
243 .await
244 .ok()
245 .flatten()
246}
247
248pub(crate) async fn save_limits_to_db(provider: String, model: String, limits: &CachedLimits) {
250 let value = match serde_json::to_string(limits) {
251 Ok(v) => v,
252 Err(_) => return,
253 };
254 let _ = tokio::task::spawn_blocking(move || -> Option<()> {
255 mermaid_runtime::with_shared_store(|store| {
256 store.provider_probes().upsert(NewProviderProbe {
257 provider,
258 model_id: model,
259 capability_key: LIMITS_PROBE_KEY.into(),
260 capability_value: value,
261 confidence: "probed".into(),
262 error: None,
263 })
264 })
265 .ok()?;
266 Some(())
267 })
268 .await;
269}
270
271pub(crate) async fn resolve_limits_cached<F, Fut>(
277 provider: &str,
278 model: &str,
279 fetch: F,
280) -> Option<CachedLimits>
281where
282 F: FnOnce() -> Fut,
283 Fut: std::future::Future<Output = Result<ModelLimits>>,
284{
285 if let Some(cached) = load_limits_from_db(provider.to_string(), model.to_string()).await {
286 return Some(cached);
287 }
288 match fetch().await {
289 Ok(limits) => {
290 let cached = CachedLimits {
291 max_context_tokens: limits.max_context_tokens,
292 max_output_tokens: limits.max_output_tokens,
293 };
294 save_limits_to_db(provider.to_string(), model.to_string(), &cached).await;
295 Some(cached)
296 },
297 Err(_) => None,
299 }
300}
301
302pub(crate) const REJECTIONS_PROBE_KEY: &str = "rejected_params";
307
308fn read_rejections(store: &RuntimeStore, provider: &str, model: &str) -> Option<Rejections> {
310 let rec = store
311 .provider_probes()
312 .get(provider, model, REJECTIONS_PROBE_KEY)
313 .ok()??;
314 if probe_is_stale(&rec.probed_at) {
315 return None;
316 }
317 serde_json::from_str(&rec.capability_value).ok()
318}
319
320fn write_rejections(
323 store: &RuntimeStore,
324 provider: &str,
325 model: &str,
326 learned: &Rejections,
327) -> anyhow::Result<()> {
328 let mut merged = read_rejections(store, provider, model).unwrap_or_default();
329 merged.extend(learned);
330 store.provider_probes().upsert(NewProviderProbe {
331 provider: provider.to_string(),
332 model_id: model.to_string(),
333 capability_key: REJECTIONS_PROBE_KEY.into(),
334 capability_value: serde_json::to_string(&merged)?,
335 confidence: "learned".into(),
336 error: None,
337 })?;
338 Ok(())
339}
340
341#[derive(Default)]
346pub(crate) struct RejectionCache {
347 seeded: tokio::sync::OnceCell<()>,
348 persisted: std::sync::Mutex<Rejections>,
350}
351
352impl RejectionCache {
353 pub(crate) async fn seed(&self, provider: &str, model: &str, memory: &ParamMemory) {
355 self.seeded
356 .get_or_init(|| async {
357 let (provider, model) = (provider.to_string(), model.to_string());
358 let cached = tokio::task::spawn_blocking(move || {
359 mermaid_runtime::with_shared_store(|store| {
360 Ok(read_rejections(store, &provider, &model))
361 })
362 .ok()
363 .flatten()
364 })
365 .await
366 .ok()
367 .flatten();
368 if let Some(cached) = cached {
369 memory.seed(&cached);
370 *self.lock() = cached;
371 }
372 })
373 .await;
374 }
375
376 pub(crate) async fn persist(&self, provider: &str, model: &str, memory: &ParamMemory) {
378 let learned = memory.snapshot();
379 if *self.lock() == learned {
380 return;
381 }
382 *self.lock() = learned.clone();
383 let (provider, model) = (provider.to_string(), model.to_string());
384 let _ = tokio::task::spawn_blocking(move || {
385 mermaid_runtime::with_shared_store(|store| {
386 write_rejections(store, &provider, &model, &learned)
387 })
388 })
389 .await;
390 }
391
392 fn lock(&self) -> std::sync::MutexGuard<'_, Rejections> {
393 self.persisted
394 .lock()
395 .unwrap_or_else(std::sync::PoisonError::into_inner)
396 }
397}
398
399pub(crate) fn parse_output_cap_message(body: &str) -> Option<usize> {
413 let cap = if let Some(rest) = text_after(body, "exceeds model's maximum output tokens") {
414 leading_integer(rest)
415 } else if body.contains("max_tokens is too large") {
416 text_after(body, "supports at most").and_then(leading_integer)
417 } else {
418 None
419 }?;
420 (1_024..10_000_000).contains(&cap).then_some(cap)
421}
422
423fn text_after<'a>(haystack: &'a str, marker: &str) -> Option<&'a str> {
425 haystack.find(marker).map(|i| &haystack[i + marker.len()..])
426}
427
428fn leading_integer(s: &str) -> Option<usize> {
432 let start = s.find(|c: char| c.is_ascii_digit()).filter(|&i| i <= 8)?;
433 s[start..]
434 .chars()
435 .take_while(char::is_ascii_digit)
436 .collect::<String>()
437 .parse()
438 .ok()
439}
440
441pub(crate) fn retry_cap(requested: usize, learned: usize) -> Option<usize> {
447 (requested == 0 || requested > learned).then_some(learned)
448}
449
450pub(crate) fn output_cap_from_error(err: &ModelError) -> Option<usize> {
453 match err {
454 ModelError::Backend(mermaid_model::models::BackendError::HttpError {
455 status: 400,
456 message,
457 ..
458 }) => parse_output_cap_message(message),
459 _ => None,
460 }
461}
462
463pub(crate) async fn learn_output_cap(provider: String, model: String, cap: usize) {
468 let _ = tokio::task::spawn_blocking(move || -> Option<()> {
469 let existing = mermaid_runtime::with_shared_store(|store| {
470 store
471 .provider_probes()
472 .get(&provider, &model, LIMITS_PROBE_KEY)
473 })
474 .ok()
475 .flatten()
476 .and_then(|rec| serde_json::from_str::<CachedLimits>(&rec.capability_value).ok());
477 let merged = CachedLimits {
478 max_context_tokens: existing.and_then(|l| l.max_context_tokens),
479 max_output_tokens: Some(cap),
480 };
481 let value = serde_json::to_string(&merged).ok()?;
482 mermaid_runtime::with_shared_store(|store| {
483 store.provider_probes().upsert(NewProviderProbe {
484 provider,
485 model_id: model,
486 capability_key: LIMITS_PROBE_KEY.into(),
487 capability_value: value,
488 confidence: "probed".into(),
489 error: None,
490 })
491 })
492 .ok()?;
493 Some(())
494 })
495 .await;
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501
502 fn temp_store(name: &str) -> RuntimeStore {
503 let dir =
504 std::env::temp_dir().join(format!("mermaid_rejections_{name}_{}", std::process::id()));
505 let _ = std::fs::remove_dir_all(&dir);
506 std::fs::create_dir_all(&dir).expect("dir");
507 RuntimeStore::open(dir.join("runtime.sqlite3")).expect("store")
508 }
509
510 #[test]
511 fn rejections_round_trip_through_provider_probes() {
512 let store = temp_store("round_trip");
513 assert_eq!(read_rejections(&store, "anthropic", "claude-nova-7"), None);
514 let learned = Rejections::from(["temperature"]);
515 write_rejections(&store, "anthropic", "claude-nova-7", &learned).expect("write");
516 assert_eq!(
517 read_rejections(&store, "anthropic", "claude-nova-7"),
518 Some(learned)
519 );
520 assert_eq!(read_rejections(&store, "anthropic", "claude-other"), None);
522 let rec = store
523 .provider_probes()
524 .get("anthropic", "claude-nova-7", REJECTIONS_PROBE_KEY)
525 .expect("get")
526 .expect("row");
527 assert_eq!(rec.confidence, "learned");
528 assert_eq!(rec.capability_value, r#"["temperature"]"#);
529 }
530
531 #[test]
532 fn writing_rejections_merges_with_a_concurrent_sessions() {
533 let store = temp_store("merge");
534 write_rejections(&store, "openai", "m", &Rejections::from(["temperature"])).expect("a");
535 write_rejections(&store, "openai", "m", &Rejections::from(["max_tokens"])).expect("b");
536 assert_eq!(
537 read_rejections(&store, "openai", "m"),
538 Some(Rejections::from(["max_tokens", "temperature"]))
539 );
540 }
541
542 const MINIMAX_RAW: &str =
544 "max_tokens (521276) exceeds model's maximum output tokens (131072) for model minimax-m3";
545 const MINIMAX_JSON: &str = r#"{"error":"max_tokens (521276) exceeds model's maximum output tokens (131072) for model minimax-m3 (ref: a05c9ffb-168f)"}"#;
546 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"}}"#;
547
548 #[test]
549 fn parse_output_cap_matches_documented_wordings() {
550 assert_eq!(parse_output_cap_message(MINIMAX_RAW), Some(131_072));
551 assert_eq!(parse_output_cap_message(MINIMAX_JSON), Some(131_072));
552 assert_eq!(parse_output_cap_message(OPENAI_STYLE), Some(16_384));
553 }
554
555 #[test]
556 fn parse_output_cap_never_matches_context_limit_wordings() {
557 for body in [
560 "prompt is too long: 210000 tokens > 200000 maximum",
561 "This model's maximum context length is 128000 tokens",
562 "input length and max_tokens exceed context limit: 190000 + 20000 > 200000",
563 "the request exceeds the maximum context window of 131072 tokens",
564 "rate limit exceeded, try again in 20s",
565 "",
566 ] {
567 assert_eq!(parse_output_cap_message(body), None, "matched: {body}");
568 }
569 }
570
571 #[test]
572 fn parse_output_cap_rejects_nonsense_values() {
573 assert_eq!(
575 parse_output_cap_message("exceeds model's maximum output tokens (512)"),
576 None
577 );
578 assert_eq!(
579 parse_output_cap_message("exceeds model's maximum output tokens (99999999999)"),
580 None
581 );
582 assert_eq!(
584 parse_output_cap_message(
585 "exceeds model's maximum output tokens for this deployment tier which is 131072"
586 ),
587 None
588 );
589 }
590
591 #[test]
592 fn retry_cap_triple() {
593 assert_eq!(retry_cap(0, 131_072), Some(131_072));
595 assert_eq!(retry_cap(521_276, 131_072), Some(131_072));
597 assert_eq!(retry_cap(4_096, 131_072), None);
599 }
600
601 #[test]
602 fn output_cap_from_error_gates_on_http_400() {
603 let err_400 = ModelError::Backend(mermaid_model::models::BackendError::HttpError {
604 status: 400,
605 message: MINIMAX_JSON.to_string(),
606 debug: Default::default(),
607 });
608 assert_eq!(output_cap_from_error(&err_400), Some(131_072));
609 let err_500 = ModelError::Backend(mermaid_model::models::BackendError::HttpError {
611 status: 500,
612 message: MINIMAX_JSON.to_string(),
613 debug: Default::default(),
614 });
615 assert_eq!(output_cap_from_error(&err_500), None);
616 assert_eq!(output_cap_from_error(&ModelError::Cancelled), None);
617 }
618}