| 1 | //! API request/response models for `DeepSeek` and OpenAI-compatible endpoints. |
| 2 | |
| 3 | use serde::{Deserialize, Serialize}; |
| 4 | |
| 5 | /// Context window used only for legacy DeepSeek model IDs that do not name a |
| 6 | /// newer V4 alias and do not carry an explicit `*k` suffix. |
| 7 | pub const LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS: u32 = 128_000; |
| 8 | pub const DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS: u32 = 1_000_000; |
| 9 | /// Conservative Kimi Code K3 context baseline. The membership route's real |
| 10 | /// context is plan-tier dependent (verified 2026-07-20 from |
| 11 | /// <https://www.kimi.com/code/docs/en/kimi-code/models>): Moderato gets 256K, |
| 12 | /// while Allegretto and above get up to 1M. Bare `k3` therefore keeps this |
| 13 | /// safe floor everywhere; higher plan entitlements must come from an explicit |
| 14 | /// provider `context_window` configuration or fresh provider facts while |
| 15 | /// preserving the `k3` wire id. |
| 16 | pub const KIMI_CODE_K3_CONTEXT_WINDOW_TOKENS: u32 = 262_144; |
| 17 | /// Kimi K3 context window on the open platform (`kimi-k3` pay-as-you-go). |
| 18 | /// Verified 2026-07-20 from <https://platform.kimi.ai/docs/guide/kimi-k3-quickstart> |
| 19 | /// (1,048,576 tokens). Max output is a separate fact below and must never be |
| 20 | /// conflated with this window. |
| 21 | pub const KIMI_K3_CONTEXT_WINDOW_TOKENS: u32 = 1_048_576; |
| 22 | /// Conservative K3 default generation ceiling. The direct Kimi API defaults |
| 23 | /// `max_completion_tokens` to 131,072, while its documented route maximum is |
| 24 | /// a separate exact-route fact below. Membership and neighboring routes do |
| 25 | /// not inherit that direct-platform maximum. |
| 26 | pub const KIMI_K3_DEFAULT_MAX_COMPLETION_TOKENS: u32 = 131_072; |
| 27 | /// Documented maximum output for the exact direct Kimi K3 API route. |
| 28 | /// |
| 29 | /// Source: <https://platform.kimi.ai/docs/guide/kimi-k3-quickstart> (verified 2026-07-20). |
| 30 | pub const DIRECT_KIMI_K3_MAX_OUTPUT_TOKENS: u32 = 1_048_576; |
| 31 | /// Last-resort compaction trigger when [`context_window_for_model`] returns |
| 32 | /// `None` (an unrecognised model id). v0.8.11 raised this from `50_000` to |
| 33 | /// `102_400` (80% of [`LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS`]) so unknown |
| 34 | /// models inherit the same late-trigger discipline as V4 instead of paying |
| 35 | /// the prefix-cache hit at 5% of the V4 window. Known DeepSeek / Claude |
| 36 | /// models resolve to their own scaled value via |
| 37 | /// `compaction_threshold_for_model` (#664). |
| 38 | pub const DEFAULT_COMPACTION_TOKEN_THRESHOLD: usize = 102_400; |
| 39 | #[cfg(test)] |
| 40 | const COMPACTION_THRESHOLD_PERCENT: u32 = 80; |
| 41 | |
| 42 | // === Core Message Types === |
| 43 | |
| 44 | /// Request payload for sending a message to the API. |
| 45 | #[derive(Debug, Serialize, Deserialize, Clone)] |
| 46 | pub struct MessageRequest { |
| 47 | pub model: String, |
| 48 | pub messages: Vec<Message>, |
| 49 | pub max_tokens: u32, |
| 50 | #[serde(skip_serializing_if = "Option::is_none")] |
| 51 | pub system: Option<SystemPrompt>, |
| 52 | #[serde(skip_serializing_if = "Option::is_none")] |
| 53 | pub tools: Option<Vec<Tool>>, |
| 54 | #[serde(skip_serializing_if = "Option::is_none")] |
| 55 | pub tool_choice: Option<serde_json::Value>, |
| 56 | #[serde(skip_serializing_if = "Option::is_none")] |
| 57 | pub metadata: Option<serde_json::Value>, |
| 58 | #[serde(skip_serializing_if = "Option::is_none")] |
| 59 | pub thinking: Option<serde_json::Value>, |
| 60 | /// DeepSeek reasoning-effort tier: "off" | "low" | "medium" | "high" | "max". |
| 61 | /// Translated by the client into DeepSeek's `reasoning_effort` + `thinking` fields. |
| 62 | #[serde(skip_serializing_if = "Option::is_none")] |
| 63 | pub reasoning_effort: Option<String>, |
| 64 | #[serde(skip_serializing_if = "Option::is_none")] |
| 65 | pub stream: Option<bool>, |
| 66 | #[serde(skip_serializing_if = "Option::is_none")] |
| 67 | pub temperature: Option<f32>, |
| 68 | #[serde(skip_serializing_if = "Option::is_none")] |
| 69 | pub top_p: Option<f32>, |
| 70 | } |
| 71 | |
| 72 | /// System prompt representation (plain text or structured blocks). |
| 73 | #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] |
| 74 | #[serde(untagged)] |
| 75 | pub enum SystemPrompt { |
| 76 | Text(String), |
| 77 | Blocks(Vec<SystemBlock>), |
| 78 | } |
| 79 | |
| 80 | /// A structured system prompt block. |
| 81 | #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] |
| 82 | pub struct SystemBlock { |
| 83 | #[serde(rename = "type")] |
| 84 | pub block_type: String, |
| 85 | pub text: String, |
| 86 | #[serde(skip_serializing_if = "Option::is_none")] |
| 87 | pub cache_control: Option<CacheControl>, |
| 88 | } |
| 89 | |
| 90 | /// OpenAI-compatible image URL payload inside a multimodal message. |
| 91 | #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] |
| 92 | pub struct ImageUrlContent { |
| 93 | pub url: String, |
| 94 | } |
| 95 | |
| 96 | /// A chat message with role and content blocks. |
| 97 | #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] |
| 98 | pub struct Message { |
| 99 | pub role: String, |
| 100 | pub content: Vec<ContentBlock>, |
| 101 | } |
| 102 | |
| 103 | /// Internal role used for assistant text that was visible before a turn was |
| 104 | /// interrupted. It is persisted distinctly from a completed answer. |
| 105 | pub const INTERRUPTED_ASSISTANT_ROLE: &str = "assistant_interrupted"; |
| 106 | pub const INTERRUPTED_ASSISTANT_CONTEXT_PREFIX: &str = "[The following assistant output was interrupted before completion and may be incomplete or wrong]\n"; |
| 107 | |
| 108 | /// A single content block inside a message. |
| 109 | #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] |
| 110 | #[serde(tag = "type")] |
| 111 | pub enum ContentBlock { |
| 112 | #[serde(rename = "text")] |
| 113 | Text { |
| 114 | text: String, |
| 115 | #[serde(skip_serializing_if = "Option::is_none")] |
| 116 | cache_control: Option<CacheControl>, |
| 117 | }, |
| 118 | #[serde(rename = "image_url")] |
| 119 | ImageUrl { image_url: ImageUrlContent }, |
| 120 | #[serde(rename = "thinking")] |
| 121 | Thinking { |
| 122 | thinking: String, |
| 123 | /// Anthropic signed-thinking signature (#3014). Only populated on the |
| 124 | /// native Messages dialect and serde-skipped when absent so OpenAI |
| 125 | /// dialects are unaffected. Anthropic rejects tool loops that drop or |
| 126 | /// modify signed thinking blocks, so replay this verbatim. |
| 127 | #[serde(skip_serializing_if = "Option::is_none", default)] |
| 128 | signature: Option<String>, |
| 129 | }, |
| 130 | #[serde(rename = "tool_use")] |
| 131 | ToolUse { |
| 132 | id: String, |
| 133 | name: String, |
| 134 | input: serde_json::Value, |
| 135 | #[serde(skip_serializing_if = "Option::is_none")] |
| 136 | caller: Option<ToolCaller>, |
| 137 | }, |
| 138 | #[serde(rename = "tool_result")] |
| 139 | ToolResult { |
| 140 | tool_use_id: String, |
| 141 | content: String, |
| 142 | #[serde(skip_serializing_if = "Option::is_none")] |
| 143 | is_error: Option<bool>, |
| 144 | #[serde(skip_serializing_if = "Option::is_none")] |
| 145 | content_blocks: Option<Vec<serde_json::Value>>, |
| 146 | }, |
| 147 | #[serde(rename = "server_tool_use")] |
| 148 | ServerToolUse { |
| 149 | id: String, |
| 150 | name: String, |
| 151 | input: serde_json::Value, |
| 152 | }, |
| 153 | #[serde(rename = "tool_search_tool_result")] |
| 154 | ToolSearchToolResult { |
| 155 | tool_use_id: String, |
| 156 | content: serde_json::Value, |
| 157 | }, |
| 158 | #[serde(rename = "code_execution_tool_result")] |
| 159 | CodeExecutionToolResult { |
| 160 | tool_use_id: String, |
| 161 | content: serde_json::Value, |
| 162 | }, |
| 163 | } |
| 164 | |
| 165 | /// Cache control metadata for tool definitions and blocks. |
| 166 | #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] |
| 167 | pub struct CacheControl { |
| 168 | #[serde(rename = "type")] |
| 169 | pub cache_type: String, |
| 170 | } |
| 171 | |
| 172 | /// Metadata describing who invoked a tool call. |
| 173 | #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] |
| 174 | pub struct ToolCaller { |
| 175 | #[serde(rename = "type")] |
| 176 | pub caller_type: String, |
| 177 | #[serde(skip_serializing_if = "Option::is_none")] |
| 178 | pub tool_id: Option<String>, |
| 179 | } |
| 180 | |
| 181 | /// Tool definition exposed to the model. |
| 182 | #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] |
| 183 | pub struct Tool { |
| 184 | #[serde(rename = "type", skip_serializing_if = "Option::is_none")] |
| 185 | pub tool_type: Option<String>, |
| 186 | pub name: String, |
| 187 | pub description: String, |
| 188 | pub input_schema: serde_json::Value, |
| 189 | #[serde(skip_serializing_if = "Option::is_none")] |
| 190 | pub allowed_callers: Option<Vec<String>>, |
| 191 | #[serde(skip_serializing_if = "Option::is_none")] |
| 192 | pub defer_loading: Option<bool>, |
| 193 | #[serde(skip_serializing_if = "Option::is_none")] |
| 194 | pub input_examples: Option<Vec<serde_json::Value>>, |
| 195 | #[serde(skip_serializing_if = "Option::is_none")] |
| 196 | pub strict: Option<bool>, |
| 197 | #[serde(skip_serializing_if = "Option::is_none")] |
| 198 | pub cache_control: Option<CacheControl>, |
| 199 | } |
| 200 | |
| 201 | /// Container metadata for code-execution style server tools. |
| 202 | #[derive(Debug, Serialize, Deserialize, Clone)] |
| 203 | pub struct ContainerInfo { |
| 204 | pub id: String, |
| 205 | #[serde(skip_serializing_if = "Option::is_none")] |
| 206 | pub expires_at: Option<String>, |
| 207 | } |
| 208 | |
| 209 | /// Server-side tool usage counters. |
| 210 | #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)] |
| 211 | pub struct ServerToolUsage { |
| 212 | #[serde(skip_serializing_if = "Option::is_none")] |
| 213 | pub code_execution_requests: Option<u32>, |
| 214 | #[serde(skip_serializing_if = "Option::is_none")] |
| 215 | pub tool_search_requests: Option<u32>, |
| 216 | } |
| 217 | |
| 218 | /// Response payload for a message request. |
| 219 | #[derive(Debug, Serialize, Deserialize, Clone)] |
| 220 | pub struct MessageResponse { |
| 221 | pub id: String, |
| 222 | pub r#type: String, |
| 223 | pub role: String, |
| 224 | pub content: Vec<ContentBlock>, |
| 225 | pub model: String, |
| 226 | pub stop_reason: Option<String>, |
| 227 | pub stop_sequence: Option<String>, |
| 228 | #[serde(skip_serializing_if = "Option::is_none")] |
| 229 | pub container: Option<ContainerInfo>, |
| 230 | pub usage: Usage, |
| 231 | } |
| 232 | |
| 233 | /// Token usage metadata for a response. |
| 234 | #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)] |
| 235 | pub struct Usage { |
| 236 | pub input_tokens: u32, |
| 237 | pub output_tokens: u32, |
| 238 | #[serde(skip_serializing_if = "Option::is_none")] |
| 239 | pub prompt_cache_hit_tokens: Option<u32>, |
| 240 | #[serde(skip_serializing_if = "Option::is_none")] |
| 241 | pub prompt_cache_miss_tokens: Option<u32>, |
| 242 | /// Cache-creation / cache-write tokens (Anthropic `cache_creation_input_tokens`). |
| 243 | /// Billed at the cache-write rate when the pricing row publishes one (#4318). |
| 244 | #[serde(skip_serializing_if = "Option::is_none")] |
| 245 | pub prompt_cache_write_tokens: Option<u32>, |
| 246 | #[serde(skip_serializing_if = "Option::is_none")] |
| 247 | pub reasoning_tokens: Option<u32>, |
| 248 | /// Approximate input tokens spent re-sending prior `reasoning_content` |
| 249 | /// across user-message boundaries in DeepSeek V4 thinking-mode tool-calling |
| 250 | /// turns (V4 §5.1.1 "Interleaved Thinking"). Estimated client-side at |
| 251 | /// ~4 chars/token from the outgoing request body, before the model sees it. |
| 252 | #[serde(skip_serializing_if = "Option::is_none")] |
| 253 | pub reasoning_replay_tokens: Option<u32>, |
| 254 | #[serde(skip_serializing_if = "Option::is_none")] |
| 255 | pub server_tool_use: Option<ServerToolUsage>, |
| 256 | } |
| 257 | |
| 258 | /// Map known models to their approximate context window sizes. |
| 259 | /// |
| 260 | /// Lookup order: |
| 261 | /// 1. An explicit `_Nk` suffix in the model name, for **any** vendor. This |
| 262 | /// lets self-hosted deployments advertise their window through the served |
| 263 | /// model name (e.g. a vLLM `--served-model-name qwen3-32b-256k`), which is |
| 264 | /// the only signal we have for non-DeepSeek/Claude models. The 1000-token |
| 265 | /// approximation is fine for compaction-threshold math. |
| 266 | /// 2. DeepSeek vendor heuristics (V4 family -> 1M, legacy -> 128K). |
| 267 | /// 3. Claude -> 200K. |
| 268 | #[must_use] |
| 269 | pub fn context_window_for_model(model: &str) -> Option<u32> { |
| 270 | if let Some(window) = crate::model_catalog::resolved_context_window(model) { |
| 271 | return Some(window); |
| 272 | } |
| 273 | let lower = model.to_lowercase(); |
| 274 | if let Some(explicit_window) = explicit_context_window_hint(&lower) { |
| 275 | return Some(explicit_window); |
| 276 | } |
| 277 | if lower.contains("deepseek") { |
| 278 | if lower.contains("v4") { |
| 279 | return Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS); |
| 280 | } |
| 281 | return Some(LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS); |
| 282 | } |
| 283 | if is_openai_gpt_55_api_model(&lower) || is_openai_gpt_56_api_model(&lower) { |
| 284 | return Some(1_050_000); |
| 285 | } |
| 286 | if is_openai_codex_model(&lower) { |
| 287 | return Some(400_000); |
| 288 | } |
| 289 | if let Some(window) = known_context_window_for_model(&lower) { |
| 290 | return Some(window); |
| 291 | } |
| 292 | if lower.contains("claude") { |
| 293 | return Some(200_000); |
| 294 | } |
| 295 | None |
| 296 | } |
| 297 | |
| 298 | fn known_context_window_for_model(model_lower: &str) -> Option<u32> { |
| 299 | match model_lower { |
| 300 | // OpenAI API model docs, verified 2026-06-12: |
| 301 | // https://developers.openai.com/api/docs/models/gpt-5.5 |
| 302 | // Family aliases and snapshots are handled by |
| 303 | // `is_openai_gpt_55_api_model` before this table. |
| 304 | // OpenAI Codex model docs, verified 2026-06-12: |
| 305 | // https://developers.openai.com/api/docs/models/gpt-5-codex |
| 306 | // https://developers.openai.com/api/docs/models/gpt-5.3-codex |
| 307 | "gpt-5-codex" | "gpt-5.3-codex" => Some(400_000), |
| 308 | // Anthropic 4.6+ models carry a 1M window; Haiku stays at 200K (#3014). |
| 309 | "claude-opus-4-8" | "claude-sonnet-4-6" | "claude-sonnet-5" | "claude-fable-5" => { |
| 310 | Some(1_000_000) |
| 311 | } |
| 312 | "claude-haiku-4-5" => Some(200_000), |
| 313 | "trinity-mini" => Some(128_000), |
| 314 | "arcee-ai/trinity-large-thinking" | "trinity-large-thinking" | "trinity-large-preview" => { |
| 315 | Some(262_144) |
| 316 | } |
| 317 | "google/gemma-4-31b-it" |
| 318 | | "google/gemma-4-31b-it:free" |
| 319 | | "google/gemma-4-26b-a4b-it" |
| 320 | | "google/gemma-4-26b-a4b-it:free" |
| 321 | | "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free" |
| 322 | | "qwen/qwen3.6-35b-a3b" |
| 323 | | "qwen/qwen3.6-max-preview" |
| 324 | | "qwen/qwen3.6-27b" |
| 325 | | "tencent/hy3-preview" => Some(262_144), |
| 326 | // Official Kimi K3 platform pricing (2026-07-20): |
| 327 | // https://platform.kimi.ai/docs/guide/kimi-k3-quickstart — 1,048,576 context |
| 328 | // for the open platform. |
| 329 | "moonshotai/kimi-k3" | "kimi-k3" | "opencode-go/kimi-k3" => { |
| 330 | Some(KIMI_K3_CONTEXT_WINDOW_TOKENS) |
| 331 | } |
| 332 | // Bare `k3` is the Kimi Code membership route id whose context is |
| 333 | // plan-tier dependent (256K on lower tiers, up to 1M on higher ones) |
| 334 | // — keep the safe floor, and never fall through to the 128K legacy |
| 335 | // default. |
| 336 | "k3" => Some(KIMI_CODE_K3_CONTEXT_WINDOW_TOKENS), |
| 337 | "moonshotai/kimi-k2.7-code" |
| 338 | | "moonshotai/kimi-k2.6" |
| 339 | | "moonshotai/kimi-k2.6:free" |
| 340 | | "kimi-k2.7-code" |
| 341 | | "kimi-k2.6" |
| 342 | | "kimi-for-coding" |
| 343 | | "kimi-for-coding-highspeed" => Some(262_144), |
| 344 | "minimax-m2.7" |
| 345 | | "minimax/minimax-m2.7" |
| 346 | | "minimax-m2.7-highspeed" |
| 347 | | "minimax-m2.5" |
| 348 | | "minimax-m2.5-highspeed" |
| 349 | | "minimax-m2.1" |
| 350 | | "minimax-m2.1-highspeed" |
| 351 | | "minimax-m2" => Some(204_800), |
| 352 | "z-ai/glm-5.1" | "z-ai/glm-5v-turbo" | "glm-5.1" | "glm-5v-turbo" => Some(202_752), |
| 353 | "z-ai/glm-5-turbo" | "glm-5-turbo" => Some(202_752), |
| 354 | // GLM-5.3 limits are inherited from GLM-5.2 pending official Z.ai |
| 355 | // release metadata (see `INHERITED FROM glm-5.2` in config/models.rs). |
| 356 | "z-ai/glm-5.2" | "glm-5.2" | "z-ai/glm-5.3" | "glm-5.3" => Some(1_000_000), |
| 357 | "minimax/minimax-m3" | "minimax-m3" | "qwen/qwen3.6-flash" | "qwen/qwen3.6-plus" => { |
| 358 | Some(1_000_000) |
| 359 | } |
| 360 | // Alibaba Cloud Model Studio (Token Plan console + curated catalog, |
| 361 | // verified 2026-08-03): ~1M context. Never fall through to the 128K |
| 362 | // legacy default — that number is the generation ceiling, not the window. |
| 363 | "qwen3.8-max" |
| 364 | | "qwen3.8-max-preview" |
| 365 | | "qwen3.7-plus" |
| 366 | | "qwen3.7-max" |
| 367 | | "qwen3.6-flash" => Some(1_000_000), |
| 368 | "nvidia/nemotron-3-ultra-550b-a55b" | "nvidia/nemotron-3-ultra-550b-a55b:free" => { |
| 369 | Some(1_000_000) |
| 370 | } |
| 371 | "xiaomi/mimo-v2.5-pro" |
| 372 | | "xiaomi/mimo-v2.5" |
| 373 | | "mimo-v2.5-pro" |
| 374 | | "mimo-v2.5-pro-ultraspeed" |
| 375 | | "mimo-v2.5" => Some(1_000_000), |
| 376 | "mimo-v2.5-asr" |
| 377 | | "mimo-v2.5-tts" |
| 378 | | "mimo-v2.5-tts-voicedesign" |
| 379 | | "mimo-v2.5-tts-voiceclone" |
| 380 | | "mimo-v2-tts" => Some(8_000), |
| 381 | "grok-4.5" => Some(500_000), |
| 382 | "grok-4.3" => Some(1_000_000), |
| 383 | "grok-build" => Some(512_000), |
| 384 | "grok-composer-2.5-fast" => Some(200_000), |
| 385 | "grok-4.20-0309-reasoning" | "grok-4.20-0309-non-reasoning" => Some(2_000_000), |
| 386 | "muse-spark-1.1" | "muse-spark-1.2" | "muse-spark-1.2-contributor" => Some(1_000_000), |
| 387 | _ => None, |
| 388 | } |
| 389 | } |
| 390 | |
| 391 | #[must_use] |
| 392 | pub fn max_output_tokens_for_model(model: &str) -> Option<u32> { |
| 393 | if let Some(max_output) = crate::model_catalog::resolved_max_output(model) { |
| 394 | return Some(max_output); |
| 395 | } |
| 396 | let lower = model.to_lowercase(); |
| 397 | if lower.contains("deepseek") && lower.contains("v4") { |
| 398 | return Some(384_000); |
| 399 | } |
| 400 | if is_openai_gpt_55_api_model(&lower) |
| 401 | || is_openai_gpt_56_api_model(&lower) |
| 402 | || is_openai_codex_model(&lower) |
| 403 | { |
| 404 | return Some(128_000); |
| 405 | } |
| 406 | match lower.as_str() { |
| 407 | "gpt-5-codex" | "gpt-5.3-codex" => Some(128_000), |
| 408 | // claude-sonnet-4-6 max output raised 64K -> 128K per |
| 409 | // https://platform.claude.com/docs/en/about-claude/models/overview |
| 410 | // (2026-07-09 audit). |
| 411 | "claude-opus-4-8" | "claude-sonnet-4-6" | "claude-sonnet-5" | "claude-fable-5" => { |
| 412 | Some(128_000) |
| 413 | } |
| 414 | "claude-haiku-4-5" => Some(64_000), |
| 415 | "arcee-ai/trinity-large-thinking" | "trinity-large-thinking" => Some(262_144), |
| 416 | // Keep the generic/model-id lookup at K3's conservative documented |
| 417 | // default generation ceiling. The exact direct route's 1M maximum is |
| 418 | // applied later with endpoint-aware provenance; membership and |
| 419 | // neighboring routes must not inherit it. |
| 420 | "moonshotai/kimi-k3" | "kimi-k3" | "k3" | "opencode-go/kimi-k3" => { |
| 421 | Some(KIMI_K3_DEFAULT_MAX_COMPLETION_TOKENS) |
| 422 | } |
| 423 | // Kimi K2.7 Code has a 256K context window but its documented default |
| 424 | // maximum generation is 32K. Keeping those separate prevents the |
| 425 | // input budget from collapsing to the 1K emergency floor (#4368). The |
| 426 | // direct-platform value matches the provider-reported bundled |
| 427 | // catalog. The Kimi Code membership ids (`kimi-for-coding` family) |
| 428 | // are deliberately absent here: the membership catalog is the source |
| 429 | // of truth for their limits and no client-side output ceiling is |
| 430 | // claimed, so they fall back to the generic default. |
| 431 | "moonshotai/kimi-k2.7-code" | "moonshotai/kimi-k2.6" | "kimi-k2.7-code" | "kimi-k2.6" => { |
| 432 | Some(32_768) |
| 433 | } |
| 434 | "minimax/minimax-m3" | "minimax-m3" => Some(524_288), |
| 435 | // Alibaba's published limit is 65,536 output tokens; the earlier |
| 436 | // 262,140 mirrored the context window (data-entry smell flagged by |
| 437 | // MODEL_PROVIDER_AUDIT A2/D-7, vendor-verified 2026-07-12). |
| 438 | "qwen/qwen3.6-35b-a3b" |
| 439 | | "qwen/qwen3.6-27b" |
| 440 | | "qwen/qwen3.6-flash" |
| 441 | | "qwen/qwen3.6-max-preview" |
| 442 | | "qwen/qwen3.6-plus" => Some(65_536), |
| 443 | // Model Studio: 128K is the generation ceiling, not the context window. |
| 444 | "qwen3.8-max" | "qwen3.8-max-preview" => Some(131_072), |
| 445 | "qwen3.7-plus" | "qwen3.7-max" | "qwen3.6-flash" => Some(65_536), |
| 446 | "z-ai/glm-5.1" | "z-ai/glm-5.2" | "z-ai/glm-5.3" | "z-ai/glm-5-turbo" | "glm-5.1" |
| 447 | | "glm-5.2" | "glm-5.3" | "glm-5-turbo" => Some(131_072), |
| 448 | "xiaomi/mimo-v2.5-pro" |
| 449 | | "xiaomi/mimo-v2.5" |
| 450 | | "mimo-v2.5-pro" |
| 451 | | "mimo-v2.5-pro-ultraspeed" |
| 452 | | "mimo-v2.5" => Some(131_072), |
| 453 | "mimo-v2.5-asr" => Some(2_048), |
| 454 | "mimo-v2.5-tts" |
| 455 | | "mimo-v2.5-tts-voicedesign" |
| 456 | | "mimo-v2.5-tts-voiceclone" |
| 457 | | "mimo-v2-tts" => Some(8_192), |
| 458 | "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free" => Some(65_536), |
| 459 | "nvidia/nemotron-3-ultra-550b-a55b" => Some(16_384), |
| 460 | "nvidia/nemotron-3-ultra-550b-a55b:free" => Some(65_536), |
| 461 | "google/gemma-4-31b-it" => Some(16_384), |
| 462 | "google/gemma-4-31b-it:free" | "google/gemma-4-26b-a4b-it:free" => Some(32_768), |
| 463 | "muse-spark-1.1" | "muse-spark-1.2" | "muse-spark-1.2-contributor" => Some(32_000), |
| 464 | _ => None, |
| 465 | } |
| 466 | } |
| 467 | |
| 468 | #[must_use] |
| 469 | pub fn model_supports_reasoning(model: &str) -> bool { |
| 470 | if let Some(supports_reasoning) = crate::model_catalog::resolved_supports_reasoning(model) { |
| 471 | return supports_reasoning; |
| 472 | } |
| 473 | let lower = model.to_lowercase(); |
| 474 | if lower.contains("deepseek") && lower.contains("v4") { |
| 475 | return true; |
| 476 | } |
| 477 | // #3016 plus the 2026 Kimi Code K2.7 update: Moonshot-native Kimi IDs, |
| 478 | // including the stable `kimi-for-coding` coding route, emit |
| 479 | // reasoning_content that must stay out of answer prose. |
| 480 | if lower.starts_with("kimi-") { |
| 481 | return true; |
| 482 | } |
| 483 | matches!( |
| 484 | lower.as_str(), |
| 485 | "claude-opus-4-8" |
| 486 | | "claude-sonnet-4-6" |
| 487 | | "claude-sonnet-5" |
| 488 | | "claude-fable-5" |
| 489 | | "gpt-5-codex" |
| 490 | | "gpt-5.3-codex" |
| 491 | | "trinity-mini" |
| 492 | | "arcee-ai/trinity-large-thinking" |
| 493 | | "trinity-large-thinking" |
| 494 | | "thinkingmachines/inkling" |
| 495 | | "google/gemma-4-31b-it" |
| 496 | | "google/gemma-4-31b-it:free" |
| 497 | | "google/gemma-4-26b-a4b-it" |
| 498 | | "google/gemma-4-26b-a4b-it:free" |
| 499 | | "moonshotai/kimi-k2.7-code" |
| 500 | | "moonshotai/kimi-k2.6" |
| 501 | | "moonshotai/kimi-k2.6:free" |
| 502 | | "kimi-k2.7-code" |
| 503 | | "kimi-k2.6" |
| 504 | | "kimi-for-coding" |
| 505 | | "minimax/minimax-m3" |
| 506 | | "minimax/minimax-m2.7" |
| 507 | | "minimax-m3" |
| 508 | | "minimax-m2.7" |
| 509 | | "minimax-m2.7-highspeed" |
| 510 | | "minimax-m2.5" |
| 511 | | "minimax-m2.5-highspeed" |
| 512 | | "minimax-m2.1" |
| 513 | | "minimax-m2.1-highspeed" |
| 514 | | "minimax-m2" |
| 515 | | "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free" |
| 516 | | "nvidia/nemotron-3-ultra-550b-a55b" |
| 517 | | "nvidia/nemotron-3-ultra-550b-a55b:free" |
| 518 | | "qwen/qwen3.6-flash" |
| 519 | | "qwen/qwen3.6-35b-a3b" |
| 520 | | "qwen/qwen3.6-max-preview" |
| 521 | | "qwen/qwen3.6-27b" |
| 522 | | "qwen/qwen3.6-plus" |
| 523 | | "qwen/qwen3.7-plus" |
| 524 | // Bare qwen3.x ids are Alibaba Cloud Model Studio's own model ids |
| 525 | // (Token Plan / Coding Plan catalogs). Per Model Studio's |
| 526 | // deep-thinking docs these are hybrid-thinking models that stream |
| 527 | // `reasoning_content` (OpenAI dialect) or thinking blocks |
| 528 | // (Anthropic dialect); qwen3.7/3.6/3.5 families default thinking |
| 529 | // ON server-side. |
| 530 | | "qwen3.8-max" |
| 531 | | "qwen3.8-max-preview" |
| 532 | | "qwen3.7-max" |
| 533 | | "qwen3.7-plus" |
| 534 | | "qwen3.6-plus" |
| 535 | | "qwen3.6-flash" |
| 536 | | "qwen3.5-plus" |
| 537 | | "qwen3.5-flash" |
| 538 | | "tencent/hy3-preview" |
| 539 | | "xiaomi/mimo-v2.5-pro" |
| 540 | | "xiaomi/mimo-v2.5" |
| 541 | | "mimo-v2.5-pro" |
| 542 | | "mimo-v2.5-pro-ultraspeed" |
| 543 | | "mimo-v2.5" |
| 544 | | "z-ai/glm-5.1" |
| 545 | | "z-ai/glm-5.2" |
| 546 | | "z-ai/glm-5.3" |
| 547 | | "z-ai/glm-5-turbo" |
| 548 | | "glm-5.1" |
| 549 | | "glm-5.2" |
| 550 | | "glm-5.3" |
| 551 | | "glm-5-turbo" |
| 552 | | "grok-4.5" |
| 553 | | "grok-4.3" |
| 554 | | "grok-build" |
| 555 | | "grok-4.20-0309-reasoning" |
| 556 | | "muse-spark-1.1" |
| 557 | | "muse-spark-1.2" |
| 558 | | "muse-spark-1.2-contributor" |
| 559 | ) || is_openai_gpt_55_api_model(&lower) |
| 560 | || is_openai_gpt_56_api_model(&lower) |
| 561 | || is_openai_codex_model(&lower) |
| 562 | } |
| 563 | |
| 564 | /// Contributor tier of Muse Spark 1.2 is a distinct selectable id with |
| 565 | /// its own wire model (`muse-spark-1.2-contributor`) and cheaper billing in |
| 566 | /// exchange for training-data opt-in. Do not collapse it to the standard tier. |
| 567 | #[must_use] |
| 568 | pub fn effective_muse_wire_id(model: &str) -> &str { |
| 569 | model |
| 570 | } |
| 571 | |
| 572 | #[must_use] |
| 573 | pub(crate) fn model_is_openai_reasoning_family(model: &str) -> bool { |
| 574 | let lower = model.to_lowercase(); |
| 575 | is_openai_gpt_55_api_model(&lower) |
| 576 | || is_openai_gpt_56_api_model(&lower) |
| 577 | || is_openai_codex_model(&lower) |
| 578 | } |
| 579 | |
| 580 | fn is_openai_gpt_55_api_model(model_lower: &str) -> bool { |
| 581 | matches!(model_lower, "gpt-5.5" | "gpt-5.5-pro") |
| 582 | || has_date_snapshot_suffix(model_lower, "gpt-5.5-") |
| 583 | || has_date_snapshot_suffix(model_lower, "gpt-5.5-pro-") |
| 584 | } |
| 585 | |
| 586 | pub(crate) fn is_openai_gpt_56_api_model(model_lower: &str) -> bool { |
| 587 | matches!( |
| 588 | model_lower, |
| 589 | "gpt-5.6" | "gpt-5.6-sol" | "gpt-5.6-terra" | "gpt-5.6-luna" |
| 590 | ) |
| 591 | } |
| 592 | |
| 593 | fn is_openai_codex_model(model_lower: &str) -> bool { |
| 594 | matches!( |
| 595 | model_lower, |
| 596 | "gpt-5-codex" |
| 597 | | "gpt-5.1-codex" |
| 598 | | "gpt-5.1-codex-mini" |
| 599 | | "gpt-5.1-codex-max" |
| 600 | | "gpt-5.2-codex" |
| 601 | | "gpt-5.3-codex" |
| 602 | | "codex-gpt-5.5" |
| 603 | | "chatgpt-gpt-5.5" |
| 604 | | "gpt-5.5-codex" |
| 605 | | "gpt-5.5-codex-preview" |
| 606 | | "codex-gpt-5.5-preview" |
| 607 | | "chatgpt-gpt-5.5-preview" |
| 608 | ) |
| 609 | } |
| 610 | |
| 611 | pub(crate) fn has_date_snapshot_suffix(model_lower: &str, prefix: &str) -> bool { |
| 612 | let Some(rest) = model_lower.strip_prefix(prefix) else { |
| 613 | return false; |
| 614 | }; |
| 615 | let bytes = rest.as_bytes(); |
| 616 | bytes.len() == 10 |
| 617 | && bytes[4] == b'-' |
| 618 | && bytes[7] == b'-' |
| 619 | && bytes |
| 620 | .iter() |
| 621 | .enumerate() |
| 622 | .all(|(idx, byte)| idx == 4 || idx == 7 || byte.is_ascii_digit()) |
| 623 | } |
| 624 | |
| 625 | /// Parse an explicit `_Nk` context-window hint from a model name (vendor |
| 626 | /// agnostic). Returns the window in tokens for `N` in `8..=1024`. |
| 627 | fn explicit_context_window_hint(model_lower: &str) -> Option<u32> { |
| 628 | let bytes = model_lower.as_bytes(); |
| 629 | let mut i = 0usize; |
| 630 | while i < bytes.len() { |
| 631 | if bytes[i].is_ascii_digit() { |
| 632 | let start = i; |
| 633 | while i < bytes.len() && bytes[i].is_ascii_digit() { |
| 634 | i += 1; |
| 635 | } |
| 636 | if i >= bytes.len() || bytes[i] != b'k' { |
| 637 | continue; |
| 638 | } |
| 639 | |
| 640 | let before_ok = start == 0 || !bytes[start - 1].is_ascii_alphanumeric(); |
| 641 | let after_ok = i + 1 >= bytes.len() || !bytes[i + 1].is_ascii_alphanumeric(); |
| 642 | if !before_ok || !after_ok { |
| 643 | continue; |
| 644 | } |
| 645 | |
| 646 | if let Ok(kilo_tokens) = model_lower[start..i].parse::<u32>() |
| 647 | && (8..=1024).contains(&kilo_tokens) |
| 648 | { |
| 649 | return Some(kilo_tokens.saturating_mul(1000)); |
| 650 | } |
| 651 | } else { |
| 652 | i += 1; |
| 653 | } |
| 654 | } |
| 655 | None |
| 656 | } |
| 657 | |
| 658 | /// Derive a compaction token threshold from model context and a caller-supplied |
| 659 | /// percentage. |
| 660 | #[must_use] |
| 661 | #[cfg(test)] |
| 662 | pub fn compaction_threshold_for_model_at_percent(model: &str, percent: f64) -> usize { |
| 663 | let Some(window) = context_window_for_model(model) else { |
| 664 | return DEFAULT_COMPACTION_TOKEN_THRESHOLD; |
| 665 | }; |
| 666 | |
| 667 | let percent = percent.clamp(10.0, 100.0); |
| 668 | let threshold = (f64::from(window) * percent / 100.0).round(); |
| 669 | let threshold = if threshold.is_finite() && threshold > 0.0 { |
| 670 | threshold as u64 |
| 671 | } else { |
| 672 | u64::from(window) * u64::from(COMPACTION_THRESHOLD_PERCENT) / 100 |
| 673 | }; |
| 674 | usize::try_from(threshold).unwrap_or(DEFAULT_COMPACTION_TOKEN_THRESHOLD) |
| 675 | } |
| 676 | |
| 677 | /// Whether auto-compaction should be enabled when the user did not explicitly |
| 678 | /// configure it. Known model windows default automatic continuity on; an |
| 679 | /// explicit `auto_compact = false` remains authoritative at the call sites. |
| 680 | #[must_use] |
| 681 | #[cfg(test)] |
| 682 | pub fn auto_compact_default_for_model(model: &str) -> bool { |
| 683 | context_window_for_model(model).is_some() |
| 684 | } |
| 685 | |
| 686 | // === Streaming Structures === |
| 687 | |
| 688 | #[allow(dead_code)] |
| 689 | #[derive(Debug, Deserialize, Clone)] |
| 690 | #[serde(tag = "type")] |
| 691 | /// Streaming event types for SSE responses. |
| 692 | pub enum StreamEvent { |
| 693 | #[serde(rename = "message_start")] |
| 694 | MessageStart { message: MessageResponse }, |
| 695 | #[serde(rename = "content_block_start")] |
| 696 | ContentBlockStart { |
| 697 | index: u32, |
| 698 | content_block: ContentBlockStart, |
| 699 | }, |
| 700 | #[serde(rename = "content_block_delta")] |
| 701 | ContentBlockDelta { index: u32, delta: Delta }, |
| 702 | #[serde(rename = "content_block_stop")] |
| 703 | ContentBlockStop { index: u32 }, |
| 704 | #[serde(rename = "message_delta")] |
| 705 | MessageDelta { |
| 706 | delta: MessageDelta, |
| 707 | usage: Option<Usage>, |
| 708 | }, |
| 709 | #[serde(rename = "message_stop")] |
| 710 | MessageStop, |
| 711 | #[serde(rename = "ping")] |
| 712 | Ping, |
| 713 | /// Anthropic SSE error event (#3014). |
| 714 | #[serde(rename = "error")] |
| 715 | Error { error: serde_json::Value }, |
| 716 | } |
| 717 | |
| 718 | #[allow(dead_code)] |
| 719 | #[derive(Debug, Deserialize, Clone)] |
| 720 | #[serde(tag = "type")] |
| 721 | /// Content block types used in streaming starts. |
| 722 | pub enum ContentBlockStart { |
| 723 | #[serde(rename = "text")] |
| 724 | Text { text: String }, |
| 725 | #[serde(rename = "thinking")] |
| 726 | Thinking { thinking: String }, |
| 727 | #[serde(rename = "tool_use")] |
| 728 | ToolUse { |
| 729 | id: String, |
| 730 | name: String, |
| 731 | input: serde_json::Value, // usually empty or partial |
| 732 | #[serde(skip_serializing_if = "Option::is_none")] |
| 733 | caller: Option<ToolCaller>, |
| 734 | }, |
| 735 | #[serde(rename = "server_tool_use")] |
| 736 | ServerToolUse { |
| 737 | id: String, |
| 738 | name: String, |
| 739 | input: serde_json::Value, |
| 740 | }, |
| 741 | } |
| 742 | |
| 743 | // Variant names match legacy streaming spec, suppressing style warning |
| 744 | #[allow(clippy::enum_variant_names)] |
| 745 | #[derive(Debug, Deserialize, Clone)] |
| 746 | #[serde(tag = "type")] |
| 747 | /// Delta events emitted during streaming responses. |
| 748 | pub enum Delta { |
| 749 | #[serde(rename = "text_delta")] |
| 750 | TextDelta { text: String }, |
| 751 | #[serde(rename = "thinking_delta")] |
| 752 | ThinkingDelta { thinking: String }, |
| 753 | #[serde(rename = "input_json_delta")] |
| 754 | InputJsonDelta { partial_json: String }, |
| 755 | /// Anthropic signed-thinking signature delta (#3014); arrives at the end |
| 756 | /// of a thinking block on the native Messages stream. |
| 757 | #[serde(rename = "signature_delta")] |
| 758 | SignatureDelta { signature: String }, |
| 759 | } |
| 760 | |
| 761 | #[allow(dead_code)] |
| 762 | #[derive(Debug, Deserialize, Clone)] |
| 763 | /// Delta payload for message-level updates. |
| 764 | pub struct MessageDelta { |
| 765 | pub stop_reason: Option<String>, |
| 766 | pub stop_sequence: Option<String>, |
| 767 | } |
| 768 | |
| 769 | #[cfg(test)] |
| 770 | mod tests { |
| 771 | use super::*; |
| 772 | use std::collections::BTreeMap; |
| 773 | |
| 774 | #[test] |
| 775 | fn interrupted_assistant_role_round_trips_as_distinct_session_item() { |
| 776 | let message = Message { |
| 777 | role: INTERRUPTED_ASSISTANT_ROLE.to_string(), |
| 778 | content: vec![ContentBlock::Text { |
| 779 | text: "partial output".to_string(), |
| 780 | cache_control: None, |
| 781 | }], |
| 782 | }; |
| 783 | let encoded = serde_json::to_string(&message).expect("message should serialize"); |
| 784 | let decoded: Message = serde_json::from_str(&encoded).expect("message should deserialize"); |
| 785 | assert_eq!(decoded, message); |
| 786 | assert_ne!(decoded.role, "assistant"); |
| 787 | } |
| 788 | |
| 789 | #[test] |
| 790 | fn v4_snapshots_preserve_context_window() { |
| 791 | // v-series snapshots get 1M context since they contain "v4" |
| 792 | assert_eq!( |
| 793 | context_window_for_model("deepseek-v4-flash-20260423"), |
| 794 | Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS) |
| 795 | ); |
| 796 | assert_eq!( |
| 797 | context_window_for_model("deepseek-v4-pro-20260423"), |
| 798 | Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS) |
| 799 | ); |
| 800 | } |
| 801 | |
| 802 | #[test] |
| 803 | fn unknown_legacy_deepseek_models_map_to_128k_context_window() { |
| 804 | assert_eq!( |
| 805 | context_window_for_model("deepseek-coder"), |
| 806 | Some(LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS) |
| 807 | ); |
| 808 | assert_eq!( |
| 809 | context_window_for_model("deepseek-v3.2-0324"), |
| 810 | Some(LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS) |
| 811 | ); |
| 812 | } |
| 813 | |
| 814 | #[test] |
| 815 | fn deepseek_v4_models_map_to_1m_context_window() { |
| 816 | assert_eq!( |
| 817 | context_window_for_model("deepseek-v4-pro"), |
| 818 | Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS) |
| 819 | ); |
| 820 | assert_eq!( |
| 821 | context_window_for_model("deepseek-v4-flash"), |
| 822 | Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS) |
| 823 | ); |
| 824 | assert_eq!( |
| 825 | context_window_for_model("deepseek-ai/deepseek-v4-pro"), |
| 826 | Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS) |
| 827 | ); |
| 828 | } |
| 829 | |
| 830 | #[test] |
| 831 | fn recent_openrouter_large_models_have_static_windows() { |
| 832 | for (model, expected_window) in [ |
| 833 | ("arcee-ai/trinity-large-thinking", 262_144), |
| 834 | ("trinity-large-thinking", 262_144), |
| 835 | (concat!("qwen/", "qwen3.6-flash"), 1_000_000), |
| 836 | (concat!("qwen/", "qwen3.6-35b-a3b"), 262_144), |
| 837 | (concat!("qwen/", "qwen3.6-max-preview"), 262_144), |
| 838 | (concat!("qwen/", "qwen3.6-plus"), 1_000_000), |
| 839 | (concat!("xiaomi/", "mimo-v2.5-pro"), 1_000_000), |
| 840 | ("mimo-v2.5-pro", 1_000_000), |
| 841 | ("mimo-v2.5-pro-ultraspeed", 1_000_000), |
| 842 | ("mimo-v2.5", 1_000_000), |
| 843 | ("minimax/minimax-m3", 1_000_000), |
| 844 | ("minimax/minimax-m2.7", 204_800), |
| 845 | ("moonshotai/kimi-k2.7-code", 262_144), |
| 846 | ("moonshotai/kimi-k2.6", 262_144), |
| 847 | ("google/gemma-4-31b-it", 262_144), |
| 848 | ("z-ai/glm-5.1", 202_752), |
| 849 | ("z-ai/glm-5.2", 1_000_000), |
| 850 | ("z-ai/glm-5.3", 1_000_000), |
| 851 | ] { |
| 852 | assert_eq!(context_window_for_model(model), Some(expected_window)); |
| 853 | assert!(model_supports_reasoning(model)); |
| 854 | } |
| 855 | } |
| 856 | |
| 857 | #[test] |
| 858 | fn openai_api_and_codex_models_have_verified_context_metadata() { |
| 859 | for model in ["gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] { |
| 860 | assert_eq!(context_window_for_model(model), Some(1_050_000)); |
| 861 | assert_eq!(max_output_tokens_for_model(model), Some(128_000)); |
| 862 | assert!(model_supports_reasoning(model)); |
| 863 | assert_eq!( |
| 864 | compaction_threshold_for_model_at_percent(model, 80.0), |
| 865 | 840_000 |
| 866 | ); |
| 867 | } |
| 868 | |
| 869 | for model in [ |
| 870 | "gpt-5.5", |
| 871 | "gpt-5.5-pro", |
| 872 | "gpt-5.5-2026-04-23", |
| 873 | "gpt-5.5-pro-2026-04-23", |
| 874 | ] { |
| 875 | assert_eq!(context_window_for_model(model), Some(1_050_000)); |
| 876 | assert_eq!(max_output_tokens_for_model(model), Some(128_000)); |
| 877 | assert!(model_supports_reasoning(model)); |
| 878 | assert_eq!( |
| 879 | compaction_threshold_for_model_at_percent(model, 80.0), |
| 880 | 840_000 |
| 881 | ); |
| 882 | } |
| 883 | |
| 884 | for model in [ |
| 885 | "gpt-5-codex", |
| 886 | "gpt-5.1-codex", |
| 887 | "gpt-5.1-codex-mini", |
| 888 | "gpt-5.1-codex-max", |
| 889 | "gpt-5.2-codex", |
| 890 | "gpt-5.3-codex", |
| 891 | "codex-gpt-5.5", |
| 892 | "chatgpt-gpt-5.5", |
| 893 | "gpt-5.5-codex", |
| 894 | "gpt-5.5-codex-preview", |
| 895 | ] { |
| 896 | assert_eq!(context_window_for_model(model), Some(400_000)); |
| 897 | assert_eq!(max_output_tokens_for_model(model), Some(128_000)); |
| 898 | assert!(model_supports_reasoning(model)); |
| 899 | assert_eq!( |
| 900 | compaction_threshold_for_model_at_percent(model, 80.0), |
| 901 | 320_000 |
| 902 | ); |
| 903 | } |
| 904 | |
| 905 | assert_eq!(context_window_for_model("gpt-5.5-nano"), None); |
| 906 | assert_eq!(max_output_tokens_for_model("gpt-5.5-nano"), None); |
| 907 | assert!(!model_supports_reasoning("gpt-5.5-nano")); |
| 908 | } |
| 909 | |
| 910 | #[test] |
| 911 | fn anthropic_stepfun_and_sakana_limits_match_2026_07_09_audit() { |
| 912 | // Sonnet 4.6 output cap raised 64K -> 128K per |
| 913 | // https://platform.claude.com/docs/en/about-claude/models/overview; |
| 914 | // Haiku stays at 64K. |
| 915 | assert_eq!( |
| 916 | max_output_tokens_for_model("claude-sonnet-4-6"), |
| 917 | Some(128_000) |
| 918 | ); |
| 919 | assert_eq!( |
| 920 | max_output_tokens_for_model("claude-haiku-4-5"), |
| 921 | Some(64_000) |
| 922 | ); |
| 923 | // step-3.7-flash max output is third-party sourced (models.dev + |
| 924 | // Artificial Analysis; the official StepFun page is silent): |
| 925 | // https://models.dev/models/stepfun/step-3.7-flash/ |
| 926 | assert_eq!(max_output_tokens_for_model("step-3.7-flash"), Some(256_000)); |
| 927 | assert_eq!(context_window_for_model("step-3.7-flash"), Some(256_000)); |
| 928 | // fugu-ultra limits are third-party sourced (Requesty; Sakana's own |
| 929 | // >272K price tier at https://console.sakana.ai/pricing confirms the |
| 930 | // context window exceeds 272K). |
| 931 | for model in ["fugu-ultra", "fugu-ultra-20260615"] { |
| 932 | assert_eq!(context_window_for_model(model), Some(1_000_000), "{model}"); |
| 933 | assert_eq!(max_output_tokens_for_model(model), Some(131_000), "{model}"); |
| 934 | } |
| 935 | } |
| 936 | |
| 937 | #[test] |
| 938 | fn claude_fable_5_and_sonnet_5_have_verified_metadata() { |
| 939 | // 1M context / 128K output per |
| 940 | // https://platform.claude.com/docs/en/about-claude/pricing (2026-07-09). |
| 941 | for model in ["claude-fable-5", "claude-sonnet-5"] { |
| 942 | assert_eq!(context_window_for_model(model), Some(1_000_000), "{model}"); |
| 943 | assert_eq!(max_output_tokens_for_model(model), Some(128_000), "{model}"); |
| 944 | assert!(model_supports_reasoning(model), "{model}"); |
| 945 | } |
| 946 | } |
| 947 | |
| 948 | #[test] |
| 949 | fn muse_spark_has_verified_context_and_reasoning_metadata() { |
| 950 | assert_eq!(context_window_for_model("muse-spark-1.1"), Some(1_000_000)); |
| 951 | assert_eq!(max_output_tokens_for_model("muse-spark-1.1"), Some(32_000)); |
| 952 | assert!(model_supports_reasoning("muse-spark-1.1")); |
| 953 | // Muse Spark 1.2 standard: 1M context, $1.25/$4.25 + $0.15 cache (Artificial Analysis). |
| 954 | assert_eq!(context_window_for_model("muse-spark-1.2"), Some(1_000_000)); |
| 955 | assert_eq!(max_output_tokens_for_model("muse-spark-1.2"), Some(32_000)); |
| 956 | assert!(model_supports_reasoning("muse-spark-1.2")); |
| 957 | // Contributor tier: same model/limits, ~12×/21× cheaper in exchange for training-data opt-in. |
| 958 | assert_eq!( |
| 959 | context_window_for_model("muse-spark-1.2-contributor"), |
| 960 | Some(1_000_000) |
| 961 | ); |
| 962 | assert_eq!( |
| 963 | max_output_tokens_for_model("muse-spark-1.2-contributor"), |
| 964 | Some(32_000) |
| 965 | ); |
| 966 | assert!(model_supports_reasoning("muse-spark-1.2-contributor")); |
| 967 | } |
| 968 | |
| 969 | #[test] |
| 970 | fn modelstudio_qwen38_max_is_1m_context_not_128k() { |
| 971 | // Owner Token Plan console + curated catalog (2026-08-03). The 128K |
| 972 | // figure is max output, not the window — never collapse them. |
| 973 | for model in ["qwen3.8-max", "qwen3.8-max-preview"] { |
| 974 | assert_eq!(context_window_for_model(model), Some(1_000_000), "{model}"); |
| 975 | assert_eq!(max_output_tokens_for_model(model), Some(131_072), "{model}"); |
| 976 | } |
| 977 | } |
| 978 | |
| 979 | #[test] |
| 980 | fn modelstudio_bare_qwen_models_support_reasoning() { |
| 981 | // Model Studio's deep-thinking docs: every qwen3.x family the Token / |
| 982 | // Coding Plan catalogs carry is hybrid-thinking (reasoning_content on |
| 983 | // the OpenAI dialect, thinking blocks on the Anthropic dialect). |
| 984 | for model in [ |
| 985 | "qwen3.8-max", |
| 986 | "qwen3.8-max-preview", |
| 987 | "qwen3.7-max", |
| 988 | "qwen3.7-plus", |
| 989 | "qwen3.6-plus", |
| 990 | "qwen3.6-flash", |
| 991 | "qwen3.5-plus", |
| 992 | "qwen3.5-flash", |
| 993 | ] { |
| 994 | assert!(model_supports_reasoning(model), "{model}"); |
| 995 | } |
| 996 | } |
| 997 | |
| 998 | #[test] |
| 999 | fn model_metadata_catalog_override_flows_through_models_chokepoint() { |
| 1000 | let _lock = crate::model_catalog::test_catalog_lock(); |
| 1001 | let mut overrides = BTreeMap::new(); |
| 1002 | overrides.insert( |
| 1003 | "catalog-only-model".to_string(), |
| 1004 | crate::model_catalog::CatalogEntry { |
| 1005 | id: "catalog-only-model".to_string(), |
| 1006 | context_window: Some(777_000), |
| 1007 | max_output: Some(55_000), |
| 1008 | supports_reasoning: Some(true), |
| 1009 | input_usd_per_million: None, |
| 1010 | output_usd_per_million: None, |
| 1011 | modalities: Vec::new(), |
| 1012 | supported_parameters: Vec::new(), |
| 1013 | provider_model_id: None, |
| 1014 | provenance: crate::model_catalog::MetadataProvenance::UserOverride, |
| 1015 | }, |
| 1016 | ); |
| 1017 | let catalog = crate::model_catalog::MergedCatalog::from_sources( |
| 1018 | overrides, |
| 1019 | None, |
| 1020 | crate::model_catalog::bundled_catalog(), |
| 1021 | chrono::Utc::now(), |
| 1022 | ); |
| 1023 | let _guard = crate::model_catalog::replace_active_catalog_for_test(catalog); |
| 1024 | |
| 1025 | assert_eq!( |
| 1026 | context_window_for_model("catalog-only-model"), |
| 1027 | Some(777_000) |
| 1028 | ); |
| 1029 | assert_eq!( |
| 1030 | max_output_tokens_for_model("catalog-only-model"), |
| 1031 | Some(55_000) |
| 1032 | ); |
| 1033 | assert!(model_supports_reasoning("catalog-only-model")); |
| 1034 | } |
| 1035 | |
| 1036 | #[test] |
| 1037 | fn moonshot_native_kimi_ids_support_reasoning_including_coding_route() { |
| 1038 | // #3016: bare Moonshot ids (no moonshotai/ prefix) emit |
| 1039 | // reasoning_content; kimi-for-coding currently rides the K2.7 Code path. |
| 1040 | assert!(model_supports_reasoning("kimi-k2.7-code")); |
| 1041 | assert!(model_supports_reasoning("kimi-k2.6")); |
| 1042 | assert!(model_supports_reasoning("kimi-for-coding")); |
| 1043 | assert!(model_supports_reasoning("kimi-for-coding-highspeed")); |
| 1044 | assert!(model_supports_reasoning("kimi-k2.5")); |
| 1045 | } |
| 1046 | |
| 1047 | #[test] |
| 1048 | fn xai_grok_models_have_static_context_metadata() { |
| 1049 | for (model, expected_window, supports_reasoning) in [ |
| 1050 | ("grok-4.5", 500_000, true), |
| 1051 | ("grok-4.3", 1_000_000, true), |
| 1052 | ("grok-build", 512_000, true), |
| 1053 | ("grok-composer-2.5-fast", 200_000, false), |
| 1054 | ("grok-4.20-0309-reasoning", 2_000_000, true), |
| 1055 | ("grok-4.20-0309-non-reasoning", 2_000_000, false), |
| 1056 | ] { |
| 1057 | assert_eq!(context_window_for_model(model), Some(expected_window)); |
| 1058 | assert_eq!(max_output_tokens_for_model(model), None); |
| 1059 | assert_eq!(model_supports_reasoning(model), supports_reasoning); |
| 1060 | } |
| 1061 | } |
| 1062 | |
| 1063 | #[test] |
| 1064 | fn arcee_direct_models_preserve_verified_capabilities_only() { |
| 1065 | assert_eq!( |
| 1066 | context_window_for_model("trinity-large-preview"), |
| 1067 | Some(262_144) |
| 1068 | ); |
| 1069 | assert!(!model_supports_reasoning("trinity-large-preview")); |
| 1070 | assert_eq!(context_window_for_model("trinity-mini"), Some(128_000)); |
| 1071 | assert_eq!(max_output_tokens_for_model("trinity-mini"), None); |
| 1072 | assert!(model_supports_reasoning("trinity-mini")); |
| 1073 | } |
| 1074 | |
| 1075 | #[test] |
| 1076 | fn qwen37_plus_and_inkling_reasoning_do_not_invent_limits() { |
| 1077 | for model in ["qwen/qwen3.7-plus", "thinkingmachines/inkling"] { |
| 1078 | assert_eq!(context_window_for_model(model), None, "{model}"); |
| 1079 | assert_eq!(max_output_tokens_for_model(model), None, "{model}"); |
| 1080 | assert!(model_supports_reasoning(model), "{model}"); |
| 1081 | } |
| 1082 | } |
| 1083 | |
| 1084 | #[test] |
| 1085 | fn recent_openrouter_large_models_have_known_output_caps() { |
| 1086 | assert_eq!( |
| 1087 | max_output_tokens_for_model("arcee-ai/trinity-large-thinking"), |
| 1088 | Some(262_144) |
| 1089 | ); |
| 1090 | assert_eq!( |
| 1091 | max_output_tokens_for_model("trinity-large-thinking"), |
| 1092 | Some(262_144) |
| 1093 | ); |
| 1094 | assert_eq!( |
| 1095 | max_output_tokens_for_model(concat!("qwen/", "qwen3.6-flash")), |
| 1096 | Some(65_536) |
| 1097 | ); |
| 1098 | assert_eq!( |
| 1099 | max_output_tokens_for_model(concat!("qwen/", "qwen3.6-max-preview")), |
| 1100 | Some(65_536) |
| 1101 | ); |
| 1102 | assert_eq!( |
| 1103 | max_output_tokens_for_model(concat!("qwen/", "qwen3.6-plus")), |
| 1104 | Some(65_536) |
| 1105 | ); |
| 1106 | assert_eq!( |
| 1107 | max_output_tokens_for_model(concat!("xiaomi/", "mimo-v2.5-pro")), |
| 1108 | Some(131_072) |
| 1109 | ); |
| 1110 | assert_eq!(max_output_tokens_for_model("mimo-v2.5-pro"), Some(131_072)); |
| 1111 | assert_eq!( |
| 1112 | max_output_tokens_for_model("mimo-v2.5-pro-ultraspeed"), |
| 1113 | Some(131_072) |
| 1114 | ); |
| 1115 | assert_eq!(max_output_tokens_for_model("mimo-v2.5"), Some(131_072)); |
| 1116 | assert_eq!( |
| 1117 | max_output_tokens_for_model("minimax/minimax-m3"), |
| 1118 | Some(524_288) |
| 1119 | ); |
| 1120 | assert_eq!(max_output_tokens_for_model("z-ai/glm-5.1"), Some(131_072)); |
| 1121 | assert_eq!(max_output_tokens_for_model("z-ai/glm-5.2"), Some(131_072)); |
| 1122 | assert_eq!(max_output_tokens_for_model("z-ai/glm-5.3"), Some(131_072)); |
| 1123 | assert_eq!( |
| 1124 | max_output_tokens_for_model("z-ai/glm-5-turbo"), |
| 1125 | Some(131_072) |
| 1126 | ); |
| 1127 | assert_eq!(max_output_tokens_for_model("glm-5-turbo"), Some(131_072)); |
| 1128 | } |
| 1129 | |
| 1130 | #[test] |
| 1131 | fn k3_route_ids_use_verified_contracts_not_legacy_128k() { |
| 1132 | // Open-platform K3 carries the verified 1M contract. |
| 1133 | assert_eq!(context_window_for_model("kimi-k3"), Some(1_048_576)); |
| 1134 | assert_eq!( |
| 1135 | context_window_for_model("opencode-go/kimi-k3"), |
| 1136 | Some(1_048_576) |
| 1137 | ); |
| 1138 | // Bare `k3` (Kimi Code membership) is plan-tier dependent, so it |
| 1139 | // keeps the documented safe floor — and must never fall through to |
| 1140 | // the 128K legacy default. |
| 1141 | assert_eq!(context_window_for_model("k3"), Some(262_144)); |
| 1142 | assert_eq!(max_output_tokens_for_model("k3"), Some(131_072)); |
| 1143 | assert_eq!(max_output_tokens_for_model("kimi-k3"), Some(131_072)); |
| 1144 | // Never project max output as the context window. |
| 1145 | assert_ne!( |
| 1146 | context_window_for_model("k3"), |
| 1147 | max_output_tokens_for_model("k3") |
| 1148 | ); |
| 1149 | assert_ne!( |
| 1150 | context_window_for_model("kimi-k3"), |
| 1151 | max_output_tokens_for_model("kimi-k3") |
| 1152 | ); |
| 1153 | } |
| 1154 | |
| 1155 | #[test] |
| 1156 | fn kimi_code_membership_ids_mirror_their_family_facts() { |
| 1157 | // The high-speed membership id rides the kimi-for-coding family |
| 1158 | // context fact (256K) and reasoning support via the same `kimi-` |
| 1159 | // native-id rule as `kimi-for-coding`. No client-side output ceiling |
| 1160 | // is claimed for the membership ids — the membership catalog is the |
| 1161 | // source of truth, so the generic lookup returns None. |
| 1162 | assert_eq!( |
| 1163 | context_window_for_model("kimi-for-coding-highspeed"), |
| 1164 | Some(262_144) |
| 1165 | ); |
| 1166 | assert_eq!( |
| 1167 | max_output_tokens_for_model("kimi-for-coding-highspeed"), |
| 1168 | None |
| 1169 | ); |
| 1170 | assert_eq!(max_output_tokens_for_model("kimi-for-coding"), None); |
| 1171 | assert!(model_supports_reasoning("kimi-for-coding-highspeed")); |
| 1172 | } |
| 1173 | |
| 1174 | #[test] |
| 1175 | fn bare_provider_model_ids_mirror_vendor_prefixed_rows() { |
| 1176 | // Direct-provider routes (Moonshot, MiniMax, Z.ai) serve bare model |
| 1177 | // ids without the OpenRouter vendor prefix; both spellings must |
| 1178 | // resolve identical metadata (#1310 ride-along on #3023). |
| 1179 | for (model, expected_window) in [ |
| 1180 | ("kimi-k3", 1_048_576), |
| 1181 | ("kimi-k2.7-code", 262_144), |
| 1182 | ("kimi-k2.6", 262_144), |
| 1183 | ("minimax-m3", 1_000_000), |
| 1184 | ("minimax-m2.7", 204_800), |
| 1185 | ("minimax-m2.5-highspeed", 204_800), |
| 1186 | ("minimax-m2", 204_800), |
| 1187 | ("glm-5.1", 202_752), |
| 1188 | ("glm-5.2", 1_000_000), |
| 1189 | // Inherited from glm-5.2 pending official Z.ai release metadata. |
| 1190 | ("glm-5.3", 1_000_000), |
| 1191 | ("glm-5-turbo", 202_752), |
| 1192 | ] { |
| 1193 | assert_eq!(context_window_for_model(model), Some(expected_window)); |
| 1194 | assert!(model_supports_reasoning(model)); |
| 1195 | } |
| 1196 | assert_eq!(context_window_for_model("kimi-for-coding"), Some(262_144)); |
| 1197 | assert!(model_supports_reasoning("kimi-for-coding")); |
| 1198 | assert_eq!(context_window_for_model("glm-5v-turbo"), Some(202_752)); |
| 1199 | assert!(!model_supports_reasoning("glm-5v-turbo")); |
| 1200 | // GLM-5-Turbo is a fast text sibling (distinct from the glm-5v-turbo |
| 1201 | // vision model): same compact window as 5.1 but reasoning-capable. |
| 1202 | assert_eq!(context_window_for_model("z-ai/glm-5-turbo"), Some(202_752)); |
| 1203 | assert!(model_supports_reasoning("z-ai/glm-5-turbo")); |
| 1204 | assert_eq!( |
| 1205 | crate::model_catalog::resolved_max_output("kimi-k2.7-code"), |
| 1206 | Some(32_768) |
| 1207 | ); |
| 1208 | assert_eq!(max_output_tokens_for_model("kimi-k2.7-code"), Some(32_768)); |
| 1209 | assert_eq!(max_output_tokens_for_model("kimi-k2.6"), Some(32_768)); |
| 1210 | assert_eq!(max_output_tokens_for_model("kimi-for-coding"), None); |
| 1211 | assert_eq!(max_output_tokens_for_model("kimi-k3"), Some(131_072)); |
| 1212 | assert_eq!(max_output_tokens_for_model("minimax-m3"), Some(524_288)); |
| 1213 | assert_eq!(max_output_tokens_for_model("glm-5.1"), Some(131_072)); |
| 1214 | assert_eq!(max_output_tokens_for_model("glm-5.2"), Some(131_072)); |
| 1215 | assert_eq!(max_output_tokens_for_model("glm-5.3"), Some(131_072)); |
| 1216 | } |
| 1217 | |
| 1218 | #[test] |
| 1219 | fn deepseek_models_with_k_suffix_use_hint() { |
| 1220 | assert_eq!(context_window_for_model("deepseek-v3.2-32k"), Some(32_000)); |
| 1221 | assert_eq!( |
| 1222 | context_window_for_model("deepseek-v3.2-256k-preview"), |
| 1223 | Some(256_000) |
| 1224 | ); |
| 1225 | assert_eq!( |
| 1226 | context_window_for_model("deepseek-v3.2-2k-preview"), |
| 1227 | Some(LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS) |
| 1228 | ); |
| 1229 | } |
| 1230 | |
| 1231 | #[test] |
| 1232 | fn compaction_threshold_scales_with_context_window() { |
| 1233 | assert_eq!( |
| 1234 | compaction_threshold_for_model_at_percent("deepseek-v3.2-128k", 80.0), |
| 1235 | 102_400 |
| 1236 | ); |
| 1237 | // v0.8.11 (#664): unknown-model fallback also resolves to 80% of |
| 1238 | // `LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS` (128K legacy DeepSeek |
| 1239 | // fallback) — same late-trigger discipline as the V4 path. Was |
| 1240 | // `50_000` pre-v0.8.11; that hardcoded value compacted at ~5% of a |
| 1241 | // 1M window when model detection silently fell through, which is |
| 1242 | // exactly the prefix-cache-burning behaviour we're getting away from. |
| 1243 | assert_eq!( |
| 1244 | compaction_threshold_for_model_at_percent("unknown-model", 80.0), |
| 1245 | 102_400 |
| 1246 | ); |
| 1247 | } |
| 1248 | |
| 1249 | #[test] |
| 1250 | fn compaction_scales_for_deepseek_v4_1m_context() { |
| 1251 | assert_eq!( |
| 1252 | compaction_threshold_for_model_at_percent("deepseek-v4-pro", 80.0), |
| 1253 | 800_000 |
| 1254 | ); |
| 1255 | } |
| 1256 | |
| 1257 | #[test] |
| 1258 | fn compaction_threshold_honors_configured_percent() { |
| 1259 | assert_eq!( |
| 1260 | compaction_threshold_for_model_at_percent("deepseek-v4-pro", 75.0), |
| 1261 | 750_000 |
| 1262 | ); |
| 1263 | assert_eq!( |
| 1264 | compaction_threshold_for_model_at_percent("trinity-large-thinking", 80.0), |
| 1265 | 209_715 |
| 1266 | ); |
| 1267 | } |
| 1268 | |
| 1269 | #[test] |
| 1270 | fn auto_compaction_defaults_on_for_known_supported_model_windows() { |
| 1271 | assert!(auto_compact_default_for_model("trinity-large-thinking")); |
| 1272 | assert!(auto_compact_default_for_model("deepseek-v3.2-128k")); |
| 1273 | assert!(auto_compact_default_for_model("deepseek-v4-pro")); |
| 1274 | assert!(auto_compact_default_for_model("mimo-v2.5-pro")); |
| 1275 | assert!(!auto_compact_default_for_model("unknown-model")); |
| 1276 | } |
| 1277 | } |
| 1278 |