use crate::types::ThinkingLevel;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ApiProtocol {
AnthropicMessages,
OpenAiCompletions,
OpenAiResponses,
AzureOpenAiResponses,
GoogleGenerativeAi,
GoogleVertex,
BedrockConverseStream,
}
impl std::fmt::Display for ApiProtocol {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::AnthropicMessages => write!(f, "anthropic_messages"),
Self::OpenAiCompletions => write!(f, "openai_completions"),
Self::OpenAiResponses => write!(f, "openai_responses"),
Self::AzureOpenAiResponses => write!(f, "azure_openai_responses"),
Self::GoogleGenerativeAi => write!(f, "google_generative_ai"),
Self::GoogleVertex => write!(f, "google_vertex"),
Self::BedrockConverseStream => write!(f, "bedrock_converse_stream"),
}
}
}
pub(crate) fn openai_id_is_reasoning(id: &str) -> bool {
let id = id.rsplit('/').next().unwrap_or(id).to_ascii_lowercase();
let non_reasoning = id.starts_with("gpt-3")
|| id.starts_with("gpt-4")
|| id.starts_with("chatgpt-")
|| id.contains("-chat");
!non_reasoning
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CostConfig {
pub input_per_million: f64,
pub output_per_million: f64,
#[serde(default)]
pub cache_read_per_million: f64,
#[serde(default)]
pub cache_write_per_million: f64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub context_tiers: Vec<ContextTier>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ContextTier {
pub above_prompt_tokens: u64,
pub input_per_million: f64,
pub output_per_million: f64,
#[serde(default)]
pub cache_read_per_million: f64,
#[serde(default)]
pub cache_write_per_million: f64,
}
impl ContextTier {
pub fn new(above_prompt_tokens: u64, input_per_million: f64, output_per_million: f64) -> Self {
debug_assert!(
above_prompt_tokens > 0,
"a tier at 0 applies to every request, making the base rates dead code"
);
Self {
above_prompt_tokens,
input_per_million,
output_per_million,
cache_read_per_million: 0.0,
cache_write_per_million: 0.0,
}
}
pub fn with_cache_read(mut self, per_million: f64) -> Self {
self.cache_read_per_million = per_million;
self
}
pub fn with_cache_write(mut self, per_million: f64) -> Self {
self.cache_write_per_million = per_million;
self
}
pub fn is_configured(&self) -> bool {
self.input_per_million != 0.0
|| self.output_per_million != 0.0
|| self.cache_read_per_million != 0.0
|| self.cache_write_per_million != 0.0
}
}
impl CostConfig {
pub fn new(input_per_million: f64, output_per_million: f64) -> Self {
Self {
input_per_million,
output_per_million,
cache_read_per_million: 0.0,
cache_write_per_million: 0.0,
context_tiers: Vec::new(),
}
}
pub fn with_cache_read(mut self, per_million: f64) -> Self {
self.cache_read_per_million = per_million;
self
}
pub fn with_cache_write(mut self, per_million: f64) -> Self {
self.cache_write_per_million = per_million;
self
}
pub fn with_context_tier(mut self, tier: ContextTier) -> Self {
self.context_tiers.push(tier);
self.context_tiers.sort_by_key(|t| t.above_prompt_tokens);
self
}
pub fn is_configured(&self) -> bool {
self.input_per_million != 0.0
|| self.output_per_million != 0.0
|| self.cache_read_per_million != 0.0
|| self.cache_write_per_million != 0.0
|| self.context_tiers.iter().any(|t| t.is_configured())
}
pub fn cost_usd(&self, usage: &crate::types::Usage) -> f64 {
let prompt = usage.input + usage.cache_read + usage.cache_write;
let tier = self
.context_tiers
.iter()
.rfind(|t| prompt > t.above_prompt_tokens);
let (input, output, cache_read, cache_write) = match tier {
Some(t) => (
t.input_per_million,
t.output_per_million,
t.cache_read_per_million,
t.cache_write_per_million,
),
None => (
self.input_per_million,
self.output_per_million,
self.cache_read_per_million,
self.cache_write_per_million,
),
};
let or_input = |rate: f64| if rate == 0.0 { input } else { rate };
let (cache_read, cache_write) = (or_input(cache_read), or_input(cache_write));
(usage.input as f64 * input
+ usage.output as f64 * output
+ usage.cache_read as f64 * cache_read
+ usage.cache_write as f64 * cache_write)
/ 1_000_000.0
}
}
impl Default for CostConfig {
fn default() -> Self {
Self {
input_per_million: 0.0,
output_per_million: 0.0,
cache_read_per_million: 0.0,
cache_write_per_million: 0.0,
context_tiers: Vec::new(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum MaxTokensField {
#[default]
MaxTokens,
MaxCompletionTokens,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ThinkingFormat {
#[default]
OpenAi,
Xai,
Qwen,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize,
)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum ReasoningEffortCeiling {
#[default]
High,
XHigh,
Max,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct OpenAiCompat {
pub supports_store: bool,
pub supports_developer_role: bool,
pub supports_reasoning_effort: bool,
#[serde(default)]
pub supports_thinking_control: bool,
pub supports_usage_in_streaming: bool,
pub max_tokens_field: MaxTokensField,
pub requires_tool_result_name: bool,
#[serde(default)]
pub requires_assistant_after_tool_result: bool,
pub thinking_format: ThinkingFormat,
#[serde(default)]
pub supports_prompt_cache_key: bool,
#[serde(default)]
pub replays_reasoning_content: bool,
#[serde(default)]
pub encrypted_reasoning: bool,
#[serde(default)]
pub max_reasoning_effort: ReasoningEffortCeiling,
}
impl Default for OpenAiCompat {
fn default() -> Self {
Self {
supports_store: false,
supports_developer_role: false,
supports_reasoning_effort: false,
supports_thinking_control: false,
supports_usage_in_streaming: true,
max_tokens_field: MaxTokensField::MaxTokens,
requires_tool_result_name: false,
requires_assistant_after_tool_result: false,
thinking_format: ThinkingFormat::OpenAi,
supports_prompt_cache_key: false,
replays_reasoning_content: false,
encrypted_reasoning: false,
max_reasoning_effort: ReasoningEffortCeiling::High,
}
}
}
fn warn_effort_clamp(model: &str, level: ThinkingLevel, sent: &'static str) {
let Some(slot) = effort_clamp_slot(level, sent) else {
return;
};
if first_effort_clamp(model, slot) {
tracing::warn!(
model,
requested = ?level,
sent,
"reasoning effort for model {model} clamped to the model's declared ceiling \
(OpenAiCompat::max_reasoning_effort); set it on the ModelConfig's \
compat if the model accepts a higher rung. Logged once per model and clamp."
);
}
}
fn first_effort_clamp(model: &str, slot: usize) -> bool {
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
static WARNED: OnceLock<Mutex<HashMap<String, u8>>> = OnceLock::new();
let bit = 1u8 << slot;
let mut warned = WARNED
.get_or_init(Default::default)
.lock()
.unwrap_or_else(|e| e.into_inner());
match warned.get_mut(model) {
Some(mask) if *mask & bit != 0 => false,
Some(mask) => {
*mask |= bit;
true
}
None => {
warned.insert(model.to_string(), bit);
true
}
}
}
fn effort_clamp_slot(level: ThinkingLevel, sent: &str) -> Option<usize> {
match (level, sent) {
(ThinkingLevel::XHigh, "high") => Some(0),
(ThinkingLevel::Max, "high") => Some(1),
(ThinkingLevel::Max, "xhigh") => Some(2),
_ => None,
}
}
impl OpenAiCompat {
pub(crate) fn openai_reasoning_effort(
&self,
model: &str,
level: ThinkingLevel,
) -> Option<&'static str> {
use ReasoningEffortCeiling as Ceiling;
let effort = match level {
ThinkingLevel::Off => return None,
ThinkingLevel::Minimal | ThinkingLevel::Low => "low",
ThinkingLevel::Medium => "medium",
ThinkingLevel::High => "high",
ThinkingLevel::XHigh if self.max_reasoning_effort >= Ceiling::XHigh => "xhigh",
ThinkingLevel::XHigh => "high",
ThinkingLevel::Max => match self.max_reasoning_effort {
Ceiling::Max => "max",
Ceiling::XHigh => "xhigh",
Ceiling::High => "high",
},
};
warn_effort_clamp(model, level, effort);
Some(effort)
}
pub fn openai() -> Self {
Self {
supports_store: true,
supports_developer_role: true,
supports_reasoning_effort: true,
supports_usage_in_streaming: true,
max_tokens_field: MaxTokensField::MaxCompletionTokens,
supports_prompt_cache_key: true,
..Default::default()
}
}
pub fn meta() -> Self {
Self {
supports_reasoning_effort: true,
supports_usage_in_streaming: true,
max_tokens_field: MaxTokensField::MaxCompletionTokens,
..Default::default()
}
}
pub fn xai() -> Self {
Self {
supports_reasoning_effort: true,
supports_usage_in_streaming: true,
thinking_format: ThinkingFormat::Xai,
max_reasoning_effort: ReasoningEffortCeiling::XHigh,
..Default::default()
}
}
pub fn groq() -> Self {
Self {
supports_usage_in_streaming: true,
..Default::default()
}
}
pub fn cerebras() -> Self {
Self::default()
}
pub fn openrouter() -> Self {
Self {
supports_usage_in_streaming: true,
max_tokens_field: MaxTokensField::MaxCompletionTokens,
..Default::default()
}
}
pub fn mistral() -> Self {
Self {
supports_usage_in_streaming: true,
max_tokens_field: MaxTokensField::MaxTokens,
..Default::default()
}
}
pub fn deepseek() -> Self {
Self {
supports_reasoning_effort: true,
supports_thinking_control: true,
supports_usage_in_streaming: true,
max_tokens_field: MaxTokensField::MaxTokens,
replays_reasoning_content: true,
..Default::default()
}
}
pub fn zai() -> Self {
Self {
supports_usage_in_streaming: true,
..Default::default()
}
}
pub fn minimax() -> Self {
Self {
supports_usage_in_streaming: true,
..Default::default()
}
}
pub fn qwen() -> Self {
Self {
supports_usage_in_streaming: true,
max_tokens_field: MaxTokensField::MaxTokens,
thinking_format: ThinkingFormat::Qwen,
..Default::default()
}
}
pub fn ollama() -> Self {
Self {
requires_assistant_after_tool_result: true,
..Default::default()
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
#[non_exhaustive]
pub struct AnthropicCompat {
pub adaptive_thinking: bool,
pub bearer_auth: bool,
pub native_structured_output: bool,
}
impl Default for AnthropicCompat {
fn default() -> Self {
Self {
adaptive_thinking: true,
bearer_auth: false,
native_structured_output: false,
}
}
}
impl AnthropicCompat {
pub fn legacy() -> Self {
Self {
adaptive_thinking: false,
..Self::default()
}
}
pub fn with_native_structured_output(mut self, on: bool) -> Self {
self.native_structured_output = on;
self
}
pub fn with_bearer_auth(mut self, on: bool) -> Self {
self.bearer_auth = on;
self
}
pub fn for_claude_id(id: &str) -> Self {
match claude_version(id) {
Some(v) => {
let base = if v < (4, 6) {
Self::legacy()
} else {
Self::default()
};
base.with_native_structured_output(v >= (4, 5))
}
None => Self::default().with_native_structured_output(true),
}
}
}
fn claude_version(id: &str) -> Option<(u32, u32)> {
fn leading_digits(token: &str) -> Option<(&str, bool)> {
let end = token
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(token.len());
(end > 0).then(|| (&token[..end], end < token.len()))
}
let lower = id.to_ascii_lowercase();
let rest = lower.strip_prefix("claude-")?;
let mut tokens = rest
.split(['-', '.'])
.skip_while(|t| leading_digits(t).is_none());
let (major, major_suffixed) = leading_digits(tokens.next()?)?;
let major: u32 = major.parse().ok()?;
let minor = if major_suffixed {
0
} else {
tokens
.next()
.and_then(leading_digits)
.filter(|(digits, _)| digits.len() <= 2)
.and_then(|(digits, _)| digits.parse().ok())
.unwrap_or(0)
};
Some((major, minor))
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
#[non_exhaustive]
pub struct GoogleCompat {
pub thinking_level: Option<bool>,
}
impl GoogleCompat {
pub fn force_thinking_level() -> Self {
Self {
thinking_level: Some(true),
}
}
pub fn force_thinking_budget() -> Self {
Self {
thinking_level: Some(false),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OpenCodeGateway {
Zen,
Go,
}
impl OpenCodeGateway {
fn provider_name(self) -> &'static str {
match self {
Self::Zen => "opencode-zen",
Self::Go => "opencode-go",
}
}
fn base_url(self) -> &'static str {
match self {
Self::Zen => "https://opencode.ai/zen/v1",
Self::Go => "https://opencode.ai/zen/go/v1",
}
}
}
#[derive(Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ModelConfig {
pub id: String,
pub name: String,
pub api: ApiProtocol,
pub provider: String,
pub base_url: String,
pub reasoning: bool,
pub context_window: u32,
pub max_tokens: u32,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_cost"
)]
pub cost: Option<CostConfig>,
#[serde(skip)]
list_priced: bool,
#[serde(default)]
pub headers: HashMap<String, String>,
#[serde(default)]
pub compat: Option<OpenAiCompat>,
#[serde(default)]
pub anthropic: Option<AnthropicCompat>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub google: Option<GoogleCompat>,
}
fn deserialize_cost<'de, D>(deserializer: D) -> Result<Option<CostConfig>, D::Error>
where
D: serde::Deserializer<'de>,
{
let cost = Option::<CostConfig>::deserialize(deserializer)?;
Ok(cost.filter(CostConfig::is_configured))
}
impl std::fmt::Debug for ModelConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let headers: Vec<&str> = self.headers.keys().map(String::as_str).collect();
f.debug_struct("ModelConfig")
.field("id", &self.id)
.field("name", &self.name)
.field("api", &self.api)
.field("provider", &self.provider)
.field("base_url", &self.base_url)
.field("reasoning", &self.reasoning)
.field("context_window", &self.context_window)
.field("max_tokens", &self.max_tokens)
.field("header_names", &headers)
.finish_non_exhaustive()
}
}
impl ModelConfig {
pub fn mock() -> Self {
Self::custom(
ApiProtocol::AnthropicMessages,
"mock",
"http://mock.invalid",
"mock",
"Mock",
)
}
fn priced(self) -> Self {
debug_assert!(
super::prices::PRICED_PROVIDERS.contains(&self.provider.as_str()),
"{} looks prices up but is not in PRICED_PROVIDERS",
self.provider
);
self.lookup_price()
}
fn lookup_price(mut self) -> Self {
self.cost = super::prices::global::resolved_cost(&self.provider, &self.id);
self.list_priced = true;
self
}
pub fn reprice(self) -> Self {
if self.list_priced && super::prices::PRICED_PROVIDERS.contains(&self.provider.as_str()) {
self.lookup_price()
} else {
self
}
}
pub fn with_encrypted_reasoning(mut self, on: bool) -> Self {
self.compat
.get_or_insert_with(OpenAiCompat::default)
.encrypted_reasoning = on;
self
}
pub fn with_prices(mut self, table: &super::PriceTable) -> Self {
if let Some(cost) = table.cost(&self.provider, &self.id) {
self.cost = Some(cost);
}
self
}
pub fn custom(
api: ApiProtocol,
provider: impl Into<String>,
base_url: impl Into<String>,
model_id: impl Into<String>,
name: impl Into<String>,
) -> Self {
Self {
id: model_id.into(),
name: name.into(),
api,
provider: provider.into(),
base_url: base_url.into(),
reasoning: false,
context_window: 128_000,
max_tokens: 16_000,
cost: None,
list_priced: false,
headers: HashMap::new(),
google: None,
compat: None,
anthropic: None,
}
}
pub fn anthropic(id: impl Into<String>, name: impl Into<String>) -> Self {
let id = id.into();
let compat = id
.to_ascii_lowercase()
.starts_with("claude-")
.then(|| AnthropicCompat::for_claude_id(&id));
Self {
id,
name: name.into(),
api: ApiProtocol::AnthropicMessages,
provider: "anthropic".into(),
base_url: "https://api.anthropic.com/v1".into(),
reasoning: true,
context_window: 200_000,
max_tokens: 16_000,
cost: None, list_priced: false,
headers: HashMap::new(),
google: None,
anthropic: compat,
compat: None,
}
.priced()
}
pub fn claude_fable_5() -> Self {
Self {
context_window: 1_000_000,
max_tokens: 64_000,
anthropic: Some(AnthropicCompat::default().with_native_structured_output(true)),
..Self::anthropic("claude-fable-5", "Claude Fable 5")
}
}
pub fn claude_fable_5_1() -> Self {
Self {
context_window: 1_000_000,
max_tokens: 64_000,
anthropic: Some(AnthropicCompat::default().with_native_structured_output(true)),
..Self::anthropic("claude-fable-5-1", "Claude Fable 5.1")
}
}
pub fn claude_opus_5_5() -> Self {
Self {
context_window: 1_000_000,
max_tokens: 64_000,
anthropic: Some(AnthropicCompat::default().with_native_structured_output(true)),
..Self::anthropic("claude-opus-5-5", "Claude Opus 5.5")
}
}
pub fn claude_opus_5() -> Self {
Self {
context_window: 1_000_000,
max_tokens: 64_000,
anthropic: Some(AnthropicCompat::default().with_native_structured_output(true)),
..Self::anthropic("claude-opus-5", "Claude Opus 5")
}
}
pub fn claude_opus_4_8() -> Self {
Self {
context_window: 1_000_000,
max_tokens: 64_000,
anthropic: Some(AnthropicCompat::default().with_native_structured_output(true)),
..Self::anthropic("claude-opus-4-8", "Claude Opus 4.8")
}
}
pub fn claude_sonnet_5() -> Self {
Self {
context_window: 1_000_000,
max_tokens: 64_000,
anthropic: Some(AnthropicCompat::default().with_native_structured_output(true)),
..Self::anthropic("claude-sonnet-5", "Claude Sonnet 5")
}
}
pub fn claude_haiku_4_5() -> Self {
Self {
context_window: 200_000,
max_tokens: 32_000,
anthropic: Some(AnthropicCompat::legacy().with_native_structured_output(true)),
..Self::anthropic("claude-haiku-4-5", "Claude Haiku 4.5")
}
}
pub fn gpt_5_5() -> Self {
Self {
reasoning: true,
context_window: 1_000_000,
max_tokens: 64_000,
compat: Some(OpenAiCompat {
max_reasoning_effort: ReasoningEffortCeiling::XHigh,
..OpenAiCompat::openai()
}),
..Self::openai("gpt-5.5", "GPT-5.5")
}
}
pub fn gpt_6_astra() -> Self {
Self::gpt_6("gpt-6-astra", "GPT-6 Astra")
}
pub fn gpt_6_sol() -> Self {
Self::gpt_6("gpt-6-sol", "GPT-6 Sol")
}
pub fn gpt_6_luna() -> Self {
Self::gpt_6("gpt-6-luna", "GPT-6 Luna")
}
fn gpt_6(id: &str, name: &str) -> Self {
Self {
reasoning: true,
context_window: 1_050_000,
max_tokens: 64_000,
compat: Some(OpenAiCompat {
max_reasoning_effort: ReasoningEffortCeiling::Max,
..OpenAiCompat::openai()
}),
..Self::openai_responses(id, name)
}
}
pub fn openai(id: impl Into<String>, name: impl Into<String>) -> Self {
Self {
id: id.into(),
name: name.into(),
api: ApiProtocol::OpenAiCompletions,
provider: "openai".into(),
base_url: "https://api.openai.com/v1".into(),
reasoning: false,
context_window: 128_000,
max_tokens: 4096,
cost: None, list_priced: false,
headers: HashMap::new(),
google: None,
anthropic: None,
compat: Some(OpenAiCompat::openai()),
}
.priced()
}
pub fn openai_responses(id: impl Into<String>, name: impl Into<String>) -> Self {
let id = id.into();
let reasoning = openai_id_is_reasoning(&id);
Self {
id,
name: name.into(),
api: ApiProtocol::OpenAiResponses,
provider: "openai".into(),
base_url: "https://api.openai.com/v1".into(),
reasoning,
context_window: 128_000,
max_tokens: 16_000,
cost: None, list_priced: false,
headers: HashMap::new(),
google: None,
anthropic: None,
compat: None,
}
.priced()
}
pub fn local(base_url: impl Into<String>, model_id: impl Into<String>) -> Self {
Self {
id: model_id.into(),
name: "Local Model".into(),
api: ApiProtocol::OpenAiCompletions,
provider: "local".into(),
base_url: base_url.into(),
reasoning: false,
context_window: 128_000,
max_tokens: 4096,
cost: None,
list_priced: false,
headers: HashMap::new(),
google: None,
anthropic: None,
compat: Some(OpenAiCompat::default()),
}
}
pub fn opencode_zen(model_id: impl Into<String>) -> Self {
Self::opencode(model_id.into(), OpenCodeGateway::Zen)
}
pub fn opencode_go(model_id: impl Into<String>) -> Self {
Self::opencode(model_id.into(), OpenCodeGateway::Go)
}
fn opencode(id: String, gateway: OpenCodeGateway) -> Self {
let lower = id.to_ascii_lowercase();
if lower.starts_with("gemini-") {
tracing::warn!(
"OpenCode serves Gemini models over a Google-native endpoint yoagent \
does not target; '{}' is routed to /chat/completions and will likely \
fail at request time",
id
);
} else if lower.starts_with("jev-") {
tracing::warn!(
"OpenCode serves Jev models on its /systemone evaluation endpoint, \
which yoagent does not target; '{}' is routed to /chat/completions \
and will likely fail at request time",
id
);
}
let anthropic_protocol = match gateway {
OpenCodeGateway::Zen => lower.starts_with("claude-") || lower.starts_with("qwen"),
OpenCodeGateway::Go => lower.starts_with("qwen") || lower.starts_with("minimax-"),
};
let (api, reasoning, compat, anthropic) = if anthropic_protocol {
(
ApiProtocol::AnthropicMessages,
true,
None,
Some(
if lower.starts_with("claude-") {
AnthropicCompat::for_claude_id(&lower)
} else {
AnthropicCompat::default()
}
.with_bearer_auth(true),
),
)
} else if lower.starts_with("gpt-")
|| lower.starts_with("grok-")
|| lower.starts_with("muse-spark-")
{
(ApiProtocol::OpenAiResponses, true, None, None)
} else {
(
ApiProtocol::OpenAiCompletions,
false,
Some(OpenAiCompat::default()),
None,
)
};
Self {
id: id.clone(),
name: id,
api,
provider: gateway.provider_name().into(),
base_url: gateway.base_url().into(),
reasoning,
context_window: 128_000,
max_tokens: 16_000,
cost: None,
list_priced: false,
headers: HashMap::new(),
google: None,
compat,
anthropic,
}
}
pub fn openai_compat(
base_url: impl Into<String>,
model_id: impl Into<String>,
provider: impl Into<String>,
compat: OpenAiCompat,
) -> Self {
let id = model_id.into();
Self {
id: id.clone(),
name: id,
api: ApiProtocol::OpenAiCompletions,
provider: provider.into(),
base_url: base_url.into(),
reasoning: false,
context_window: 128_000,
max_tokens: 4096,
cost: None,
list_priced: false,
headers: HashMap::new(),
google: None,
anthropic: None,
compat: Some(compat),
}
}
pub fn ollama(base_url: impl Into<String>, model_id: impl Into<String>) -> Self {
let id = model_id.into();
Self {
id: id.clone(),
name: id,
api: ApiProtocol::OpenAiCompletions,
provider: "ollama".into(),
base_url: base_url.into(),
reasoning: false,
context_window: 128_000,
max_tokens: 4096,
cost: None,
list_priced: false,
headers: HashMap::new(),
google: None,
anthropic: None,
compat: Some(OpenAiCompat::ollama()),
}
}
pub fn zai(id: impl Into<String>, name: impl Into<String>) -> Self {
Self {
id: id.into(),
name: name.into(),
api: ApiProtocol::OpenAiCompletions,
provider: "zai".into(),
base_url: "https://api.z.ai/api/paas/v4".into(),
reasoning: false,
context_window: 128_000,
max_tokens: 4096,
cost: None, list_priced: false,
headers: HashMap::new(),
google: None,
anthropic: None,
compat: Some(OpenAiCompat::zai()),
}
.priced()
}
pub fn meta(id: impl Into<String>, name: impl Into<String>) -> Self {
Self {
id: id.into(),
name: name.into(),
api: ApiProtocol::OpenAiCompletions,
provider: "meta".into(),
base_url: "https://api.meta.ai/v1".into(),
reasoning: true,
context_window: 1_048_576,
max_tokens: 131_072,
cost: None, list_priced: false,
headers: HashMap::new(),
google: None,
anthropic: None,
compat: Some(OpenAiCompat::meta()),
}
.priced()
}
pub fn minimax(id: impl Into<String>, name: impl Into<String>) -> Self {
Self {
id: id.into(),
name: name.into(),
api: ApiProtocol::OpenAiCompletions,
provider: "minimax".into(),
base_url: "https://api.minimax.io/v1".into(),
reasoning: false,
context_window: 1_000_000,
max_tokens: 4096,
cost: None, list_priced: false,
headers: HashMap::new(),
google: None,
anthropic: None,
compat: Some(OpenAiCompat::minimax()),
}
.priced()
}
pub fn qwen(id: impl Into<String>, name: impl Into<String>) -> Self {
Self {
id: id.into(),
name: name.into(),
api: ApiProtocol::OpenAiCompletions,
provider: "qwen".into(),
base_url: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1".into(),
reasoning: true,
context_window: 128_000,
max_tokens: 4096,
cost: None, list_priced: false,
headers: HashMap::new(),
google: None,
anthropic: None,
compat: Some(OpenAiCompat::qwen()),
}
.priced()
}
pub fn xai(id: impl Into<String>, name: impl Into<String>) -> Self {
Self {
id: id.into(),
name: name.into(),
api: ApiProtocol::OpenAiCompletions,
provider: "xai".into(),
base_url: "https://api.x.ai/v1".into(),
reasoning: false,
context_window: 131_072,
max_tokens: 4096,
cost: None, list_priced: false,
headers: HashMap::new(),
google: None,
anthropic: None,
compat: Some(OpenAiCompat::xai()),
}
.priced()
}
pub fn groq(id: impl Into<String>, name: impl Into<String>) -> Self {
Self {
id: id.into(),
name: name.into(),
api: ApiProtocol::OpenAiCompletions,
provider: "groq".into(),
base_url: "https://api.groq.com/openai/v1".into(),
reasoning: false,
context_window: 128_000,
max_tokens: 4096,
cost: None, list_priced: false,
headers: HashMap::new(),
google: None,
anthropic: None,
compat: Some(OpenAiCompat::groq()),
}
.priced()
}
pub fn deepseek(id: impl Into<String>, name: impl Into<String>) -> Self {
Self {
id: id.into(),
name: name.into(),
api: ApiProtocol::OpenAiCompletions,
provider: "deepseek".into(),
base_url: "https://api.deepseek.com".into(),
reasoning: true,
context_window: 1_000_000,
max_tokens: 384_000,
cost: None, list_priced: false,
headers: HashMap::new(),
google: None,
anthropic: None,
compat: Some(OpenAiCompat::deepseek()),
}
.priced()
}
pub fn mistral(id: impl Into<String>, name: impl Into<String>) -> Self {
Self {
id: id.into(),
name: name.into(),
api: ApiProtocol::OpenAiCompletions,
provider: "mistral".into(),
base_url: "https://api.mistral.ai/v1".into(),
reasoning: false,
context_window: 128_000,
max_tokens: 4096,
cost: None, list_priced: false,
headers: HashMap::new(),
google: None,
anthropic: None,
compat: Some(OpenAiCompat::mistral()),
}
.priced()
}
pub fn google(id: impl Into<String>, name: impl Into<String>) -> Self {
Self {
id: id.into(),
name: name.into(),
api: ApiProtocol::GoogleGenerativeAi,
provider: "google".into(),
base_url: "https://generativelanguage.googleapis.com".into(),
reasoning: false,
context_window: 1_000_000,
max_tokens: 8192,
cost: None, list_priced: false,
headers: HashMap::new(),
google: None,
anthropic: None,
compat: None,
}
.priced()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn meta_preset_matches_launch_specs() {
crate::provider::prices::enable_bundled();
let mc = ModelConfig::meta("muse-spark-1.1", "Muse Spark 1.1");
assert_eq!(mc.provider, "meta");
assert_eq!(mc.api, ApiProtocol::OpenAiCompletions);
assert_eq!(mc.base_url, "https://api.meta.ai/v1");
assert_eq!(mc.context_window, 1_048_576);
assert_eq!(mc.max_tokens, 131_072);
assert!(mc.cost.as_ref().unwrap().is_configured());
assert_eq!(mc.cost.as_ref().unwrap().input_per_million, 1.25);
assert_eq!(mc.cost.as_ref().unwrap().output_per_million, 4.25);
assert_eq!(mc.cost.as_ref().unwrap().cache_read_per_million, 0.15);
assert_eq!(mc.cost.as_ref().unwrap().cache_write_per_million, 1.25);
let compat = mc.compat.expect("compat flags set");
assert!(matches!(
compat.max_tokens_field,
MaxTokensField::MaxCompletionTokens
));
assert!(compat.supports_reasoning_effort);
assert!(compat.supports_usage_in_streaming);
}
#[test]
fn test_model_config_anthropic() {
let config = ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5");
assert_eq!(config.api, ApiProtocol::AnthropicMessages);
assert_eq!(config.provider, "anthropic");
assert_eq!(config.base_url, "https://api.anthropic.com/v1");
assert!(config.compat.is_none());
let compat = config.anthropic.expect("inferred from the id");
assert!(compat.adaptive_thinking && compat.native_structured_output);
let haiku = ModelConfig::anthropic("claude-haiku-4-5", "Haiku 4.5")
.anthropic
.unwrap();
assert!(!haiku.adaptive_thinking && haiku.native_structured_output);
}
#[test]
fn effort_clamp_warning_dedupes_per_model_and_kind() {
let a = "dedupe-test-model-a";
let b = "dedupe-test-model-b";
assert!(first_effort_clamp(a, 0), "first clamp for a model warns");
assert!(!first_effort_clamp(a, 0), "the same clamp again does not");
assert!(first_effort_clamp(a, 2), "another clamp kind still warns");
assert!(!first_effort_clamp(a, 2));
assert!(
first_effort_clamp(b, 0),
"another model's same clamp still warns"
);
assert!(!first_effort_clamp(b, 0));
assert!(first_effort_clamp(a, 1));
assert!(!first_effort_clamp(a, 0) && !first_effort_clamp(a, 1));
}
#[test]
fn effort_clamps_are_detected_exactly() {
use ReasoningEffortCeiling as C;
let at = |ceiling| OpenAiCompat {
max_reasoning_effort: ceiling,
..OpenAiCompat::openai()
};
let levels = [
ThinkingLevel::Off,
ThinkingLevel::Minimal,
ThinkingLevel::Low,
ThinkingLevel::Medium,
ThinkingLevel::High,
ThinkingLevel::XHigh,
ThinkingLevel::Max,
];
for ceiling in [C::High, C::XHigh, C::Max] {
for level in levels {
let clamped = at(ceiling)
.openai_reasoning_effort("test-model", level)
.and_then(|sent| effort_clamp_slot(level, sent))
.is_some();
let expected = matches!(
(level, ceiling),
(ThinkingLevel::XHigh, C::High) | (ThinkingLevel::Max, C::High | C::XHigh)
);
assert_eq!(clamped, expected, "{level:?} at {ceiling:?}");
}
}
}
#[test]
fn test_cost_usd() {
let cost = CostConfig {
input_per_million: 3.0,
output_per_million: 15.0,
cache_read_per_million: 0.3,
cache_write_per_million: 3.75,
..Default::default()
};
let usage = crate::types::Usage {
input: 1_000_000,
output: 100_000,
cache_read: 2_000_000,
cache_write: 400_000,
total_tokens: 0,
};
assert!((cost.cost_usd(&usage) - 6.6).abs() < 1e-9);
assert_eq!(CostConfig::default().cost_usd(&usage), 0.0);
assert_eq!(CostConfig::new(0.0, 0.0).cost_usd(&usage), 0.0);
}
#[test]
fn zero_cache_rates_bill_at_the_input_rate() {
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
let bare = CostConfig::new(2.0, 8.0);
assert!(close(bare.cost_usd(&usage(0, 500_000, 0, 0)), 1.0));
assert!(close(bare.cost_usd(&usage(0, 0, 500_000, 0)), 1.0));
let read_only = CostConfig::new(2.0, 8.0).with_cache_read(0.2);
assert!(close(read_only.cost_usd(&usage(0, 500_000, 0, 0)), 0.1));
assert!(close(read_only.cost_usd(&usage(0, 0, 500_000, 0)), 1.0));
let write_only = CostConfig::new(2.0, 8.0).with_cache_write(2.5);
assert!(close(write_only.cost_usd(&usage(0, 0, 100_000, 0)), 0.25));
assert!(close(write_only.cost_usd(&usage(0, 100_000, 0, 0)), 0.2));
let tiered = CostConfig::new(2.0, 8.0)
.with_cache_read(0.2)
.with_cache_write(2.5)
.with_context_tier(ContextTier::new(100_000, 4.0, 12.0));
let m = 1_000_000;
assert!(close(tiered.cost_usd(&usage(0, m, 0, 0)), 4.0));
assert!(close(tiered.cost_usd(&usage(0, 0, m, 0)), 4.0));
assert!(close(tiered.cost_usd(&usage(0, 50_000, 0, 0)), 0.01));
let tier_rates = CostConfig::new(2.0, 8.0).with_context_tier(
ContextTier::new(100_000, 4.0, 12.0)
.with_cache_read(0.4)
.with_cache_write(5.0),
);
assert!(close(tier_rates.cost_usd(&usage(0, m, 0, 0)), 0.4));
assert!(close(tier_rates.cost_usd(&usage(0, 0, m, 0)), 5.0));
let free = CostConfig::new(0.0, 0.0).with_context_tier(ContextTier::new(100_000, 0.0, 0.0));
assert_eq!(free.cost_usd(&usage(10, m, m, 10)), 0.0);
assert_eq!(free.cost_usd(&usage(10, 10, 10, 10)), 0.0);
}
#[test]
fn test_new_generation_presets() {
crate::provider::prices::enable_bundled();
let fable = ModelConfig::claude_fable_5();
assert_eq!(fable.id, "claude-fable-5");
assert_eq!(fable.api, ApiProtocol::AnthropicMessages);
assert_eq!(fable.context_window, 1_000_000);
assert_eq!(fable.cost.as_ref().unwrap().input_per_million, 10.0);
assert_eq!(fable.cost.as_ref().unwrap().output_per_million, 50.0);
let fable_5_1 = ModelConfig::claude_fable_5_1();
assert_eq!(fable_5_1.id, "claude-fable-5-1");
assert_eq!(fable_5_1.api, ApiProtocol::AnthropicMessages);
assert_eq!(fable_5_1.context_window, 1_000_000);
assert_eq!(fable_5_1.max_tokens, 64_000);
let (c51, c5) = (fable_5_1.cost.unwrap(), fable.cost.unwrap());
assert_eq!(c51.input_per_million, 10.0);
assert_eq!(c51.output_per_million, 50.0);
assert_eq!(c51.cache_write_per_million, 12.5);
assert_eq!(c51.cache_read_per_million, 0.25);
assert_eq!(c5.cache_read_per_million, 1.0);
assert_eq!(c51.input_per_million, c5.input_per_million);
assert_eq!(c51.output_per_million, c5.output_per_million);
assert_eq!(c51.cache_write_per_million, c5.cache_write_per_million);
let opus_5_5 = ModelConfig::claude_opus_5_5();
assert_eq!(opus_5_5.id, "claude-opus-5-5");
assert_eq!(opus_5_5.name, "Claude Opus 5.5");
assert_eq!(opus_5_5.api, ApiProtocol::AnthropicMessages);
assert_eq!(opus_5_5.context_window, 1_000_000);
assert_eq!(opus_5_5.max_tokens, 64_000);
let c55 = opus_5_5.cost.as_ref().unwrap();
assert_eq!(c55.input_per_million, 4.0);
assert_eq!(c55.output_per_million, 20.0);
assert_eq!(c55.cache_read_per_million, 0.2);
assert_eq!(c55.cache_write_per_million, 5.0);
let compat = opus_5_5.anthropic.as_ref().unwrap();
assert!(compat.adaptive_thinking);
assert!(!compat.bearer_auth);
assert!(compat.native_structured_output);
let opus_5 = ModelConfig::claude_opus_5();
assert_eq!(opus_5.id, "claude-opus-5");
assert_eq!(opus_5.api, ApiProtocol::AnthropicMessages);
assert_eq!(opus_5.context_window, 1_000_000);
assert_eq!(opus_5.max_tokens, 64_000);
assert_eq!(opus_5.cost.as_ref().unwrap().input_per_million, 5.0);
assert_eq!(opus_5.cost.as_ref().unwrap().output_per_million, 25.0);
assert_eq!(opus_5.cost.as_ref().unwrap().cache_read_per_million, 0.5);
assert_eq!(opus_5.cost.as_ref().unwrap().cache_write_per_million, 6.25);
let opus = ModelConfig::claude_opus_4_8();
assert_eq!(opus.id, "claude-opus-4-8");
assert_eq!(opus.context_window, 1_000_000);
assert_eq!(opus.cost.as_ref().unwrap().input_per_million, 5.0);
let sonnet = ModelConfig::claude_sonnet_5();
assert_eq!(sonnet.id, "claude-sonnet-5");
assert_eq!(sonnet.cost.as_ref().unwrap().output_per_million, 10.0);
let haiku = ModelConfig::claude_haiku_4_5();
assert_eq!(haiku.id, "claude-haiku-4-5");
assert_eq!(haiku.context_window, 200_000);
let gpt = ModelConfig::gpt_5_5();
assert_eq!(gpt.id, "gpt-5.5");
assert_eq!(gpt.api, ApiProtocol::OpenAiCompletions);
assert_eq!(gpt.context_window, 1_000_000);
assert_eq!(gpt.cost.as_ref().unwrap().output_per_million, 30.0);
let compat = gpt.compat.as_ref().unwrap();
assert_eq!(compat.max_reasoning_effort, ReasoningEffortCeiling::XHigh);
assert!(compat.supports_reasoning_effort && compat.supports_prompt_cache_key);
assert_eq!(compat.max_tokens_field, MaxTokensField::MaxCompletionTokens);
}
#[test]
fn gpt_6_presets() {
crate::provider::prices::enable_bundled();
for (mc, id) in [
(ModelConfig::gpt_6_astra(), "gpt-6-astra"),
(ModelConfig::gpt_6_sol(), "gpt-6-sol"),
(ModelConfig::gpt_6_luna(), "gpt-6-luna"),
] {
assert_eq!(mc.id, id);
assert_eq!(mc.api, ApiProtocol::OpenAiResponses, "{id}");
assert_eq!(mc.provider, "openai");
assert_eq!(mc.base_url, "https://api.openai.com/v1");
assert!(mc.reasoning);
assert_eq!(mc.context_window, 1_050_000);
assert!(mc.max_tokens <= 128_000);
let compat = mc.compat.as_ref().unwrap();
assert_eq!(compat.max_reasoning_effort, ReasoningEffortCeiling::Max);
let tiers = &mc.cost.as_ref().unwrap().context_tiers;
assert_eq!(tiers.len(), 1, "{id}");
assert_eq!(tiers[0].above_prompt_tokens, 272_000);
}
}
#[test]
fn openai_responses_infers_reasoning_from_the_id() {
for id in [
"gpt-4o",
"gpt-4o-mini",
"gpt-4.1",
"gpt-4.1-nano",
"gpt-4-turbo",
"gpt-3.5-turbo",
"chatgpt-4o-latest",
"gpt-5-chat-latest",
"GPT-4o",
"openai/gpt-4.1",
] {
assert!(!ModelConfig::openai_responses(id, id).reasoning, "{id}");
}
for id in [
"o1",
"o3",
"o3-pro",
"o4-mini",
"gpt-5",
"gpt-5-mini",
"gpt-5-codex",
"gpt-5.5",
"gpt-6-sol",
"codex-mini-latest",
"some-future-model",
] {
assert!(ModelConfig::openai_responses(id, id).reasoning, "{id}");
}
assert!(ModelConfig::gpt_5_5().reasoning);
}
fn usage(input: u64, cache_read: u64, cache_write: u64, output: u64) -> crate::types::Usage {
crate::types::Usage {
input,
output,
cache_read,
cache_write,
total_tokens: input + output + cache_read + cache_write,
}
}
#[test]
fn gpt_6_rates_per_band() {
crate::provider::prices::enable_bundled();
type Band = [f64; 4];
let cases: [(ModelConfig, Band, Band); 3] = [
(
ModelConfig::gpt_6_astra(),
[10.0, 1.0, 12.5, 50.0],
[20.0, 2.0, 25.0, 75.0],
),
(
ModelConfig::gpt_6_sol(),
[2.0, 0.2, 2.5, 10.0],
[4.0, 0.4, 5.0, 15.0],
),
(
ModelConfig::gpt_6_luna(),
[0.1, 0.01, 0.125, 0.5],
[0.2, 0.02, 0.25, 0.75],
),
];
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
for (mc, short, long) in cases {
let cost = mc.cost.as_ref().unwrap();
let per_m = |u| cost.cost_usd(&u) * 10.0;
assert!(close(per_m(usage(100_000, 0, 0, 0)), short[0]), "{}", mc.id);
assert!(close(per_m(usage(0, 100_000, 0, 0)), short[1]), "{}", mc.id);
assert!(close(per_m(usage(0, 0, 100_000, 0)), short[2]), "{}", mc.id);
assert!(close(per_m(usage(0, 0, 0, 100_000)), short[3]), "{}", mc.id);
let m = 1_000_000;
assert!(
close(cost.cost_usd(&usage(m, 0, 0, 0)), long[0]),
"{}",
mc.id
);
assert!(
close(cost.cost_usd(&usage(0, m, 0, 0)), long[1]),
"{}",
mc.id
);
assert!(
close(cost.cost_usd(&usage(0, 0, m, 0)), long[2]),
"{}",
mc.id
);
assert!(
close(
cost.cost_usd(&usage(300_000, 0, 0, m)),
0.3 * long[0] + long[3]
),
"{}",
mc.id
);
assert!(
close(cost.cost_usd(&usage(272_000, 0, 0, 0)), 0.272 * short[0]),
"{}",
mc.id
);
}
}
#[test]
fn gpt_5_5_long_band() {
crate::provider::prices::enable_bundled();
let cost = ModelConfig::gpt_5_5().cost.unwrap();
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
assert!(close(cost.cost_usd(&usage(200_000, 0, 0, 0)), 1.0));
assert!(close(cost.cost_usd(&usage(0, 200_000, 0, 0)), 0.1));
assert!(close(cost.cost_usd(&usage(0, 0, 0, 100_000)), 3.0));
assert!(close(cost.cost_usd(&usage(272_000, 0, 0, 0)), 1.36));
assert!(close(cost.cost_usd(&usage(1_000_000, 0, 0, 0)), 10.0));
assert!(close(
cost.cost_usd(&usage(300_000, 0, 0, 1_000_000)),
3.0 + 45.0
));
assert!(close(cost.cost_usd(&usage(0, 1_000_000, 0, 0)), 1.0));
assert!(close(cost.cost_usd(&usage(0, 0, 100_000, 0)), 0.5));
assert!(close(cost.cost_usd(&usage(0, 0, 1_000_000, 0)), 10.0));
}
#[test]
fn test_opencode_zen_protocol_selection() {
let gpt = ModelConfig::opencode_zen("gpt-5.5");
assert_eq!(gpt.api, ApiProtocol::OpenAiResponses);
assert_eq!(gpt.provider, "opencode-zen");
assert_eq!(gpt.base_url, "https://opencode.ai/zen/v1");
for id in ["grok-4.7", "grok-build-0.1", "muse-spark-1.3"] {
let config = ModelConfig::opencode_zen(id);
assert_eq!(config.api, ApiProtocol::OpenAiResponses, "{id}");
assert!(config.reasoning, "{id}");
assert!(config.compat.is_none(), "{id}");
assert!(config.anthropic.is_none(), "{id}");
}
for id in ["claude-sonnet-5", "qwen3.7-max"] {
let config = ModelConfig::opencode_zen(id);
assert_eq!(config.api, ApiProtocol::AnthropicMessages, "{id}");
let compat = config.anthropic.expect("anthropic compat set");
assert!(compat.bearer_auth);
}
for id in ["deepseek-v4-pro", "minimax-m3", "glm-5.2", "kimi-k2.7-code"] {
let config = ModelConfig::opencode_zen(id);
assert_eq!(config.api, ApiProtocol::OpenAiCompletions, "{id}");
assert!(config.compat.is_some());
}
for id in ["gemini-3.5-pro", "jev-1"] {
let config = ModelConfig::opencode_zen(id);
assert_eq!(config.api, ApiProtocol::OpenAiCompletions, "{id}");
}
}
#[test]
fn opencode_claude_compat_matches_presets() {
for preset in [
ModelConfig::claude_fable_5(),
ModelConfig::claude_fable_5_1(),
ModelConfig::claude_opus_5_5(),
ModelConfig::claude_opus_5(),
ModelConfig::claude_opus_4_8(),
ModelConfig::claude_sonnet_5(),
ModelConfig::claude_haiku_4_5(),
] {
let want = preset.anthropic.unwrap();
let got = ModelConfig::opencode_zen(preset.id.clone())
.anthropic
.unwrap();
assert!(got.bearer_auth, "{}", preset.id);
assert_eq!(
got.adaptive_thinking, want.adaptive_thinking,
"{}",
preset.id
);
assert_eq!(
got.native_structured_output, want.native_structured_output,
"{}",
preset.id
);
}
let qwen = ModelConfig::opencode_zen("qwen3.7-max").anthropic.unwrap();
assert!(qwen.bearer_auth && qwen.adaptive_thinking && !qwen.native_structured_output);
let minimax = ModelConfig::opencode_go("minimax-m3").anthropic.unwrap();
assert!(minimax.bearer_auth && minimax.adaptive_thinking);
assert!(!minimax.native_structured_output);
}
#[test]
fn claude_id_compat_by_generation() {
for (id, adaptive, native) in [
("claude-3-5-sonnet-20241022", false, false),
("claude-3-7-sonnet", false, false),
("claude-sonnet-4", false, false),
("claude-sonnet-4-20250514", false, false),
("claude-opus-4-1", false, false),
("claude-opus-4-5", false, true),
("claude-sonnet-4.5", false, true),
("claude-haiku-4-5-20251001", false, true),
("claude-sonnet-4-6", true, true),
("claude-opus-4-8", true, true),
("claude-sonnet-5", true, true),
("claude-fable-5-1", true, true),
("Claude-Opus-5-5", true, true),
("claude-nextgen", true, true),
("claude-opus-4-6[1m]", true, true),
("claude-sonnet-4-5@20250929", false, true),
("claude-opus-4-6-v1:0", true, true),
("claude-haiku-4-5-20251001-v1:0", false, true),
("claude-sonnet-4@20250514", false, false),
("claude-opus-5[1m]", true, true),
("claude-opus-4@20250514", false, false),
("claude-sonnet-4-20250514-v1:0", false, false),
] {
let c = AnthropicCompat::for_claude_id(id);
assert_eq!(c.adaptive_thinking, adaptive, "{id}");
assert_eq!(c.native_structured_output, native, "{id}");
assert!(!c.bearer_auth, "{id}");
}
}
#[test]
fn test_opencode_go_protocol_selection() {
for id in [
"gpt-6-luna",
"gpt-5.6-luna",
"grok-4.7",
"grok-4.6",
"muse-spark-1.3-contributor",
] {
let config = ModelConfig::opencode_go(id);
assert_eq!(config.api, ApiProtocol::OpenAiResponses, "{id}");
assert_eq!(config.base_url, "https://opencode.ai/zen/go/v1");
assert!(config.compat.is_none(), "{id}");
}
for id in ["qwen3.7-max", "minimax-m3"] {
let config = ModelConfig::opencode_go(id);
assert_eq!(config.api, ApiProtocol::AnthropicMessages, "{id}");
assert_eq!(config.base_url, "https://opencode.ai/zen/go/v1");
assert!(config.anthropic.expect("anthropic compat set").bearer_auth);
}
for id in [
"glm-5.2",
"kimi-k2.7-code",
"deepseek-v4-flash",
"mimo-v2.5",
] {
let config = ModelConfig::opencode_go(id);
assert_eq!(config.api, ApiProtocol::OpenAiCompletions, "{id}");
assert_eq!(config.provider, "opencode-go", "{id}");
}
}
#[test]
fn test_model_config_openai() {
let config = ModelConfig::openai("gpt-4o", "GPT-4o");
assert_eq!(config.api, ApiProtocol::OpenAiCompletions);
let compat = config.compat.unwrap();
assert!(compat.supports_store);
assert!(compat.supports_developer_role);
assert_eq!(compat.max_tokens_field, MaxTokensField::MaxCompletionTokens);
}
#[test]
fn test_openai_compat_variants() {
let xai = OpenAiCompat::xai();
assert_eq!(xai.thinking_format, ThinkingFormat::Xai);
assert!(!xai.supports_store);
let groq = OpenAiCompat::groq();
assert!(groq.supports_usage_in_streaming);
assert!(!groq.supports_store);
let deepseek = OpenAiCompat::deepseek();
assert_eq!(deepseek.max_tokens_field, MaxTokensField::MaxTokens);
assert!(deepseek.supports_reasoning_effort);
assert!(deepseek.supports_thinking_control);
let zai = OpenAiCompat::zai();
assert!(zai.supports_usage_in_streaming);
assert!(!zai.supports_store);
let minimax = OpenAiCompat::minimax();
assert!(minimax.supports_usage_in_streaming);
assert!(!minimax.supports_store);
let ollama = OpenAiCompat::ollama();
assert!(ollama.requires_assistant_after_tool_result);
assert!(!ollama.requires_tool_result_name);
let qwen = OpenAiCompat::qwen();
assert_eq!(qwen.thinking_format, ThinkingFormat::Qwen);
assert_eq!(qwen.max_tokens_field, MaxTokensField::MaxTokens);
assert!(qwen.supports_usage_in_streaming);
assert!(!qwen.supports_reasoning_effort);
assert!(!qwen.supports_thinking_control);
}
#[test]
fn test_model_config_deserializes_without_anthropic_field() {
let mut value = serde_json::to_value(ModelConfig::anthropic("m", "M")).unwrap();
value.as_object_mut().unwrap().remove("anthropic");
let config: ModelConfig = serde_json::from_value(value).unwrap();
assert!(config.anthropic.is_none());
}
#[test]
fn test_anthropic_compat_deserializes_from_partial_json() {
let compat: AnthropicCompat = serde_json::from_value(serde_json::json!({})).unwrap();
assert!(compat.adaptive_thinking);
assert!(!compat.bearer_auth);
let compat: AnthropicCompat =
serde_json::from_value(serde_json::json!({"bearer_auth": true})).unwrap();
assert!(compat.adaptive_thinking);
assert!(compat.bearer_auth);
assert!(!compat.native_structured_output);
assert!(!AnthropicCompat::default().native_structured_output);
assert!(!AnthropicCompat::legacy().native_structured_output);
}
#[test]
fn claude_presets_use_native_structured_output() {
for mc in [
ModelConfig::claude_fable_5(),
ModelConfig::claude_fable_5_1(),
ModelConfig::claude_opus_5_5(),
ModelConfig::claude_opus_5(),
ModelConfig::claude_opus_4_8(),
ModelConfig::claude_sonnet_5(),
ModelConfig::claude_haiku_4_5(),
] {
let compat = mc.anthropic.as_ref().expect("preset sets compat");
assert!(compat.native_structured_output, "{}", mc.id);
assert_eq!(
compat.adaptive_thinking,
mc.id != "claude-haiku-4-5",
"{}",
mc.id
);
assert!(!compat.bearer_auth, "{}", mc.id);
}
let unknown = ModelConfig::anthropic("claude-x", "X").anthropic.unwrap();
assert!(unknown.adaptive_thinking && unknown.native_structured_output);
assert!(ModelConfig::anthropic("glm-4.7", "GLM").anthropic.is_none());
}
#[test]
fn test_openai_compat_deserializes_without_assistant_after_tool_result_flag() {
let compat: OpenAiCompat = serde_json::from_value(serde_json::json!({
"supports_store": false,
"supports_developer_role": false,
"supports_reasoning_effort": false,
"supports_thinking_control": false,
"supports_usage_in_streaming": true,
"max_tokens_field": "max_tokens",
"requires_tool_result_name": false,
"thinking_format": "open_ai"
}))
.unwrap();
assert!(!compat.requires_assistant_after_tool_result);
}
#[test]
fn test_model_config_local_remains_neutral() {
let config = ModelConfig::local("http://localhost:1234/v1", "local-model");
assert_eq!(config.api, ApiProtocol::OpenAiCompletions);
assert_eq!(config.provider, "local");
assert_eq!(config.base_url, "http://localhost:1234/v1");
let compat = config.compat.unwrap();
assert!(!compat.requires_assistant_after_tool_result);
}
#[test]
fn test_model_config_ollama() {
let config = ModelConfig::ollama("http://localhost:11434/v1", "llama3.1:8b");
assert_eq!(config.api, ApiProtocol::OpenAiCompletions);
assert_eq!(config.provider, "ollama");
assert_eq!(config.id, "llama3.1:8b");
assert_eq!(config.name, "llama3.1:8b");
assert_eq!(config.base_url, "http://localhost:11434/v1");
let compat = config.compat.unwrap();
assert!(compat.requires_assistant_after_tool_result);
}
#[test]
fn test_model_config_openai_compat() {
let config = ModelConfig::openai_compat(
"http://localhost:1234/v1",
"qwen3-local",
"qwen",
OpenAiCompat::qwen(),
);
assert_eq!(config.api, ApiProtocol::OpenAiCompletions);
assert_eq!(config.provider, "qwen");
assert_eq!(config.id, "qwen3-local");
assert_eq!(config.name, "qwen3-local");
assert_eq!(config.base_url, "http://localhost:1234/v1");
let compat = config.compat.unwrap();
assert_eq!(compat.thinking_format, ThinkingFormat::Qwen);
}
#[test]
fn test_model_config_qwen() {
let config = ModelConfig::qwen("qwen3.6-plus", "Qwen 3.6 Plus");
assert_eq!(config.api, ApiProtocol::OpenAiCompletions);
assert_eq!(config.provider, "qwen");
assert_eq!(
config.base_url,
"https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
);
assert!(config.reasoning);
let compat = config.compat.unwrap();
assert_eq!(compat.thinking_format, ThinkingFormat::Qwen);
assert_eq!(compat.max_tokens_field, MaxTokensField::MaxTokens);
}
#[test]
fn test_model_config_zai() {
let config = ModelConfig::zai("glm-4.7", "GLM 4.7");
assert_eq!(config.api, ApiProtocol::OpenAiCompletions);
assert_eq!(config.provider, "zai");
assert_eq!(config.base_url, "https://api.z.ai/api/paas/v4");
assert!(config.compat.is_some());
}
#[test]
fn test_model_config_minimax() {
let config = ModelConfig::minimax("MiniMax-M3", "MiniMax M3");
assert_eq!(config.api, ApiProtocol::OpenAiCompletions);
assert_eq!(config.provider, "minimax");
assert_eq!(config.base_url, "https://api.minimax.io/v1");
assert_eq!(config.context_window, 1_000_000);
assert!(config.compat.is_some());
}
#[test]
fn test_model_config_deepseek() {
let config = ModelConfig::deepseek("deepseek-v4-flash", "DeepSeek V4 Flash");
assert_eq!(config.api, ApiProtocol::OpenAiCompletions);
assert_eq!(config.provider, "deepseek");
assert_eq!(config.base_url, "https://api.deepseek.com");
assert_eq!(config.context_window, 1_000_000);
assert_eq!(config.max_tokens, 384_000);
assert!(config.reasoning);
assert!(config.compat.is_some());
}
#[test]
fn test_api_protocol_display() {
assert_eq!(
ApiProtocol::AnthropicMessages.to_string(),
"anthropic_messages"
);
assert_eq!(
ApiProtocol::OpenAiCompletions.to_string(),
"openai_completions"
);
assert_eq!(
ApiProtocol::GoogleGenerativeAi.to_string(),
"google_generative_ai"
);
}
#[test]
fn test_cost_config_default() {
let cost = CostConfig::default();
assert_eq!(cost.input_per_million, 0.0);
assert_eq!(cost.output_per_million, 0.0);
}
#[test]
fn unknown_ids_and_gateways_are_unpriced() {
let unpriced = [
ModelConfig::custom(ApiProtocol::BedrockConverseStream, "p", "u", "m", "M"),
ModelConfig::custom(
ApiProtocol::AnthropicMessages,
"anthropic",
"u",
"claude-sonnet-5",
"S",
),
ModelConfig::mock(),
ModelConfig::anthropic("claude-x", "X"),
ModelConfig::openai("gpt-x", "X"),
ModelConfig::openai_responses("gpt-x", "X"),
ModelConfig::local("http://localhost:1234/v1", "m"),
ModelConfig::local("http://localhost:1234/v1", "gpt-5.5"),
ModelConfig::opencode_zen("claude-sonnet-5"),
ModelConfig::opencode_zen("gpt-5.5"),
ModelConfig::opencode_go("glm-5.2"),
ModelConfig::openai_compat("http://h/v1", "m", "p", OpenAiCompat::default()),
ModelConfig::openai_compat("http://h/v1", "gpt-5.5", "openai", OpenAiCompat::openai()),
ModelConfig::ollama("http://localhost:11434/v1", "m"),
ModelConfig::zai("glm-5", "GLM-5"),
ModelConfig::minimax("MiniMax-M1", "M1"),
ModelConfig::qwen("qwen-plus", "Qwen"),
ModelConfig::xai("grok-4-1-fast", "Grok"),
ModelConfig::groq("llama-3.3-70b-versatile", "Llama"),
ModelConfig::deepseek("deepseek-chat", "DeepSeek"),
ModelConfig::mistral("mistral-large-latest", "Mistral"),
ModelConfig::google("gemini-3-pro", "Gemini"),
ModelConfig::meta("muse-spark-1.2-contributor", "Contributor"),
];
for mc in unpriced {
assert!(
mc.cost.is_none(),
"{} ({}) should be unpriced",
mc.id,
mc.provider
);
}
}
#[test]
fn priced_providers_match_the_constructors() {
use crate::provider::PRICED_PROVIDERS;
let looked_up = [
ModelConfig::anthropic("m", "M"),
ModelConfig::openai("m", "M"),
ModelConfig::openai_responses("m", "M"),
ModelConfig::google("m", "M"),
ModelConfig::xai("m", "M"),
ModelConfig::groq("m", "M"),
ModelConfig::deepseek("m", "M"),
ModelConfig::mistral("m", "M"),
ModelConfig::zai("m", "M"),
ModelConfig::minimax("m", "M"),
ModelConfig::qwen("m", "M"),
ModelConfig::meta("m", "M"),
];
let mut providers: Vec<&str> = looked_up.iter().map(|c| c.provider.as_str()).collect();
providers.sort();
providers.dedup();
let mut expected = PRICED_PROVIDERS.to_vec();
expected.sort();
assert_eq!(providers, expected);
assert!(looked_up.iter().all(|c| c.list_priced));
for gateway in [
ModelConfig::opencode_zen("m"),
ModelConfig::opencode_go("m"),
ModelConfig::local("http://h", "m"),
ModelConfig::ollama("http://h", "m"),
ModelConfig::mock(),
] {
assert!(!PRICED_PROVIDERS.contains(&gateway.provider.as_str()));
assert!(!gateway.list_priced);
}
}
#[test]
fn generic_constructors_price_listed_ids() {
crate::provider::prices::enable_bundled();
let pairs = [
(
ModelConfig::anthropic("claude-sonnet-5", "S"),
ModelConfig::claude_sonnet_5(),
),
(
ModelConfig::anthropic("claude-opus-5-5", "O"),
ModelConfig::claude_opus_5_5(),
),
(ModelConfig::openai("gpt-5.5", "G"), ModelConfig::gpt_5_5()),
(
ModelConfig::openai_responses("gpt-5.5", "G"),
ModelConfig::gpt_5_5(),
),
(
ModelConfig::openai("gpt-6-sol", "G"),
ModelConfig::gpt_6_sol(),
),
];
for (generic, preset) in pairs {
assert!(generic.cost.is_some(), "{} unpriced", generic.id);
assert_eq!(generic.cost, preset.cost, "{}", generic.id);
}
}
#[test]
fn with_prices_replaces_only_listed_models() {
let table = crate::provider::PriceTable::from_json_str(
r#"{"schema": 1, "providers": {
"anthropic": {"claude-sonnet-5": {"input": 1.8, "output": 9.0}},
"local": {"qwen3": {"input": 0, "output": 0}}}}"#,
)
.unwrap();
let sonnet = ModelConfig::claude_sonnet_5().with_prices(&table);
assert_eq!(sonnet.cost, Some(CostConfig::new(1.8, 9.0)));
let opus = ModelConfig::claude_opus_5().with_prices(&table);
assert_eq!(opus.cost, ModelConfig::claude_opus_5().cost);
let unknown = ModelConfig::mistral("mistral-large-latest", "M").with_prices(&table);
assert!(unknown.cost.is_none());
let local = ModelConfig::local("http://localhost:1234/v1", "qwen3").with_prices(&table);
assert_eq!(local.cost, Some(CostConfig::new(0.0, 0.0)));
}
#[test]
fn named_presets_are_priced() {
crate::provider::prices::enable_bundled();
for mc in [
ModelConfig::claude_fable_5(),
ModelConfig::claude_fable_5_1(),
ModelConfig::claude_opus_5_5(),
ModelConfig::claude_opus_5(),
ModelConfig::claude_opus_4_8(),
ModelConfig::claude_sonnet_5(),
ModelConfig::claude_haiku_4_5(),
ModelConfig::gpt_5_5(),
ModelConfig::gpt_6_astra(),
ModelConfig::gpt_6_sol(),
ModelConfig::gpt_6_luna(),
ModelConfig::meta("muse-spark-1.2", "Muse Spark 1.2"),
] {
let cost = mc.cost.as_ref();
assert!(
cost.is_some_and(CostConfig::is_configured),
"{} lost its price",
mc.id
);
}
}
#[test]
fn cost_serde_back_compat() {
crate::provider::prices::enable_bundled();
let mut v = serde_json::to_value(ModelConfig::claude_sonnet_5()).unwrap();
let priced: ModelConfig = serde_json::from_value(v.clone()).unwrap();
assert_eq!(priced.cost.as_ref().unwrap().input_per_million, 2.0);
v["cost"] = serde_json::json!({"input_per_million": 0.0, "output_per_million": 0.0});
let legacy: ModelConfig = serde_json::from_value(v.clone()).unwrap();
assert!(legacy.cost.is_none());
let mut free = ModelConfig::mock();
free.cost = Some(CostConfig::new(0.0, 0.0));
let reloaded: ModelConfig =
serde_json::from_value(serde_json::to_value(&free).unwrap()).unwrap();
assert!(reloaded.cost.is_none());
v["cost"] = serde_json::json!({
"input_per_million": 0.0,
"output_per_million": 0.0,
"context_tiers": [{"above_prompt_tokens": 1000, "input_per_million": 1.0,
"output_per_million": 2.0}]
});
let tiered: ModelConfig = serde_json::from_value(v.clone()).unwrap();
assert_eq!(tiered.cost.as_ref().unwrap().context_tiers.len(), 1);
v["cost"] = serde_json::Value::Null;
let null: ModelConfig = serde_json::from_value(v.clone()).unwrap();
assert!(null.cost.is_none());
v.as_object_mut().unwrap().remove("cost");
let missing: ModelConfig = serde_json::from_value(v).unwrap();
assert!(missing.cost.is_none());
let out = serde_json::to_value(ModelConfig::deepseek("d", "D")).unwrap();
assert!(out.get("cost").is_none(), "{out}");
}
}