pub mod anthropic;
pub mod gemini;
pub(crate) mod meta;
pub mod ollama;
pub mod openai_compat;
pub(crate) mod stream_bridge;
use std::sync::Arc;
use async_trait::async_trait;
use crate::domain::{ChatRequest, TurnId};
use crate::models::adapters::ModelLimits;
use crate::models::adapters::ollama_sizing::NumCtxSource;
use crate::models::{ModelError, Result, TokenUsage};
use crate::runtime::{NewProviderProbe, RuntimeStore};
use super::capabilities::Capabilities;
use super::ctx::{FinalResponse, StreamContext, StreamEvent};
#[derive(Debug, Clone, Copy, Default)]
pub struct ContextSizing {
pub model_max: Option<usize>,
pub effective: Option<usize>,
pub source: Option<NumCtxSource>,
pub max_output: Option<usize>,
}
#[derive(Debug, Clone, Copy)]
pub struct ModelPlacement {
pub size_vram_bytes: u64,
pub total_bytes: u64,
pub suggested_num_ctx: Option<u32>,
}
#[async_trait]
pub trait ModelProvider: Send + Sync {
fn capabilities(&self) -> &Capabilities;
async fn resolve_context_window(&self, request: &ChatRequest) -> ContextSizing {
let _ = request;
let max = self.capabilities().max_context_tokens;
ContextSizing {
model_max: max,
effective: max,
source: None,
max_output: self.capabilities().max_output_tokens,
}
}
async fn verify_placement(&self, current_num_ctx: Option<usize>) -> Option<ModelPlacement> {
let _ = current_num_ctx;
None
}
async fn supports_vision(&self) -> Option<bool> {
None
}
async fn chat(&self, request: ChatRequest, ctx: StreamContext) -> Result<FinalResponse>;
}
pub(crate) async fn collect_text(
provider: Arc<dyn ModelProvider>,
turn: TurnId,
request: ChatRequest,
token: tokio_util::sync::CancellationToken,
) -> Result<(String, Option<TokenUsage>)> {
let (stream_tx, mut stream_rx) = tokio::sync::mpsc::channel::<StreamEvent>(128);
let ctx = StreamContext::new(token, stream_tx, turn);
let collector = tokio::task::spawn(async move {
let mut text = String::new();
let mut usage = None;
while let Some(event) = stream_rx.recv().await {
match event {
StreamEvent::Text(chunk) => text.push_str(&chunk),
StreamEvent::Done {
usage: done_usage, ..
} => usage = done_usage,
StreamEvent::Reasoning(_) | StreamEvent::ToolCall(_) | StreamEvent::Status(_) => {},
}
}
(text, usage)
});
let response = provider.chat(request, ctx).await;
let (text, stream_usage) = collector.await.map_err(|err| {
ModelError::StreamError(format!("collect_text collector failed: {}", err))
})?;
match response {
Ok(final_response) => Ok((text, final_response.usage.or(stream_usage))),
Err(err) => Err(err),
}
}
pub use anthropic::AnthropicProvider;
pub use gemini::GeminiProvider;
pub use meta::MetaProvider;
pub use ollama::OllamaProvider;
pub use openai_compat::OpenAICompatProvider;
pub(crate) fn probe_is_stale(probed_at: &str) -> bool {
use chrono::{DateTime, Utc};
match DateTime::parse_from_rfc3339(probed_at) {
Ok(t) => {
Utc::now()
.signed_duration_since(t.with_timezone(&Utc))
.num_days()
>= crate::constants::PROVIDER_PROBE_TTL_DAYS
},
Err(_) => true,
}
}
#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct CachedLimits {
pub(crate) max_context_tokens: Option<usize>,
pub(crate) max_output_tokens: Option<usize>,
}
pub(crate) const LIMITS_PROBE_KEY: &str = "limits_probe";
pub(crate) async fn load_limits_from_db(provider: String, model: String) -> Option<CachedLimits> {
tokio::task::spawn_blocking(move || {
let store = RuntimeStore::open_default().ok()?;
let rec = store
.provider_probes()
.get(&provider, &model, LIMITS_PROBE_KEY)
.ok()??;
if probe_is_stale(&rec.probed_at) {
return None;
}
serde_json::from_str::<CachedLimits>(&rec.capability_value).ok()
})
.await
.ok()
.flatten()
}
pub(crate) async fn save_limits_to_db(provider: String, model: String, limits: &CachedLimits) {
let value = match serde_json::to_string(limits) {
Ok(v) => v,
Err(_) => return,
};
let _ = tokio::task::spawn_blocking(move || -> Option<()> {
let store = RuntimeStore::open_default().ok()?;
store
.provider_probes()
.upsert(NewProviderProbe {
provider,
model_id: model,
capability_key: LIMITS_PROBE_KEY.into(),
capability_value: value,
confidence: "probed".into(),
error: None,
})
.ok()?;
Some(())
})
.await;
}
pub(crate) async fn resolve_limits_cached<F, Fut>(
provider: &str,
model: &str,
fetch: F,
) -> Option<CachedLimits>
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = Result<ModelLimits>>,
{
if let Some(cached) = load_limits_from_db(provider.to_string(), model.to_string()).await {
return Some(cached);
}
match fetch().await {
Ok(limits) => {
let cached = CachedLimits {
max_context_tokens: limits.max_context_tokens,
max_output_tokens: limits.max_output_tokens,
};
save_limits_to_db(provider.to_string(), model.to_string(), &cached).await;
Some(cached)
},
Err(_) => None,
}
}
pub(crate) fn parse_output_cap_message(body: &str) -> Option<usize> {
let cap = if let Some(rest) = text_after(body, "exceeds model's maximum output tokens") {
leading_integer(rest)
} else if body.contains("max_tokens is too large") {
text_after(body, "supports at most").and_then(leading_integer)
} else {
None
}?;
(1_024..10_000_000).contains(&cap).then_some(cap)
}
fn text_after<'a>(haystack: &'a str, marker: &str) -> Option<&'a str> {
haystack.find(marker).map(|i| &haystack[i + marker.len()..])
}
fn leading_integer(s: &str) -> Option<usize> {
let start = s.find(|c: char| c.is_ascii_digit()).filter(|&i| i <= 8)?;
s[start..]
.chars()
.take_while(char::is_ascii_digit)
.collect::<String>()
.parse()
.ok()
}
pub(crate) fn retry_cap(requested: usize, learned: usize) -> Option<usize> {
(requested == 0 || requested > learned).then_some(learned)
}
pub(crate) fn output_cap_from_error(err: &ModelError) -> Option<usize> {
match err {
ModelError::Backend(crate::models::BackendError::HttpError {
status: 400,
message,
..
}) => parse_output_cap_message(message),
_ => None,
}
}
pub(crate) async fn learn_output_cap(provider: String, model: String, cap: usize) {
let _ = tokio::task::spawn_blocking(move || -> Option<()> {
let store = RuntimeStore::open_default().ok()?;
let existing = store
.provider_probes()
.get(&provider, &model, LIMITS_PROBE_KEY)
.ok()
.flatten()
.and_then(|rec| serde_json::from_str::<CachedLimits>(&rec.capability_value).ok());
let merged = CachedLimits {
max_context_tokens: existing.and_then(|l| l.max_context_tokens),
max_output_tokens: Some(cap),
};
let value = serde_json::to_string(&merged).ok()?;
store
.provider_probes()
.upsert(NewProviderProbe {
provider,
model_id: model,
capability_key: LIMITS_PROBE_KEY.into(),
capability_value: value,
confidence: "probed".into(),
error: None,
})
.ok()?;
Some(())
})
.await;
}
#[cfg(test)]
mod tests {
use super::*;
const MINIMAX_RAW: &str =
"max_tokens (521276) exceeds model's maximum output tokens (131072) for model minimax-m3";
const MINIMAX_JSON: &str = r#"{"error":"max_tokens (521276) exceeds model's maximum output tokens (131072) for model minimax-m3 (ref: a05c9ffb-168f)"}"#;
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"}}"#;
#[test]
fn parse_output_cap_matches_documented_wordings() {
assert_eq!(parse_output_cap_message(MINIMAX_RAW), Some(131_072));
assert_eq!(parse_output_cap_message(MINIMAX_JSON), Some(131_072));
assert_eq!(parse_output_cap_message(OPENAI_STYLE), Some(16_384));
}
#[test]
fn parse_output_cap_never_matches_context_limit_wordings() {
for body in [
"prompt is too long: 210000 tokens > 200000 maximum",
"This model's maximum context length is 128000 tokens",
"input length and max_tokens exceed context limit: 190000 + 20000 > 200000",
"the request exceeds the maximum context window of 131072 tokens",
"rate limit exceeded, try again in 20s",
"",
] {
assert_eq!(parse_output_cap_message(body), None, "matched: {body}");
}
}
#[test]
fn parse_output_cap_rejects_nonsense_values() {
assert_eq!(
parse_output_cap_message("exceeds model's maximum output tokens (512)"),
None
);
assert_eq!(
parse_output_cap_message("exceeds model's maximum output tokens (99999999999)"),
None
);
assert_eq!(
parse_output_cap_message(
"exceeds model's maximum output tokens for this deployment tier which is 131072"
),
None
);
}
#[test]
fn retry_cap_triple() {
assert_eq!(retry_cap(0, 131_072), Some(131_072));
assert_eq!(retry_cap(521_276, 131_072), Some(131_072));
assert_eq!(retry_cap(4_096, 131_072), None);
}
#[test]
fn output_cap_from_error_gates_on_http_400() {
let err_400 = ModelError::Backend(crate::models::BackendError::HttpError {
status: 400,
message: MINIMAX_JSON.to_string(),
debug: Default::default(),
});
assert_eq!(output_cap_from_error(&err_400), Some(131_072));
let err_500 = ModelError::Backend(crate::models::BackendError::HttpError {
status: 500,
message: MINIMAX_JSON.to_string(),
debug: Default::default(),
});
assert_eq!(output_cap_from_error(&err_500), None);
assert_eq!(output_cap_from_error(&ModelError::Cancelled), None);
}
}