| 1 | //! API request/response models for `DeepSeek` and OpenAI-compatible endpoints. |
| 2 | |
| 3 | use serde::{Deserialize, Serialize}; |
| 4 | |
| 5 | pub mod model_catalog; |
| 6 | |
| 7 | /// Context window used only for legacy DeepSeek model IDs that do not name a |
| 8 | /// newer V4 alias and do not carry an explicit `*k` suffix. |
| 9 | pub const LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS: u32 = 128_000; |
| 10 | pub const DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS: u32 = 1_000_000; |
| 11 | /// Conservative Kimi Code K3 context baseline. The membership route's real |
| 12 | /// context is plan-tier dependent (verified 2026-07-20 from |
| 13 | /// <https://www.kimi.com/code/docs/en/kimi-code/models>): Moderato gets 256K, |
| 14 | /// while Allegretto and above get up to 1M. Bare `k3` therefore keeps this |
| 15 | /// safe floor everywhere; higher plan entitlements must come from an explicit |
| 16 | /// provider `context_window` configuration or fresh provider facts while |
| 17 | /// preserving the `k3` wire id. |
| 18 | pub const KIMI_CODE_K3_CONTEXT_WINDOW_TOKENS: u32 = 262_144; |
| 19 | /// Kimi K3 context window on the open platform (`kimi-k3` pay-as-you-go). |
| 20 | /// Verified 2026-07-20 from <https://platform.kimi.ai/docs/guide/kimi-k3-quickstart> |
| 21 | /// (1,048,576 tokens). Max output is a separate fact below and must never be |
| 22 | /// conflated with this window. |
| 23 | pub const KIMI_K3_CONTEXT_WINDOW_TOKENS: u32 = 1_048_576; |
| 24 | /// Conservative K3 default generation ceiling. The direct Kimi API defaults |
| 25 | /// `max_completion_tokens` to 131,072, while its documented route maximum is |
| 26 | /// a separate exact-route fact below. Membership and neighboring routes do |
| 27 | /// not inherit that direct-platform maximum. |
| 28 | pub const KIMI_K3_DEFAULT_MAX_COMPLETION_TOKENS: u32 = 131_072; |
| 29 | /// Documented maximum output for the exact direct Kimi K3 API route. |
| 30 | /// |
| 31 | /// Source: <https://platform.kimi.ai/docs/guide/kimi-k3-quickstart> (verified 2026-07-20). |
| 32 | pub const DIRECT_KIMI_K3_MAX_OUTPUT_TOKENS: u32 = 1_048_576; |
| 33 | /// Last-resort compaction trigger when [`context_window_for_model`] returns |
| 34 | /// `None` (an unrecognised model id). v0.8.11 raised this from `50_000` to |
| 35 | /// `102_400` (80% of [`LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS`]) so unknown |
| 36 | /// models inherit the same late-trigger discipline as V4 instead of paying |
| 37 | /// the prefix-cache hit at 5% of the V4 window. Known DeepSeek / Claude |
| 38 | /// models resolve to their own scaled value via |
| 39 | /// `compaction_threshold_for_model` (#664). |
| 40 | pub const DEFAULT_COMPACTION_TOKEN_THRESHOLD: usize = 102_400; |
| 41 | pub fn canonical_official_deepseek_model_id(model: &str) -> Option<&'static str> { |
| 42 | match model.trim().to_ascii_lowercase().as_str() { |
| 43 | "deepseek-v4-pro" |
| 44 | | "deepseek-v4pro" |
| 45 | | "deepseek-ai/deepseek-v4-pro" |
| 46 | | "deepseek-ai/deepseek-v4pro" |
| 47 | | "deepseek/deepseek-v4-pro" |
| 48 | | "deepseek/deepseek-v4pro" => Some("deepseek-v4-pro"), |
| 49 | "deepseek-v4-flash" |
| 50 | | "deepseek-v4flash" |
| 51 | | "deepseek-ai/deepseek-v4-flash" |
| 52 | | "deepseek-ai/deepseek-v4flash" |
| 53 | | "deepseek/deepseek-v4-flash" |
| 54 | | "deepseek/deepseek-v4flash" => Some("deepseek-v4-flash"), |
| 55 | _ => None, |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | #[cfg(any(test, feature = "test-support"))] |
| 60 | const COMPACTION_THRESHOLD_PERCENT: u32 = 80; |
| 61 | |
| 62 | // === Core Message Types === |
| 63 | |
| 64 | // Keep the historical TUI path stable while the production request DTOs are |
| 65 | // owned by `codewhale-core`. Existing transports and response decoders do not |
| 66 | // need a flag day, and headless callers can depend on core directly. |
| 67 | // Some process-test crates include this module privately and exercise only a |
| 68 | // subset of the compatibility surface, so their crate-local dead-import view |
| 69 | // is not evidence that a re-export can be removed. |
| 70 | #[allow(unused_imports)] |
| 71 | pub use codewhale_core::request::{ |
| 72 | CacheControl, ContentBlock, INTERRUPTED_ASSISTANT_CONTEXT_PREFIX, INTERRUPTED_ASSISTANT_ROLE, |
| 73 | ImageUrlContent, Message, MessageRequest, OpaqueReasoningState, SystemBlock, SystemPrompt, |
| 74 | Tool, ToolCaller, |
| 75 | }; |
| 76 | #[allow(unused_imports)] |
| 77 | pub use codewhale_core::role::Role; |
| 78 | |
| 79 | /// Container metadata for code-execution style server tools. |
| 80 | #[derive(Debug, Serialize, Deserialize, Clone)] |
| 81 | pub struct ContainerInfo { |
| 82 | pub id: String, |
| 83 | #[serde(skip_serializing_if = "Option::is_none")] |
| 84 | pub expires_at: Option<String>, |
| 85 | } |
| 86 | |
| 87 | /// Server-side tool usage counters. |
| 88 | #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)] |
| 89 | pub struct ServerToolUsage { |
| 90 | #[serde(skip_serializing_if = "Option::is_none")] |
| 91 | pub code_execution_requests: Option<u32>, |
| 92 | #[serde(skip_serializing_if = "Option::is_none")] |
| 93 | pub tool_search_requests: Option<u32>, |
| 94 | } |
| 95 | |
| 96 | /// Response payload for a message request. |
| 97 | #[derive(Debug, Serialize, Deserialize, Clone)] |
| 98 | pub struct MessageResponse { |
| 99 | pub id: String, |
| 100 | pub r#type: String, |
| 101 | pub role: String, |
| 102 | pub content: Vec<ContentBlock>, |
| 103 | pub model: String, |
| 104 | pub stop_reason: Option<String>, |
| 105 | pub stop_sequence: Option<String>, |
| 106 | #[serde(skip_serializing_if = "Option::is_none")] |
| 107 | pub container: Option<ContainerInfo>, |
| 108 | pub usage: Usage, |
| 109 | } |
| 110 | |
| 111 | /// True when the provider ended generation because its output allowance was |
| 112 | /// exhausted. Providers use several wire spellings for the same condition. |
| 113 | #[must_use] |
| 114 | pub fn is_output_limit_stop_reason(reason: Option<&str>) -> bool { |
| 115 | reason.is_some_and(|reason| { |
| 116 | let reason = reason |
| 117 | .trim() |
| 118 | .strip_prefix("incomplete:") |
| 119 | .unwrap_or_else(|| reason.trim()); |
| 120 | matches!( |
| 121 | reason.to_ascii_lowercase().as_str(), |
| 122 | "length" | "max_tokens" | "max_output_tokens" |
| 123 | ) |
| 124 | }) |
| 125 | } |
| 126 | |
| 127 | /// True when the provider explicitly reported that it did not complete the |
| 128 | /// response. Responses API reasons carry an `incomplete:` prefix so unknown |
| 129 | /// future reasons cannot accidentally be accepted as a finished answer. |
| 130 | #[must_use] |
| 131 | pub fn is_incomplete_stop_reason(reason: Option<&str>) -> bool { |
| 132 | is_output_limit_stop_reason(reason) |
| 133 | || reason.is_some_and(|reason| { |
| 134 | let reason = reason.trim().to_ascii_lowercase(); |
| 135 | reason.starts_with("incomplete:") |
| 136 | || matches!( |
| 137 | reason.as_str(), |
| 138 | "content_filter" | "model_context_window_exceeded" |
| 139 | ) |
| 140 | }) |
| 141 | } |
| 142 | |
| 143 | #[must_use] |
| 144 | pub fn stop_reason_detail(reason: Option<&str>) -> &str { |
| 145 | reason |
| 146 | .map(str::trim) |
| 147 | .and_then(|reason| reason.strip_prefix("incomplete:").or(Some(reason))) |
| 148 | .filter(|reason| !reason.is_empty()) |
| 149 | .unwrap_or("unknown") |
| 150 | } |
| 151 | |
| 152 | /// Token usage metadata for a response. |
| 153 | #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)] |
| 154 | pub struct Usage { |
| 155 | pub input_tokens: u32, |
| 156 | pub output_tokens: u32, |
| 157 | #[serde(skip_serializing_if = "Option::is_none")] |
| 158 | pub prompt_cache_hit_tokens: Option<u32>, |
| 159 | #[serde(skip_serializing_if = "Option::is_none")] |
| 160 | pub prompt_cache_miss_tokens: Option<u32>, |
| 161 | /// Cache-creation / cache-write tokens (Anthropic `cache_creation_input_tokens`). |
| 162 | /// Billed at the cache-write rate when the pricing row publishes one (#4318). |
| 163 | #[serde(skip_serializing_if = "Option::is_none")] |
| 164 | pub prompt_cache_write_tokens: Option<u32>, |
| 165 | #[serde(skip_serializing_if = "Option::is_none")] |
| 166 | pub reasoning_tokens: Option<u32>, |
| 167 | /// Approximate input tokens spent re-sending prior `reasoning_content` |
| 168 | /// across user-message boundaries in DeepSeek V4 thinking-mode tool-calling |
| 169 | /// turns (V4 §5.1.1 "Interleaved Thinking"). Estimated client-side at |
| 170 | /// ~4 chars/token from the outgoing request body, before the model sees it. |
| 171 | #[serde(skip_serializing_if = "Option::is_none")] |
| 172 | pub reasoning_replay_tokens: Option<u32>, |
| 173 | #[serde(skip_serializing_if = "Option::is_none")] |
| 174 | pub server_tool_use: Option<ServerToolUsage>, |
| 175 | } |
| 176 | |
| 177 | /// Map known models to their approximate context window sizes. |
| 178 | /// |
| 179 | /// Exact catalog and recognized model facts take precedence. Otherwise an |
| 180 | /// explicit `_Nk` suffix supplies an unverified hint for self-hosted models. |
| 181 | /// Unrecognized DeepSeek family names remain unknown. |
| 182 | #[must_use] |
| 183 | pub fn context_window_for_model(model: &str) -> Option<u32> { |
| 184 | if let Some(window) = crate::model_catalog::resolved_context_window(model) { |
| 185 | return Some(window); |
| 186 | } |
| 187 | let lower = model.to_lowercase(); |
| 188 | // Concrete model facts take precedence over vendor-agnostic suffix |
| 189 | // inference (`k3-256k` means 262,144 tokens, not 256,000). |
| 190 | if let Some(window) = known_context_window_for_model(&lower) { |
| 191 | return Some(window); |
| 192 | } |
| 193 | if canonical_official_deepseek_model_id(&lower).is_some() { |
| 194 | return Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS); |
| 195 | } |
| 196 | if let Some(explicit_window) = explicit_context_window_hint(&lower) { |
| 197 | return Some(explicit_window); |
| 198 | } |
| 199 | if is_openai_gpt_55_api_model(&lower) || is_openai_gpt_56_api_model(&lower) { |
| 200 | return Some(1_050_000); |
| 201 | } |
| 202 | if is_openai_codex_model(&lower) { |
| 203 | return Some(400_000); |
| 204 | } |
| 205 | if lower.contains("claude") { |
| 206 | return Some(200_000); |
| 207 | } |
| 208 | None |
| 209 | } |
| 210 | |
| 211 | fn known_context_window_for_model(model_lower: &str) -> Option<u32> { |
| 212 | match model_lower { |
| 213 | // OpenAI API model docs, verified 2026-06-12: |
| 214 | // https://developers.openai.com/api/docs/models/gpt-5.5 |
| 215 | // Family aliases and snapshots are handled by |
| 216 | // `is_openai_gpt_55_api_model` before this table. |
| 217 | // OpenAI Codex model docs, verified 2026-06-12: |
| 218 | // https://developers.openai.com/api/docs/models/gpt-5-codex |
| 219 | // https://developers.openai.com/api/docs/models/gpt-5.3-codex |
| 220 | "gpt-5-codex" | "gpt-5.3-codex" => Some(400_000), |
| 221 | // Anthropic 4.6+ models carry a 1M window; Haiku stays at 200K (#3014). |
| 222 | // Opus 5 (GA 2026-07-24) is 1M / 128K per |
| 223 | // https://platform.claude.com/docs/en/about-claude/models/overview. |
| 224 | "claude-opus-4-8" | "claude-opus-5" | "claude-sonnet-4-6" | "claude-sonnet-5" |
| 225 | | "claude-fable-5" => Some(1_000_000), |
| 226 | "claude-haiku-4-5" => Some(200_000), |
| 227 | // DeepSeek V4.1 Flash, id verified live on api.deepseek.com /v1/models |
| 228 | // 2026-09-10. The V4 family ships 1M context / 384K output, and the |
| 229 | // vendor's own notice puts V4.1 Flash above V4 Pro on every metric. |
| 230 | // Listed here as an exact-id context fact; its price is time-varying |
| 231 | // and lives in `pricing.rs`, which is why this does not route through |
| 232 | // `canonical_official_deepseek_model_id`. |
| 233 | "deepseek-flash" | "deepseek/deepseek-flash" | "deepseek-ai/deepseek-flash" => { |
| 234 | Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS) |
| 235 | } |
| 236 | "trinity-mini" => Some(128_000), |
| 237 | "arcee-ai/trinity-large-thinking" | "trinity-large-thinking" | "trinity-large-preview" => { |
| 238 | Some(262_144) |
| 239 | } |
| 240 | "google/gemma-4-31b-it" |
| 241 | | "google/gemma-4-31b-it:free" |
| 242 | | "google/gemma-4-26b-a4b-it" |
| 243 | | "google/gemma-4-26b-a4b-it:free" |
| 244 | | "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free" |
| 245 | | "qwen/qwen3.6-35b-a3b" |
| 246 | | "qwen/qwen3.6-max-preview" |
| 247 | | "qwen/qwen3.6-27b" |
| 248 | | "tencent/hy3-preview" => Some(262_144), |
| 249 | // Official Kimi K3 platform pricing (2026-07-20): |
| 250 | // https://platform.kimi.ai/docs/guide/kimi-k3-quickstart — 1,048,576 context |
| 251 | // for the open platform. |
| 252 | "moonshotai/kimi-k3" | "kimi-k3" | "opencode-go/kimi-k3" => { |
| 253 | Some(KIMI_K3_CONTEXT_WINDOW_TOKENS) |
| 254 | } |
| 255 | // Bare `k3` is plan-tier dependent; `k3-256k` is fixed at 256 KiTok. |
| 256 | // Neither may fall through to the generic suffix heuristic. |
| 257 | "k3" | "k3-256k" => Some(KIMI_CODE_K3_CONTEXT_WINDOW_TOKENS), |
| 258 | // `kimi-k2.7-code-highspeed` is the same model on the direct |
| 259 | // platform's high-speed tier (262,144 context), per |
| 260 | // https://platform.kimi.ai/docs/pricing/chat-k27-code (2026-08-17). |
| 261 | "moonshotai/kimi-k2.7-code" |
| 262 | | "moonshotai/kimi-k2.7-code-highspeed" |
| 263 | | "moonshotai/kimi-k2.6" |
| 264 | | "moonshotai/kimi-k2.6:free" |
| 265 | | "kimi-k2.7-code" |
| 266 | | "kimi-k2.7-code-highspeed" |
| 267 | | "kimi-k2.6" |
| 268 | | "kimi-for-coding" |
| 269 | | "kimi-for-coding-highspeed" => Some(262_144), |
| 270 | "minimax-m2.7" |
| 271 | | "minimax/minimax-m2.7" |
| 272 | | "minimax-m2.7-highspeed" |
| 273 | | "minimax-m2.5" |
| 274 | | "minimax-m2.5-highspeed" |
| 275 | | "minimax-m2.1" |
| 276 | | "minimax-m2.1-highspeed" |
| 277 | | "minimax-m2" => Some(204_800), |
| 278 | "z-ai/glm-5.1" | "z-ai/glm-5v-turbo" | "glm-5.1" | "glm-5v-turbo" => Some(202_752), |
| 279 | "z-ai/glm-5-turbo" | "glm-5-turbo" => Some(202_752), |
| 280 | // GLM-5.3 limits are inherited from GLM-5.2 pending official Z.ai |
| 281 | // release metadata (see `INHERITED FROM glm-5.2` in config/models.rs). |
| 282 | // GLM-5.3-Flash is the published 1M multimodal sibling (2026-08-26). |
| 283 | "z-ai/glm-5.2" | "glm-5.2" | "z-ai/glm-5.3" | "glm-5.3" | "z-ai/glm-5.3-flash" |
| 284 | | "glm-5.3-flash" => Some(1_000_000), |
| 285 | "minimax/minimax-m3" | "minimax-m3" | "qwen/qwen3.8-flash" | "qwen/qwen3.6-flash" |
| 286 | | "qwen/qwen3.6-plus" => Some(1_000_000), |
| 287 | // Alibaba Cloud Model Studio (Token Plan console + curated catalog, |
| 288 | // verified 2026-08-03): ~1M context. Never fall through to the 128K |
| 289 | // legacy default — that number is the generation ceiling, not the window. |
| 290 | // Bare `qwen3.8-flash` is the OpenRouter short id (verified 2026-08-26 |
| 291 | // against models.dev: 1M context / 131K output); Alibaba first-party |
| 292 | // does not list a flash row, so this is not a Model Studio default. |
| 293 | "qwen3.8-max" |
| 294 | | "qwen3.8-max-preview" |
| 295 | | "qwen3.8-flash" |
| 296 | | "qwen3.7-plus" |
| 297 | | "qwen3.7-max" |
| 298 | | "qwen3.6-flash" => Some(1_000_000), |
| 299 | "nvidia/nemotron-3-ultra-550b-a55b" | "nvidia/nemotron-3-ultra-550b-a55b:free" => { |
| 300 | Some(1_000_000) |
| 301 | } |
| 302 | "xiaomi/mimo-v2.5-pro" |
| 303 | | "xiaomi/mimo-v2.5" |
| 304 | | "mimo-v2.5-pro" |
| 305 | | "mimo-v2.5-pro-ultraspeed" |
| 306 | | "mimo-v2.5" => Some(1_000_000), |
| 307 | "mimo-v2.5-asr" |
| 308 | | "mimo-v2.5-tts" |
| 309 | | "mimo-v2.5-tts-voicedesign" |
| 310 | | "mimo-v2.5-tts-voiceclone" |
| 311 | | "mimo-v2-tts" => Some(8_000), |
| 312 | "grok-4.6" | "grok-4.5" => Some(500_000), |
| 313 | "grok-4.3" => Some(1_000_000), |
| 314 | "grok-build" => Some(512_000), |
| 315 | "grok-composer-2.5-fast" => Some(200_000), |
| 316 | "grok-4.20-0309-reasoning" | "grok-4.20-0309-non-reasoning" => Some(2_000_000), |
| 317 | "muse-spark-1.1" | "muse-spark-1.2" | "muse-spark-1.2-contributor" => Some(1_000_000), |
| 318 | // Mistral la Plateforme text/reasoning models: all report 262144 |
| 319 | // (256K) tokens on /v1/models as of 2026-08-08. Codestral coding |
| 320 | // model (mistral-code-latest) reports 256000 tokens on the same |
| 321 | // endpoint. IDs and windows verified live against |
| 322 | // https://api.mistral.ai/v1/models rather than model-card slugs. |
| 323 | "mistral-medium-latest" |
| 324 | | "mistral-medium-3-5" |
| 325 | | "mistral-medium-2604" |
| 326 | | "mistral-medium-3.5" |
| 327 | | "mistral-medium-3" |
| 328 | | "mistral-small-latest" |
| 329 | | "mistral-small-2603" |
| 330 | | "magistral-small-latest" |
| 331 | | "mistral-large-latest" |
| 332 | | "mistral-large-2512" => Some(262_144), |
| 333 | "mistral-code-latest" | "codestral-latest" | "codestral" | "mistral-code" => Some(256_000), |
| 334 | // Google Gemini API model pages (verified 2026-08-17): every current |
| 335 | // Gemini 3.x / 2.5 text model lists a 1,048,576-token input limit and |
| 336 | // a 65,536-token output limit. |
| 337 | // https://ai.google.dev/gemini-api/docs/models/gemini-3.7-flash |
| 338 | // https://ai.google.dev/gemini-api/docs/models/gemini-3.6-flash |
| 339 | // https://ai.google.dev/gemini-api/docs/models/gemini-3.5-flash |
| 340 | // https://ai.google.dev/gemini-api/docs/models/gemini-3.5-flash-lite |
| 341 | // https://ai.google.dev/gemini-api/docs/models/gemini-3.1-pro-preview |
| 342 | // https://ai.google.dev/gemini-api/docs/models/gemini-2.5-pro |
| 343 | // https://ai.google.dev/gemini-api/docs/models/gemini-2.5-flash |
| 344 | // (gemini-3-pro-preview's page carries the same limits but is marked |
| 345 | // shut down since 2026-03-09 on the Gemini API; it stays here only |
| 346 | // because other routes still name it.) |
| 347 | "gemini-3.7-flash" |
| 348 | | "gemini-3.6-flash" |
| 349 | | "gemini-3.5-flash" |
| 350 | | "gemini-3.5-flash-lite" |
| 351 | | "gemini-3.1-pro-preview" |
| 352 | | "gemini-3-pro-preview" |
| 353 | | "gemini-2.5-pro" |
| 354 | | "gemini-2.5-flash" => Some(1_048_576), |
| 355 | // OpenRouter-hosted Dots Studio (RedNote) Dots3-Note preview: the only |
| 356 | // hosted route (single AtlasCloud endpoint) reports 512,000 context / |
| 357 | // 512,000 max completion tokens, https://openrouter.ai/api/v1/models/ |
| 358 | // dots-studio/dots-3-note-preview:free/endpoints (2026-08-17). |
| 359 | "dots-studio/dots-3-note-preview:free" => Some(512_000), |
| 360 | _ => None, |
| 361 | } |
| 362 | } |
| 363 | |
| 364 | #[must_use] |
| 365 | pub fn max_output_tokens_for_model(model: &str) -> Option<u32> { |
| 366 | if let Some(max_output) = crate::model_catalog::resolved_max_output(model) { |
| 367 | return Some(max_output); |
| 368 | } |
| 369 | let lower = model.to_lowercase(); |
| 370 | if is_openai_gpt_55_api_model(&lower) |
| 371 | || is_openai_gpt_56_api_model(&lower) |
| 372 | || is_openai_codex_model(&lower) |
| 373 | { |
| 374 | return Some(128_000); |
| 375 | } |
| 376 | match lower.as_str() { |
| 377 | "gpt-5-codex" | "gpt-5.3-codex" => Some(128_000), |
| 378 | // claude-sonnet-4-6 max output raised 64K -> 128K per |
| 379 | // https://platform.claude.com/docs/en/about-claude/models/overview |
| 380 | // (2026-07-09 audit). |
| 381 | "claude-opus-4-8" | "claude-opus-5" | "claude-sonnet-4-6" | "claude-sonnet-5" |
| 382 | | "claude-fable-5" => Some(128_000), |
| 383 | "claude-haiku-4-5" => Some(64_000), |
| 384 | "arcee-ai/trinity-large-thinking" | "trinity-large-thinking" => Some(262_144), |
| 385 | // Keep the generic/model-id lookup at K3's conservative documented |
| 386 | // default generation ceiling. The exact direct route's 1M maximum is |
| 387 | // applied later with endpoint-aware provenance; membership and |
| 388 | // neighboring routes must not inherit it. |
| 389 | "moonshotai/kimi-k3" | "kimi-k3" | "k3" | "k3-256k" | "opencode-go/kimi-k3" => { |
| 390 | Some(KIMI_K3_DEFAULT_MAX_COMPLETION_TOKENS) |
| 391 | } |
| 392 | // Kimi K2.7 Code has a 256K context window but its documented default |
| 393 | // maximum generation is 32K. Keeping those separate prevents the |
| 394 | // input budget from collapsing to the 1K emergency floor (#4368). The |
| 395 | // direct-platform value matches the provider-reported bundled |
| 396 | // catalog. The Kimi Code membership ids (`kimi-for-coding` family) |
| 397 | // are deliberately absent here: the membership catalog is the source |
| 398 | // of truth for their limits and no client-side output ceiling is |
| 399 | // claimed, so they fall back to the generic default. |
| 400 | "moonshotai/kimi-k2.7-code" |
| 401 | | "moonshotai/kimi-k2.7-code-highspeed" |
| 402 | | "moonshotai/kimi-k2.6" |
| 403 | | "kimi-k2.7-code" |
| 404 | | "kimi-k2.7-code-highspeed" |
| 405 | | "kimi-k2.6" => Some(32_768), |
| 406 | "minimax/minimax-m3" | "minimax-m3" => Some(524_288), |
| 407 | // Alibaba's published limit is 65,536 output tokens; the earlier |
| 408 | // 262,140 mirrored the context window (data-entry smell flagged by |
| 409 | // MODEL_PROVIDER_AUDIT A2/D-7, vendor-verified 2026-07-12). |
| 410 | "qwen/qwen3.6-35b-a3b" |
| 411 | | "qwen/qwen3.6-27b" |
| 412 | | "qwen/qwen3.6-flash" |
| 413 | | "qwen/qwen3.6-max-preview" |
| 414 | | "qwen/qwen3.6-plus" => Some(65_536), |
| 415 | // Model Studio: 128K is the generation ceiling, not the context window. |
| 416 | // OpenRouter qwen3.8-flash shares the 131,072 output cap (models.dev |
| 417 | // 2026-08-26); do not inherit qwen3.6-flash's 65,536 ceiling. |
| 418 | "qwen3.8-max" | "qwen3.8-max-preview" | "qwen/qwen3.8-flash" | "qwen3.8-flash" => { |
| 419 | Some(131_072) |
| 420 | } |
| 421 | "qwen3.7-plus" | "qwen3.7-max" | "qwen3.6-flash" => Some(65_536), |
| 422 | "z-ai/glm-5.1" | "z-ai/glm-5.2" | "z-ai/glm-5.3" | "z-ai/glm-5.3-flash" |
| 423 | | "z-ai/glm-5-turbo" | "glm-5.1" | "glm-5.2" | "glm-5.3" | "glm-5.3-flash" |
| 424 | | "glm-5-turbo" => Some(131_072), |
| 425 | "xiaomi/mimo-v2.5-pro" |
| 426 | | "xiaomi/mimo-v2.5" |
| 427 | | "mimo-v2.5-pro" |
| 428 | | "mimo-v2.5-pro-ultraspeed" |
| 429 | | "mimo-v2.5" => Some(131_072), |
| 430 | "mimo-v2.5-asr" => Some(2_048), |
| 431 | "mimo-v2.5-tts" |
| 432 | | "mimo-v2.5-tts-voicedesign" |
| 433 | | "mimo-v2.5-tts-voiceclone" |
| 434 | | "mimo-v2-tts" => Some(8_192), |
| 435 | "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free" => Some(65_536), |
| 436 | "nvidia/nemotron-3-ultra-550b-a55b" => Some(16_384), |
| 437 | "nvidia/nemotron-3-ultra-550b-a55b:free" => Some(65_536), |
| 438 | "google/gemma-4-31b-it" => Some(16_384), |
| 439 | "google/gemma-4-31b-it:free" | "google/gemma-4-26b-a4b-it:free" => Some(32_768), |
| 440 | "muse-spark-1.1" | "muse-spark-1.2" | "muse-spark-1.2-contributor" => Some(32_000), |
| 441 | // Gemini API output token limit (see `known_context_window_for_model`). |
| 442 | "gemini-3.7-flash" |
| 443 | | "gemini-3.6-flash" |
| 444 | | "gemini-3.5-flash" |
| 445 | | "gemini-3.5-flash-lite" |
| 446 | | "gemini-3.1-pro-preview" |
| 447 | | "gemini-3-pro-preview" |
| 448 | | "gemini-2.5-pro" |
| 449 | | "gemini-2.5-flash" => Some(65_536), |
| 450 | "dots-studio/dots-3-note-preview:free" => Some(512_000), |
| 451 | _ => None, |
| 452 | } |
| 453 | } |
| 454 | |
| 455 | /// Catalog-first reasoning capability. `None` means no catalog row and no |
| 456 | /// remaining cited fallback — unknown, not "not a reasoning model". |
| 457 | /// |
| 458 | /// Prefer this over [`model_supports_reasoning`] when the caller can surface |
| 459 | /// unknown the way unknown cost already is. The bool wrapper still defaults |
| 460 | /// unknown to `false` for existing stream/UI gates. |
| 461 | #[must_use] |
| 462 | pub fn model_reasoning_capability(model: &str) -> Option<bool> { |
| 463 | if let Some(supports_reasoning) = crate::model_catalog::resolved_supports_reasoning(model) { |
| 464 | return Some(supports_reasoning); |
| 465 | } |
| 466 | let lower = model.to_lowercase(); |
| 467 | if canonical_official_deepseek_model_id(&lower).is_some() { |
| 468 | return Some(true); |
| 469 | } |
| 470 | // Bundled Models.dev snapshot (#6032): sourced `reasoning` booleans for |
| 471 | // ids the offline catalog does not carry. Exact-id rows only — the |
| 472 | // resolver never guesses from a prefix. |
| 473 | if let Some(reasoning) = |
| 474 | codewhale_config::catalog::bundled_models_dev_catalog().reasoning_support(&lower) |
| 475 | { |
| 476 | return Some(reasoning); |
| 477 | } |
| 478 | // Remaining prefix/list arms have no bundled-catalog row yet. They stay |
| 479 | // until each family is fully sourced (#6032 part 2). Do not invent rows. |
| 480 | if lower.starts_with("kimi-") { |
| 481 | return Some(true); |
| 482 | } |
| 483 | if lower.starts_with("mistral-medium") |
| 484 | || lower.starts_with("mistral-small") |
| 485 | || lower.starts_with("magistral") |
| 486 | { |
| 487 | return Some(true); |
| 488 | } |
| 489 | let listed = matches!( |
| 490 | lower.as_str(), |
| 491 | "arcee-ai/trinity-large-thinking" |
| 492 | | "thinkingmachines/inkling" |
| 493 | | "google/gemma-4-31b-it" |
| 494 | | "google/gemma-4-31b-it:free" |
| 495 | | "google/gemma-4-26b-a4b-it" |
| 496 | | "google/gemma-4-26b-a4b-it:free" |
| 497 | | "moonshotai/kimi-k2.7-code-highspeed" |
| 498 | | "moonshotai/kimi-k2.6" |
| 499 | | "moonshotai/kimi-k2.6:free" |
| 500 | | "minimax-m3" |
| 501 | | "minimax-m2.7-highspeed" |
| 502 | | "minimax-m2.5" |
| 503 | | "minimax-m2.5-highspeed" |
| 504 | | "minimax-m2.1" |
| 505 | | "minimax-m2.1-highspeed" |
| 506 | | "minimax-m2" |
| 507 | | "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free" |
| 508 | | "nvidia/nemotron-3-ultra-550b-a55b" |
| 509 | | "nvidia/nemotron-3-ultra-550b-a55b:free" |
| 510 | | "qwen/qwen3.6-max-preview" |
| 511 | | "qwen/qwen3.6-27b" |
| 512 | | "tencent/hy3-preview" |
| 513 | | "xiaomi/mimo-v2.5-pro" |
| 514 | | "xiaomi/mimo-v2.5" |
| 515 | | "z-ai/glm-5.1" |
| 516 | | "z-ai/glm-5-turbo" |
| 517 | | "glm-5-turbo" |
| 518 | | "grok-build" |
| 519 | | "grok-4.20-0309-reasoning" |
| 520 | ) || is_openai_gpt_55_api_model(&lower) |
| 521 | || is_openai_gpt_56_api_model(&lower) |
| 522 | || is_openai_codex_model(&lower); |
| 523 | listed.then_some(true) |
| 524 | } |
| 525 | |
| 526 | #[must_use] |
| 527 | pub fn model_supports_reasoning(model: &str) -> bool { |
| 528 | model_reasoning_capability(model).unwrap_or(false) |
| 529 | } |
| 530 | |
| 531 | /// Contributor tier of Muse Spark 1.2 is a distinct selectable id with |
| 532 | /// its own wire model (`muse-spark-1.2-contributor`) and cheaper billing in |
| 533 | /// exchange for training-data opt-in. Do not collapse it to the standard tier. |
| 534 | #[must_use] |
| 535 | pub fn effective_muse_wire_id(model: &str) -> &str { |
| 536 | model |
| 537 | } |
| 538 | |
| 539 | #[must_use] |
| 540 | pub fn model_is_openai_reasoning_family(model: &str) -> bool { |
| 541 | let lower = model.to_lowercase(); |
| 542 | is_openai_gpt_55_api_model(&lower) |
| 543 | || is_openai_gpt_56_api_model(&lower) |
| 544 | || is_openai_codex_model(&lower) |
| 545 | } |
| 546 | |
| 547 | fn is_openai_gpt_55_api_model(model_lower: &str) -> bool { |
| 548 | matches!(model_lower, "gpt-5.5" | "gpt-5.5-pro") |
| 549 | || has_date_snapshot_suffix(model_lower, "gpt-5.5-") |
| 550 | || has_date_snapshot_suffix(model_lower, "gpt-5.5-pro-") |
| 551 | } |
| 552 | |
| 553 | pub fn is_openai_gpt_56_api_model(model_lower: &str) -> bool { |
| 554 | matches!( |
| 555 | model_lower, |
| 556 | "gpt-5.6" | "gpt-5.6-sol" | "gpt-5.6-terra" | "gpt-5.6-luna" |
| 557 | ) |
| 558 | } |
| 559 | |
| 560 | fn is_openai_codex_model(model_lower: &str) -> bool { |
| 561 | matches!( |
| 562 | model_lower, |
| 563 | "gpt-5-codex" |
| 564 | | "gpt-5.1-codex" |
| 565 | | "gpt-5.1-codex-mini" |
| 566 | | "gpt-5.1-codex-max" |
| 567 | | "gpt-5.2-codex" |
| 568 | | "gpt-5.3-codex" |
| 569 | | "codex-gpt-5.5" |
| 570 | | "chatgpt-gpt-5.5" |
| 571 | | "gpt-5.5-codex" |
| 572 | | "gpt-5.5-codex-preview" |
| 573 | | "codex-gpt-5.5-preview" |
| 574 | | "chatgpt-gpt-5.5-preview" |
| 575 | ) |
| 576 | } |
| 577 | |
| 578 | pub fn has_date_snapshot_suffix(model_lower: &str, prefix: &str) -> bool { |
| 579 | let Some(rest) = model_lower.strip_prefix(prefix) else { |
| 580 | return false; |
| 581 | }; |
| 582 | let bytes = rest.as_bytes(); |
| 583 | bytes.len() == 10 |
| 584 | && bytes[4] == b'-' |
| 585 | && bytes[7] == b'-' |
| 586 | && bytes |
| 587 | .iter() |
| 588 | .enumerate() |
| 589 | .all(|(idx, byte)| idx == 4 || idx == 7 || byte.is_ascii_digit()) |
| 590 | } |
| 591 | |
| 592 | /// The context window a model name's `_Nk` suffix advertises, when the |
| 593 | /// catalog does not already describe the model (#5441). |
| 594 | /// |
| 595 | /// Exposed separately from `explicit_context_window_hint` because the |
| 596 | /// honesty surfaces need to know *whether the number they are holding came |
| 597 | /// from the name* — a naming convention the serving engine may ignore is not |
| 598 | /// a fact about the route, and every surface that shows such a window must |
| 599 | /// mark it unverified. |
| 600 | #[must_use] |
| 601 | pub fn name_suffix_context_window_hint(model: &str) -> Option<u32> { |
| 602 | if crate::model_catalog::resolved_context_window(model).is_some() { |
| 603 | return None; |
| 604 | } |
| 605 | explicit_context_window_hint(&model.to_lowercase()) |
| 606 | } |
| 607 | |
| 608 | /// Parse an explicit `_Nk` context-window hint from a model name (vendor |
| 609 | /// agnostic). Returns the window in tokens for `N` in `8..=1024`. |
| 610 | fn explicit_context_window_hint(model_lower: &str) -> Option<u32> { |
| 611 | let bytes = model_lower.as_bytes(); |
| 612 | let mut i = 0usize; |
| 613 | while i < bytes.len() { |
| 614 | if bytes[i].is_ascii_digit() { |
| 615 | let start = i; |
| 616 | while i < bytes.len() && bytes[i].is_ascii_digit() { |
| 617 | i += 1; |
| 618 | } |
| 619 | if i >= bytes.len() || bytes[i] != b'k' { |
| 620 | continue; |
| 621 | } |
| 622 | |
| 623 | let before_ok = start == 0 || !bytes[start - 1].is_ascii_alphanumeric(); |
| 624 | let after_ok = i + 1 >= bytes.len() || !bytes[i + 1].is_ascii_alphanumeric(); |
| 625 | if !before_ok || !after_ok { |
| 626 | continue; |
| 627 | } |
| 628 | |
| 629 | if let Ok(kilo_tokens) = model_lower[start..i].parse::<u32>() |
| 630 | && (8..=1024).contains(&kilo_tokens) |
| 631 | { |
| 632 | return Some(kilo_tokens.saturating_mul(1000)); |
| 633 | } |
| 634 | } else { |
| 635 | i += 1; |
| 636 | } |
| 637 | } |
| 638 | None |
| 639 | } |
| 640 | |
| 641 | /// Derive a compaction token threshold from model context and a caller-supplied |
| 642 | /// percentage. |
| 643 | #[must_use] |
| 644 | #[cfg(any(test, feature = "test-support"))] |
| 645 | pub fn compaction_threshold_for_model_at_percent(model: &str, percent: f64) -> usize { |
| 646 | let Some(window) = context_window_for_model(model) else { |
| 647 | return DEFAULT_COMPACTION_TOKEN_THRESHOLD; |
| 648 | }; |
| 649 | |
| 650 | let percent = percent.clamp(10.0, 100.0); |
| 651 | let threshold = (f64::from(window) * percent / 100.0).round(); |
| 652 | let threshold = if threshold.is_finite() && threshold > 0.0 { |
| 653 | threshold as u64 |
| 654 | } else { |
| 655 | u64::from(window) * u64::from(COMPACTION_THRESHOLD_PERCENT) / 100 |
| 656 | }; |
| 657 | usize::try_from(threshold).unwrap_or(DEFAULT_COMPACTION_TOKEN_THRESHOLD) |
| 658 | } |
| 659 | |
| 660 | /// Whether auto-compaction should be enabled when the user did not explicitly |
| 661 | /// configure it. Known model windows default automatic continuity on; an |
| 662 | /// explicit `auto_compact = false` remains authoritative at the call sites. |
| 663 | #[must_use] |
| 664 | #[cfg(test)] |
| 665 | pub fn auto_compact_default_for_model(model: &str) -> bool { |
| 666 | context_window_for_model(model).is_some() |
| 667 | } |
| 668 | |
| 669 | // === Streaming Structures === |
| 670 | |
| 671 | #[allow(dead_code)] |
| 672 | #[derive(Debug, Deserialize, Clone)] |
| 673 | #[serde(tag = "type")] |
| 674 | /// Streaming event types for SSE responses. |
| 675 | pub enum StreamEvent { |
| 676 | /// Local pre-stream receipt: the provider request was sent with a reduced |
| 677 | /// tool surface. This is not provider SSE and must not count as content. |
| 678 | #[serde(rename = "tool_projection_warning")] |
| 679 | ToolProjectionWarning { |
| 680 | provider: String, |
| 681 | omitted_tool_names: Vec<String>, |
| 682 | omitted_tool_count: usize, |
| 683 | }, |
| 684 | #[serde(rename = "message_start")] |
| 685 | MessageStart { message: MessageResponse }, |
| 686 | #[serde(rename = "content_block_start")] |
| 687 | ContentBlockStart { |
| 688 | index: u32, |
| 689 | content_block: ContentBlockStart, |
| 690 | }, |
| 691 | #[serde(rename = "content_block_delta")] |
| 692 | ContentBlockDelta { index: u32, delta: Delta }, |
| 693 | #[serde(rename = "content_block_stop")] |
| 694 | ContentBlockStop { index: u32 }, |
| 695 | #[serde(rename = "message_delta")] |
| 696 | MessageDelta { |
| 697 | delta: MessageDelta, |
| 698 | usage: Option<Usage>, |
| 699 | }, |
| 700 | #[serde(rename = "message_stop")] |
| 701 | MessageStop, |
| 702 | #[serde(rename = "ping")] |
| 703 | Ping, |
| 704 | /// Anthropic SSE error event (#3014). |
| 705 | #[serde(rename = "error")] |
| 706 | Error { error: serde_json::Value }, |
| 707 | } |
| 708 | |
| 709 | #[allow(dead_code)] |
| 710 | #[derive(Debug, Deserialize, Clone)] |
| 711 | #[serde(tag = "type")] |
| 712 | /// Content block types used in streaming starts. |
| 713 | pub enum ContentBlockStart { |
| 714 | #[serde(rename = "text")] |
| 715 | Text { text: String }, |
| 716 | #[serde(rename = "thinking")] |
| 717 | Thinking { thinking: String }, |
| 718 | #[serde(rename = "tool_use")] |
| 719 | ToolUse { |
| 720 | id: String, |
| 721 | name: String, |
| 722 | input: serde_json::Value, // usually empty or partial |
| 723 | #[serde(skip_serializing_if = "Option::is_none")] |
| 724 | caller: Option<ToolCaller>, |
| 725 | /// Google thought signature, when the first streaming chunk of this |
| 726 | /// tool call carried `extra_content.google.thought_signature`. |
| 727 | #[serde(skip_serializing_if = "Option::is_none")] |
| 728 | thought_signature: Option<String>, |
| 729 | }, |
| 730 | #[serde(rename = "server_tool_use")] |
| 731 | ServerToolUse { |
| 732 | id: String, |
| 733 | name: String, |
| 734 | input: serde_json::Value, |
| 735 | }, |
| 736 | } |
| 737 | |
| 738 | // Variant names match legacy streaming spec, suppressing style warning |
| 739 | #[allow(clippy::enum_variant_names)] |
| 740 | #[derive(Debug, Deserialize, Clone)] |
| 741 | #[serde(tag = "type")] |
| 742 | /// Delta events emitted during streaming responses. |
| 743 | pub enum Delta { |
| 744 | #[serde(rename = "text_delta")] |
| 745 | TextDelta { text: String }, |
| 746 | #[serde(rename = "thinking_delta")] |
| 747 | ThinkingDelta { thinking: String }, |
| 748 | #[serde(rename = "input_json_delta")] |
| 749 | InputJsonDelta { partial_json: String }, |
| 750 | /// Anthropic signed-thinking signature delta (#3014); arrives at the end |
| 751 | /// of a thinking block on the native Messages stream. |
| 752 | #[serde(rename = "signature_delta")] |
| 753 | SignatureDelta { signature: String }, |
| 754 | /// Opaque Responses reasoning continuity, attached only when the provider |
| 755 | /// returns an encrypted item on the exact originating route. |
| 756 | #[serde(rename = "reasoning_state_delta")] |
| 757 | ReasoningStateDelta { state: OpaqueReasoningState }, |
| 758 | } |
| 759 | |
| 760 | #[allow(dead_code)] |
| 761 | #[derive(Debug, Deserialize, Clone)] |
| 762 | /// Delta payload for message-level updates. |
| 763 | pub struct MessageDelta { |
| 764 | pub stop_reason: Option<String>, |
| 765 | pub stop_sequence: Option<String>, |
| 766 | } |
| 767 | |
| 768 | #[cfg(test)] |
| 769 | mod tests { |
| 770 | use super::*; |
| 771 | use std::any::TypeId; |
| 772 | use std::collections::BTreeMap; |
| 773 | |
| 774 | /// #6032: `model_supports_reasoning` consults the catalog before its |
| 775 | /// hand-maintained pile, so a literal arm that duplicates a catalog row is |
| 776 | /// unreachable. These 27 arms were exactly that and were deleted. They must |
| 777 | /// keep answering `true` from the catalog *alone* — if a row is ever |
| 778 | /// dropped, this fails loudly here rather than silently reverting them to |
| 779 | /// "reasoning not expected", which leaks their `reasoning_content` into |
| 780 | /// ordinary prose (#6044). |
| 781 | #[test] |
| 782 | fn unknown_reasoning_capability_is_observable() { |
| 783 | assert_eq!( |
| 784 | model_reasoning_capability("not-a-real-model-xyz"), |
| 785 | None, |
| 786 | "unknown must not collapse to false at this layer" |
| 787 | ); |
| 788 | assert!( |
| 789 | !model_supports_reasoning("not-a-real-model-xyz"), |
| 790 | "legacy bool wrapper still defaults unknown to false" |
| 791 | ); |
| 792 | assert_eq!(model_reasoning_capability("kimi-for-coding"), Some(true)); |
| 793 | } |
| 794 | |
| 795 | #[test] |
| 796 | fn catalog_alone_covers_the_models_removed_from_the_heuristic_pile() { |
| 797 | let removed = [ |
| 798 | "claude-opus-4-8", |
| 799 | "claude-opus-5", |
| 800 | "claude-sonnet-4-6", |
| 801 | "claude-sonnet-5", |
| 802 | "claude-fable-5", |
| 803 | "gpt-5-codex", |
| 804 | "gpt-5.3-codex", |
| 805 | "trinity-mini", |
| 806 | "trinity-large-thinking", |
| 807 | "moonshotai/kimi-k2.7-code", |
| 808 | "kimi-k2.7-code", |
| 809 | "minimax/minimax-m3", |
| 810 | "minimax/minimax-m2.7", |
| 811 | "minimax-m2.7", |
| 812 | "qwen/qwen3.6-flash", |
| 813 | "mimo-v2.5", |
| 814 | "mimo-v2.5-pro", |
| 815 | "mimo-v2.5-pro-ultraspeed", |
| 816 | "z-ai/glm-5.2", |
| 817 | "z-ai/glm-5.3", |
| 818 | "z-ai/glm-5.3-flash", |
| 819 | "glm-5.2", |
| 820 | "glm-5.3", |
| 821 | "glm-5.3-flash", |
| 822 | "muse-spark-1.1", |
| 823 | "muse-spark-1.2", |
| 824 | "muse-spark-1.2-contributor", |
| 825 | // Part 2: cited qwen3.x / Kimi coding-route arms moved into the |
| 826 | // bundled catalog (Alibaba Cloud Model Studio deep-thinking docs; |
| 827 | // #3016 plus the 2026 K2.7 update). |
| 828 | "kimi-for-coding", |
| 829 | "kimi-for-coding-highspeed", |
| 830 | "kimi-k2.5", |
| 831 | "kimi-k2.6", |
| 832 | "qwen3.5-flash", |
| 833 | "qwen3.5-plus", |
| 834 | "qwen3.6-flash", |
| 835 | "qwen3.6-plus", |
| 836 | "qwen3.7-max", |
| 837 | "qwen3.7-plus", |
| 838 | "qwen3.8-flash", |
| 839 | "qwen3.8-max", |
| 840 | "qwen3.8-max-preview", |
| 841 | ]; |
| 842 | for model in removed { |
| 843 | assert_eq!( |
| 844 | crate::model_catalog::resolved_supports_reasoning(model), |
| 845 | Some(true), |
| 846 | "{model} no longer has a catalog row, but its heuristic arm was \ |
| 847 | deleted in #6032 — restore the row, or put the arm back" |
| 848 | ); |
| 849 | assert!( |
| 850 | model_supports_reasoning(model), |
| 851 | "{model} must still classify as reasoning-capable" |
| 852 | ); |
| 853 | } |
| 854 | } |
| 855 | |
| 856 | /// #6032 part 3: the eight literal arms deleted in favor of the bundled |
| 857 | /// Models.dev snapshot. Each id's `reasoning` bool is sourced there (all |
| 858 | /// `true`), so they must keep answering without a heuristic arm — if a |
| 859 | /// row is dropped from the asset, this fails loudly instead of silently |
| 860 | /// reverting them to "reasoning not expected" (#6044). |
| 861 | #[test] |
| 862 | fn models_dev_bundled_asset_covers_the_arms_deleted_from_the_heuristic_pile() { |
| 863 | let catalog = codewhale_config::catalog::bundled_models_dev_catalog(); |
| 864 | let deleted = [ |
| 865 | "qwen/qwen3.8-flash", |
| 866 | "qwen/qwen3.6-35b-a3b", |
| 867 | "qwen/qwen3.6-plus", |
| 868 | "qwen/qwen3.7-plus", |
| 869 | "glm-5.1", |
| 870 | "grok-4.6", |
| 871 | "grok-4.5", |
| 872 | "grok-4.3", |
| 873 | ]; |
| 874 | for model in deleted { |
| 875 | assert_eq!( |
| 876 | catalog.reasoning_support(model), |
| 877 | Some(true), |
| 878 | "{model} lost its bundled Models.dev row — restore the row or put \ |
| 879 | its heuristic arm back (#6032)" |
| 880 | ); |
| 881 | assert!( |
| 882 | model_supports_reasoning(model), |
| 883 | "{model} must still classify as reasoning-capable" |
| 884 | ); |
| 885 | } |
| 886 | } |
| 887 | |
| 888 | /// #6032 part 3 coverage guard: every literal arm remaining in the |
| 889 | /// heuristic pile must be an id the bundled Models.dev snapshot does NOT |
| 890 | /// source, so the pile can only shrink as sourcing grows. If this fails, |
| 891 | /// the named arm is now redundant (or disagrees with the asset) — delete |
| 892 | /// it, do not widen it. |
| 893 | /// |
| 894 | /// Still unsourced/unknown as of 2026-09-15: |
| 895 | /// - the literal list below (OpenRouter-style vendor ids for Gemma, |
| 896 | /// Kimi, MiniMax, Nemotron, Qwen, Hunyuan, MiMo, GLM, Grok families); |
| 897 | /// - the `kimi-` / `mistral-medium` / `mistral-small` / `magistral` |
| 898 | /// prefix arms (no asset row states any Mistral-family fact at all); |
| 899 | /// - `is_openai_gpt_55_api_model` / `is_openai_gpt_56_api_model` / |
| 900 | /// `is_openai_codex_model`, which must stay for their date-snapshot |
| 901 | /// and chatgpt/codex variants (`gpt-5.5-2026-06-01`, |
| 902 | /// `codex-gpt-5.5-preview`, …) that the asset does not enumerate. |
| 903 | #[test] |
| 904 | fn remaining_reasoning_heuristic_arms_are_unsourced_in_models_dev_bundled_asset() { |
| 905 | let catalog = codewhale_config::catalog::bundled_models_dev_catalog(); |
| 906 | let remaining = [ |
| 907 | "arcee-ai/trinity-large-thinking", |
| 908 | "thinkingmachines/inkling", |
| 909 | "google/gemma-4-31b-it", |
| 910 | "google/gemma-4-31b-it:free", |
| 911 | "google/gemma-4-26b-a4b-it", |
| 912 | "google/gemma-4-26b-a4b-it:free", |
| 913 | "moonshotai/kimi-k2.7-code-highspeed", |
| 914 | "moonshotai/kimi-k2.6", |
| 915 | "moonshotai/kimi-k2.6:free", |
| 916 | "minimax-m3", |
| 917 | "minimax-m2.7-highspeed", |
| 918 | "minimax-m2.5", |
| 919 | "minimax-m2.5-highspeed", |
| 920 | "minimax-m2.1", |
| 921 | "minimax-m2.1-highspeed", |
| 922 | "minimax-m2", |
| 923 | "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free", |
| 924 | "nvidia/nemotron-3-ultra-550b-a55b", |
| 925 | "nvidia/nemotron-3-ultra-550b-a55b:free", |
| 926 | "qwen/qwen3.6-max-preview", |
| 927 | "qwen/qwen3.6-27b", |
| 928 | "tencent/hy3-preview", |
| 929 | "xiaomi/mimo-v2.5-pro", |
| 930 | "xiaomi/mimo-v2.5", |
| 931 | "z-ai/glm-5.1", |
| 932 | "z-ai/glm-5-turbo", |
| 933 | "glm-5-turbo", |
| 934 | "grok-build", |
| 935 | "grok-4.20-0309-reasoning", |
| 936 | ]; |
| 937 | for model in remaining { |
| 938 | assert_eq!( |
| 939 | catalog.reasoning_support(model), |
| 940 | None, |
| 941 | "{model} is now sourced by the bundled Models.dev asset — delete \ |
| 942 | its heuristic arm instead of keeping both (#6032)" |
| 943 | ); |
| 944 | assert_eq!( |
| 945 | model_reasoning_capability(model), |
| 946 | Some(true), |
| 947 | "{model} must still classify as reasoning-capable via the pile" |
| 948 | ); |
| 949 | } |
| 950 | // The prefix/function arms are not enumerable literals, so the guard |
| 951 | // samples both directions: asset rows they overlap must agree with |
| 952 | // the arm's claim (`true`), and unsourced variants that still need |
| 953 | // the arm must stay unknown in the asset. |
| 954 | for sourced_and_agreeing in [ |
| 955 | "kimi-k2.6", |
| 956 | "kimi-k2.7-code", |
| 957 | "kimi-k2.7-code-highspeed", |
| 958 | "kimi-k3", |
| 959 | "gpt-5.5", |
| 960 | "gpt-5.5-pro", |
| 961 | "gpt-5.6", |
| 962 | "gpt-5.6-sol", |
| 963 | "gpt-5.6-terra", |
| 964 | "gpt-5.6-luna", |
| 965 | "gpt-5.3-codex", |
| 966 | ] { |
| 967 | assert_eq!( |
| 968 | catalog.reasoning_support(sourced_and_agreeing), |
| 969 | Some(true), |
| 970 | "{sourced_and_agreeing} now disagrees with its heuristic arm — \ |
| 971 | resolve the source before shipping (#6032)" |
| 972 | ); |
| 973 | } |
| 974 | for still_arm_only in [ |
| 975 | "kimi-k2.9", |
| 976 | "mistral-medium-latest", |
| 977 | "mistral-small-latest", |
| 978 | "magistral-medium", |
| 979 | "gpt-5.5-2026-06-01", |
| 980 | "gpt-5.1-codex-max", |
| 981 | "codex-gpt-5.5-preview", |
| 982 | "chatgpt-gpt-5.5", |
| 983 | ] { |
| 984 | assert_eq!( |
| 985 | catalog.reasoning_support(still_arm_only), |
| 986 | None, |
| 987 | "{still_arm_only} is now sourced — its heuristic arm family may \ |
| 988 | be shrinkable (#6032)" |
| 989 | ); |
| 990 | assert!(model_supports_reasoning(still_arm_only)); |
| 991 | } |
| 992 | } |
| 993 | |
| 994 | #[test] |
| 995 | fn output_limit_stop_reason_accepts_provider_aliases_only() { |
| 996 | for reason in [ |
| 997 | "length", |
| 998 | "max_tokens", |
| 999 | "max_output_tokens", |
| 1000 | " MAX_TOKENS ", |
| 1001 | "incomplete:max_output_tokens", |
| 1002 | ] { |
| 1003 | assert!(is_output_limit_stop_reason(Some(reason)), "{reason}"); |
| 1004 | } |
| 1005 | for reason in [None, Some("end_turn"), Some("tool_use"), Some("")] { |
| 1006 | assert!(!is_output_limit_stop_reason(reason), "{reason:?}"); |
| 1007 | } |
| 1008 | } |
| 1009 | |
| 1010 | #[test] |
| 1011 | fn incomplete_stop_reason_never_accepts_unknown_responses_failures() { |
| 1012 | assert!(is_incomplete_stop_reason(Some("incomplete:content_filter"))); |
| 1013 | assert!(is_incomplete_stop_reason(Some("content_filter"))); |
| 1014 | assert!(is_incomplete_stop_reason(Some( |
| 1015 | "model_context_window_exceeded" |
| 1016 | ))); |
| 1017 | assert!(is_incomplete_stop_reason(Some("max_tokens"))); |
| 1018 | assert!(!is_incomplete_stop_reason(Some("end_turn"))); |
| 1019 | assert_eq!( |
| 1020 | stop_reason_detail(Some("incomplete:content_filter")), |
| 1021 | "content_filter" |
| 1022 | ); |
| 1023 | } |
| 1024 | |
| 1025 | #[test] |
| 1026 | fn historical_tui_request_path_is_the_core_request_type() { |
| 1027 | assert_eq!( |
| 1028 | TypeId::of::<MessageRequest>(), |
| 1029 | TypeId::of::<codewhale_core::request::MessageRequest>() |
| 1030 | ); |
| 1031 | |
| 1032 | let via_tui_path = MessageRequest { |
| 1033 | model: "model".to_string(), |
| 1034 | messages: vec![], |
| 1035 | max_tokens: 1024, |
| 1036 | system: None, |
| 1037 | tools: None, |
| 1038 | tool_choice: None, |
| 1039 | metadata: None, |
| 1040 | thinking: None, |
| 1041 | reasoning_effort: None, |
| 1042 | stream: Some(true), |
| 1043 | temperature: None, |
| 1044 | top_p: None, |
| 1045 | }; |
| 1046 | let via_core_path: codewhale_core::request::MessageRequest = via_tui_path.clone(); |
| 1047 | assert_eq!( |
| 1048 | serde_json::to_vec(&via_tui_path).expect("serialize TUI path"), |
| 1049 | serde_json::to_vec(&via_core_path).expect("serialize core path") |
| 1050 | ); |
| 1051 | } |
| 1052 | |
| 1053 | #[test] |
| 1054 | fn interrupted_assistant_role_round_trips_as_distinct_session_item() { |
| 1055 | let message = Message { |
| 1056 | role: Role::InterruptedAssistant, |
| 1057 | content: vec![ContentBlock::Text { |
| 1058 | text: "partial output".to_string(), |
| 1059 | cache_control: None, |
| 1060 | }], |
| 1061 | }; |
| 1062 | let encoded = serde_json::to_string(&message).expect("message should serialize"); |
| 1063 | let decoded: Message = serde_json::from_str(&encoded).expect("message should deserialize"); |
| 1064 | assert_eq!(decoded, message); |
| 1065 | assert_ne!(decoded.role, "assistant"); |
| 1066 | } |
| 1067 | |
| 1068 | #[test] |
| 1069 | fn unrecognized_deepseek_models_do_not_inherit_sibling_metadata() { |
| 1070 | for model in [ |
| 1071 | "deepseek-v4-flash-20260423", |
| 1072 | "deepseek-v4-pro-20260423", |
| 1073 | "deepseek-coder", |
| 1074 | "deepseek-v3.2-0324", |
| 1075 | "deepseek-v4.1-flash-expires-on-0910", |
| 1076 | ] { |
| 1077 | assert_eq!(context_window_for_model(model), None, "{model}"); |
| 1078 | } |
| 1079 | assert!(!model_supports_reasoning( |
| 1080 | "deepseek-v4.1-flash-expires-on-0910" |
| 1081 | )); |
| 1082 | } |
| 1083 | |
| 1084 | #[test] |
| 1085 | fn deepseek_v4_models_map_to_1m_context_window() { |
| 1086 | assert_eq!( |
| 1087 | context_window_for_model("deepseek-v4-pro"), |
| 1088 | Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS) |
| 1089 | ); |
| 1090 | assert_eq!( |
| 1091 | context_window_for_model("deepseek-v4-flash"), |
| 1092 | Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS) |
| 1093 | ); |
| 1094 | assert_eq!( |
| 1095 | context_window_for_model("deepseek-ai/deepseek-v4-pro"), |
| 1096 | Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS) |
| 1097 | ); |
| 1098 | } |
| 1099 | |
| 1100 | #[test] |
| 1101 | fn deepseek_v4_output_caps_require_exact_catalog_metadata() { |
| 1102 | let _lock = crate::model_catalog::test_catalog_lock(); |
| 1103 | let catalog = crate::model_catalog::MergedCatalog::from_sources( |
| 1104 | BTreeMap::new(), |
| 1105 | None, |
| 1106 | crate::model_catalog::bundled_catalog(), |
| 1107 | chrono::Utc::now(), |
| 1108 | ); |
| 1109 | let _guard = crate::model_catalog::replace_active_catalog_for_test(catalog); |
| 1110 | |
| 1111 | for model in [ |
| 1112 | "deepseek-v4.1-flash-expires-on-0910", |
| 1113 | "deepseek-v4.1-flash", |
| 1114 | "deepseek-v4.1-pro", |
| 1115 | "vendor/deepseek-v4.1-flash", |
| 1116 | "deepseek-v4-flash-vendor", |
| 1117 | ] { |
| 1118 | assert!( |
| 1119 | crate::model_catalog::resolved_entry(model).is_none(), |
| 1120 | "{model}" |
| 1121 | ); |
| 1122 | assert_eq!(max_output_tokens_for_model(model), None, "{model}"); |
| 1123 | } |
| 1124 | |
| 1125 | for model in [ |
| 1126 | "deepseek-v4-flash", |
| 1127 | "deepseek-v4-pro", |
| 1128 | "deepseek-v4-flash-vision-exp", |
| 1129 | "DEEPSEEK-V4-FLASH", |
| 1130 | ] { |
| 1131 | assert_eq!(max_output_tokens_for_model(model), Some(384_000), "{model}"); |
| 1132 | } |
| 1133 | } |
| 1134 | |
| 1135 | #[test] |
| 1136 | fn recent_openrouter_large_models_have_static_windows() { |
| 1137 | for (model, expected_window) in [ |
| 1138 | ("arcee-ai/trinity-large-thinking", 262_144), |
| 1139 | ("trinity-large-thinking", 262_144), |
| 1140 | (concat!("qwen/", "qwen3.8-flash"), 1_000_000), |
| 1141 | (concat!("qwen/", "qwen3.6-flash"), 1_000_000), |
| 1142 | (concat!("qwen/", "qwen3.6-35b-a3b"), 262_144), |
| 1143 | (concat!("qwen/", "qwen3.6-max-preview"), 262_144), |
| 1144 | (concat!("qwen/", "qwen3.6-plus"), 1_000_000), |
| 1145 | (concat!("xiaomi/", "mimo-v2.5-pro"), 1_000_000), |
| 1146 | ("mimo-v2.5-pro", 1_000_000), |
| 1147 | ("mimo-v2.5-pro-ultraspeed", 1_000_000), |
| 1148 | ("mimo-v2.5", 1_000_000), |
| 1149 | ("minimax/minimax-m3", 1_000_000), |
| 1150 | ("minimax/minimax-m2.7", 204_800), |
| 1151 | ("moonshotai/kimi-k2.7-code", 262_144), |
| 1152 | ("moonshotai/kimi-k2.6", 262_144), |
| 1153 | ("google/gemma-4-31b-it", 262_144), |
| 1154 | ("z-ai/glm-5.1", 202_752), |
| 1155 | ("z-ai/glm-5.2", 1_000_000), |
| 1156 | ("z-ai/glm-5.3", 1_000_000), |
| 1157 | ("z-ai/glm-5.3-flash", 1_000_000), |
| 1158 | ] { |
| 1159 | assert_eq!(context_window_for_model(model), Some(expected_window)); |
| 1160 | assert!(model_supports_reasoning(model)); |
| 1161 | } |
| 1162 | } |
| 1163 | |
| 1164 | #[test] |
| 1165 | fn openai_api_and_codex_models_have_verified_context_metadata() { |
| 1166 | for model in ["gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] { |
| 1167 | assert_eq!(context_window_for_model(model), Some(1_050_000)); |
| 1168 | assert_eq!(max_output_tokens_for_model(model), Some(128_000)); |
| 1169 | assert!(model_supports_reasoning(model)); |
| 1170 | assert_eq!( |
| 1171 | compaction_threshold_for_model_at_percent(model, 80.0), |
| 1172 | 840_000 |
| 1173 | ); |
| 1174 | } |
| 1175 | |
| 1176 | for model in [ |
| 1177 | "gpt-5.5", |
| 1178 | "gpt-5.5-pro", |
| 1179 | "gpt-5.5-2026-04-23", |
| 1180 | "gpt-5.5-pro-2026-04-23", |
| 1181 | ] { |
| 1182 | assert_eq!(context_window_for_model(model), Some(1_050_000)); |
| 1183 | assert_eq!(max_output_tokens_for_model(model), Some(128_000)); |
| 1184 | assert!(model_supports_reasoning(model)); |
| 1185 | assert_eq!( |
| 1186 | compaction_threshold_for_model_at_percent(model, 80.0), |
| 1187 | 840_000 |
| 1188 | ); |
| 1189 | } |
| 1190 | |
| 1191 | for model in [ |
| 1192 | "gpt-5-codex", |
| 1193 | "gpt-5.1-codex", |
| 1194 | "gpt-5.1-codex-mini", |
| 1195 | "gpt-5.1-codex-max", |
| 1196 | "gpt-5.2-codex", |
| 1197 | "gpt-5.3-codex", |
| 1198 | "codex-gpt-5.5", |
| 1199 | "chatgpt-gpt-5.5", |
| 1200 | "gpt-5.5-codex", |
| 1201 | "gpt-5.5-codex-preview", |
| 1202 | ] { |
| 1203 | assert_eq!(context_window_for_model(model), Some(400_000)); |
| 1204 | assert_eq!(max_output_tokens_for_model(model), Some(128_000)); |
| 1205 | assert!(model_supports_reasoning(model)); |
| 1206 | assert_eq!( |
| 1207 | compaction_threshold_for_model_at_percent(model, 80.0), |
| 1208 | 320_000 |
| 1209 | ); |
| 1210 | } |
| 1211 | |
| 1212 | assert_eq!(context_window_for_model("gpt-5.5-nano"), None); |
| 1213 | assert_eq!(max_output_tokens_for_model("gpt-5.5-nano"), None); |
| 1214 | assert!(!model_supports_reasoning("gpt-5.5-nano")); |
| 1215 | } |
| 1216 | |
| 1217 | #[test] |
| 1218 | fn anthropic_stepfun_and_sakana_limits_match_2026_07_09_audit() { |
| 1219 | // Sonnet 4.6 output cap raised 64K -> 128K per |
| 1220 | // https://platform.claude.com/docs/en/about-claude/models/overview; |
| 1221 | // Haiku stays at 64K. |
| 1222 | assert_eq!( |
| 1223 | max_output_tokens_for_model("claude-sonnet-4-6"), |
| 1224 | Some(128_000) |
| 1225 | ); |
| 1226 | assert_eq!( |
| 1227 | max_output_tokens_for_model("claude-haiku-4-5"), |
| 1228 | Some(64_000) |
| 1229 | ); |
| 1230 | // step-3.7-flash max output is third-party sourced (models.dev + |
| 1231 | // Artificial Analysis; the official StepFun page is silent): |
| 1232 | // https://models.dev/models/stepfun/step-3.7-flash/ |
| 1233 | assert_eq!(max_output_tokens_for_model("step-3.7-flash"), Some(256_000)); |
| 1234 | assert_eq!(context_window_for_model("step-3.7-flash"), Some(256_000)); |
| 1235 | // fugu-ultra limits are third-party sourced (Requesty; Sakana's own |
| 1236 | // >272K price tier at https://console.sakana.ai/pricing confirms the |
| 1237 | // context window exceeds 272K). |
| 1238 | for model in ["fugu-ultra", "fugu-ultra-20260615"] { |
| 1239 | assert_eq!(context_window_for_model(model), Some(1_000_000), "{model}"); |
| 1240 | assert_eq!(max_output_tokens_for_model(model), Some(131_000), "{model}"); |
| 1241 | } |
| 1242 | } |
| 1243 | |
| 1244 | #[test] |
| 1245 | fn stepfun_current_coding_models_have_verified_metadata() { |
| 1246 | assert_eq!(context_window_for_model("step-5-preview"), Some(1_000_000)); |
| 1247 | assert_eq!( |
| 1248 | max_output_tokens_for_model("step-5-preview"), |
| 1249 | Some(1_000_000) |
| 1250 | ); |
| 1251 | for model in [ |
| 1252 | "step-5-preview", |
| 1253 | "step-3.7-flash", |
| 1254 | "step-3.5-flash", |
| 1255 | "step-3.5-flash-2603", |
| 1256 | ] { |
| 1257 | assert!(model_supports_reasoning(model), "{model}"); |
| 1258 | } |
| 1259 | for model in ["step-3.5-flash", "step-3.5-flash-2603"] { |
| 1260 | assert_eq!(context_window_for_model(model), Some(256_000)); |
| 1261 | assert_eq!(max_output_tokens_for_model(model), None); |
| 1262 | } |
| 1263 | } |
| 1264 | |
| 1265 | #[test] |
| 1266 | fn claude_fable_5_and_sonnet_5_have_verified_metadata() { |
| 1267 | // 1M context / 128K output per |
| 1268 | // https://platform.claude.com/docs/en/about-claude/pricing (2026-07-09). |
| 1269 | for model in ["claude-fable-5", "claude-sonnet-5"] { |
| 1270 | assert_eq!(context_window_for_model(model), Some(1_000_000), "{model}"); |
| 1271 | assert_eq!(max_output_tokens_for_model(model), Some(128_000), "{model}"); |
| 1272 | assert!(model_supports_reasoning(model), "{model}"); |
| 1273 | } |
| 1274 | } |
| 1275 | |
| 1276 | #[test] |
| 1277 | fn claude_opus_5_has_verified_metadata() { |
| 1278 | // 1M context / 128K output, adaptive thinking, per |
| 1279 | // https://platform.claude.com/docs/en/about-claude/models/overview |
| 1280 | // (2026-08-17). |
| 1281 | assert_eq!(context_window_for_model("claude-opus-5"), Some(1_000_000)); |
| 1282 | assert_eq!(max_output_tokens_for_model("claude-opus-5"), Some(128_000)); |
| 1283 | assert!(model_supports_reasoning("claude-opus-5")); |
| 1284 | } |
| 1285 | |
| 1286 | #[test] |
| 1287 | fn kimi_k2_7_code_highspeed_shares_the_k2_7_code_limits() { |
| 1288 | // https://platform.kimi.ai/docs/pricing/chat-k27-code (2026-08-17): |
| 1289 | // same model as kimi-k2.7-code, 262,144 context. |
| 1290 | for model in [ |
| 1291 | "kimi-k2.7-code-highspeed", |
| 1292 | "moonshotai/kimi-k2.7-code-highspeed", |
| 1293 | ] { |
| 1294 | assert_eq!(context_window_for_model(model), Some(262_144), "{model}"); |
| 1295 | assert_eq!(max_output_tokens_for_model(model), Some(32_768), "{model}"); |
| 1296 | assert!(model_supports_reasoning(model), "{model}"); |
| 1297 | } |
| 1298 | } |
| 1299 | |
| 1300 | #[test] |
| 1301 | fn gemini_api_models_have_documented_token_limits() { |
| 1302 | // Every current Gemini API text model page lists 1,048,576 input / |
| 1303 | // 65,536 output (verified 2026-08-17, see |
| 1304 | // `known_context_window_for_model`). |
| 1305 | for model in [ |
| 1306 | "gemini-3.7-flash", |
| 1307 | "gemini-3.6-flash", |
| 1308 | "gemini-3.5-flash", |
| 1309 | "gemini-3.5-flash-lite", |
| 1310 | "gemini-3.1-pro-preview", |
| 1311 | "gemini-3-pro-preview", |
| 1312 | "gemini-2.5-pro", |
| 1313 | "gemini-2.5-flash", |
| 1314 | ] { |
| 1315 | assert_eq!(context_window_for_model(model), Some(1_048_576), "{model}"); |
| 1316 | assert_eq!(max_output_tokens_for_model(model), Some(65_536), "{model}"); |
| 1317 | } |
| 1318 | } |
| 1319 | |
| 1320 | #[test] |
| 1321 | fn muse_spark_has_verified_context_and_reasoning_metadata() { |
| 1322 | assert_eq!(context_window_for_model("muse-spark-1.1"), Some(1_000_000)); |
| 1323 | assert_eq!(max_output_tokens_for_model("muse-spark-1.1"), Some(32_000)); |
| 1324 | assert!(model_supports_reasoning("muse-spark-1.1")); |
| 1325 | // Muse Spark 1.2 standard: 1M context, $1.25/$4.25 + $0.15 cache (Artificial Analysis). |
| 1326 | assert_eq!(context_window_for_model("muse-spark-1.2"), Some(1_000_000)); |
| 1327 | assert_eq!(max_output_tokens_for_model("muse-spark-1.2"), Some(32_000)); |
| 1328 | assert!(model_supports_reasoning("muse-spark-1.2")); |
| 1329 | // Contributor tier: same model/limits, ~12×/21× cheaper in exchange for training-data opt-in. |
| 1330 | assert_eq!( |
| 1331 | context_window_for_model("muse-spark-1.2-contributor"), |
| 1332 | Some(1_000_000) |
| 1333 | ); |
| 1334 | assert_eq!( |
| 1335 | max_output_tokens_for_model("muse-spark-1.2-contributor"), |
| 1336 | Some(32_000) |
| 1337 | ); |
| 1338 | assert!(model_supports_reasoning("muse-spark-1.2-contributor")); |
| 1339 | } |
| 1340 | |
| 1341 | #[test] |
| 1342 | fn modelstudio_qwen38_max_is_1m_context_not_128k() { |
| 1343 | // Owner Token Plan console + curated catalog (2026-08-03). The 128K |
| 1344 | // figure is max output, not the window — never collapse them. |
| 1345 | for model in ["qwen3.8-max", "qwen3.8-max-preview"] { |
| 1346 | assert_eq!(context_window_for_model(model), Some(1_000_000), "{model}"); |
| 1347 | assert_eq!(max_output_tokens_for_model(model), Some(131_072), "{model}"); |
| 1348 | } |
| 1349 | } |
| 1350 | |
| 1351 | #[test] |
| 1352 | fn openrouter_qwen38_flash_is_1m_context_with_128k_output() { |
| 1353 | // models.dev OpenRouter listing 2026-08-26: 1,000,000 / 131,072. |
| 1354 | // Both the namespaced wire id and the bare short id must resolve. |
| 1355 | for model in ["qwen/qwen3.8-flash", "qwen3.8-flash"] { |
| 1356 | assert_eq!(context_window_for_model(model), Some(1_000_000), "{model}"); |
| 1357 | assert_eq!(max_output_tokens_for_model(model), Some(131_072), "{model}"); |
| 1358 | assert!(model_supports_reasoning(model), "{model}"); |
| 1359 | } |
| 1360 | } |
| 1361 | |
| 1362 | #[test] |
| 1363 | fn modelstudio_bare_qwen_models_support_reasoning() { |
| 1364 | // Model Studio's deep-thinking docs: every qwen3.x family the Token / |
| 1365 | // Coding Plan catalogs carry is hybrid-thinking (reasoning_content on |
| 1366 | // the OpenAI dialect, thinking blocks on the Anthropic dialect). |
| 1367 | for model in [ |
| 1368 | "qwen3.8-max", |
| 1369 | "qwen3.8-max-preview", |
| 1370 | "qwen3.7-max", |
| 1371 | "qwen3.7-plus", |
| 1372 | "qwen3.6-plus", |
| 1373 | "qwen3.6-flash", |
| 1374 | "qwen3.5-plus", |
| 1375 | "qwen3.5-flash", |
| 1376 | ] { |
| 1377 | assert!(model_supports_reasoning(model), "{model}"); |
| 1378 | } |
| 1379 | } |
| 1380 | |
| 1381 | #[test] |
| 1382 | fn model_metadata_catalog_override_flows_through_models_chokepoint() { |
| 1383 | let _lock = crate::model_catalog::test_catalog_lock(); |
| 1384 | let mut overrides = BTreeMap::new(); |
| 1385 | overrides.insert( |
| 1386 | "catalog-only-model".to_string(), |
| 1387 | crate::model_catalog::CatalogEntry { |
| 1388 | id: "catalog-only-model".to_string(), |
| 1389 | context_window: Some(777_000), |
| 1390 | max_output: Some(55_000), |
| 1391 | supports_reasoning: Some(true), |
| 1392 | input_usd_per_million: None, |
| 1393 | output_usd_per_million: None, |
| 1394 | modalities: Vec::new(), |
| 1395 | supported_parameters: Vec::new(), |
| 1396 | provider_model_id: None, |
| 1397 | provenance: crate::model_catalog::MetadataProvenance::UserOverride, |
| 1398 | }, |
| 1399 | ); |
| 1400 | let catalog = crate::model_catalog::MergedCatalog::from_sources( |
| 1401 | overrides, |
| 1402 | None, |
| 1403 | crate::model_catalog::bundled_catalog(), |
| 1404 | chrono::Utc::now(), |
| 1405 | ); |
| 1406 | let _guard = crate::model_catalog::replace_active_catalog_for_test(catalog); |
| 1407 | |
| 1408 | assert_eq!( |
| 1409 | context_window_for_model("catalog-only-model"), |
| 1410 | Some(777_000) |
| 1411 | ); |
| 1412 | assert_eq!( |
| 1413 | max_output_tokens_for_model("catalog-only-model"), |
| 1414 | Some(55_000) |
| 1415 | ); |
| 1416 | assert!(model_supports_reasoning("catalog-only-model")); |
| 1417 | } |
| 1418 | |
| 1419 | #[test] |
| 1420 | fn moonshot_native_kimi_ids_support_reasoning_including_coding_route() { |
| 1421 | // #3016: bare Moonshot ids (no moonshotai/ prefix) emit |
| 1422 | // reasoning_content; kimi-for-coding currently rides the K2.7 Code path. |
| 1423 | assert!(model_supports_reasoning("kimi-k2.7-code")); |
| 1424 | assert!(model_supports_reasoning("kimi-k2.6")); |
| 1425 | assert!(model_supports_reasoning("kimi-for-coding")); |
| 1426 | assert!(model_supports_reasoning("kimi-for-coding-highspeed")); |
| 1427 | assert!(model_supports_reasoning("kimi-k2.5")); |
| 1428 | } |
| 1429 | |
| 1430 | #[test] |
| 1431 | fn xai_grok_models_have_static_context_metadata() { |
| 1432 | for (model, expected_window, supports_reasoning) in [ |
| 1433 | ("grok-4.6", 500_000, true), |
| 1434 | ("grok-4.5", 500_000, true), |
| 1435 | ("grok-4.3", 1_000_000, true), |
| 1436 | ("grok-build", 512_000, true), |
| 1437 | ("grok-composer-2.5-fast", 200_000, false), |
| 1438 | ("grok-4.20-0309-reasoning", 2_000_000, true), |
| 1439 | ("grok-4.20-0309-non-reasoning", 2_000_000, false), |
| 1440 | ] { |
| 1441 | assert_eq!(context_window_for_model(model), Some(expected_window)); |
| 1442 | assert_eq!(max_output_tokens_for_model(model), None); |
| 1443 | assert_eq!(model_supports_reasoning(model), supports_reasoning); |
| 1444 | } |
| 1445 | } |
| 1446 | |
| 1447 | #[test] |
| 1448 | fn arcee_direct_models_preserve_verified_capabilities_only() { |
| 1449 | assert_eq!( |
| 1450 | context_window_for_model("trinity-large-preview"), |
| 1451 | Some(262_144) |
| 1452 | ); |
| 1453 | assert!(!model_supports_reasoning("trinity-large-preview")); |
| 1454 | assert_eq!(context_window_for_model("trinity-mini"), Some(128_000)); |
| 1455 | assert_eq!(max_output_tokens_for_model("trinity-mini"), None); |
| 1456 | assert!(model_supports_reasoning("trinity-mini")); |
| 1457 | } |
| 1458 | |
| 1459 | #[test] |
| 1460 | fn qwen37_plus_and_inkling_reasoning_do_not_invent_limits() { |
| 1461 | for model in ["qwen/qwen3.7-plus", "thinkingmachines/inkling"] { |
| 1462 | assert_eq!(context_window_for_model(model), None, "{model}"); |
| 1463 | assert_eq!(max_output_tokens_for_model(model), None, "{model}"); |
| 1464 | assert!(model_supports_reasoning(model), "{model}"); |
| 1465 | } |
| 1466 | } |
| 1467 | |
| 1468 | #[test] |
| 1469 | fn recent_openrouter_large_models_have_known_output_caps() { |
| 1470 | assert_eq!( |
| 1471 | max_output_tokens_for_model("arcee-ai/trinity-large-thinking"), |
| 1472 | Some(262_144) |
| 1473 | ); |
| 1474 | assert_eq!( |
| 1475 | max_output_tokens_for_model("trinity-large-thinking"), |
| 1476 | Some(262_144) |
| 1477 | ); |
| 1478 | assert_eq!( |
| 1479 | max_output_tokens_for_model(concat!("qwen/", "qwen3.8-flash")), |
| 1480 | Some(131_072) |
| 1481 | ); |
| 1482 | assert_eq!( |
| 1483 | max_output_tokens_for_model(concat!("qwen/", "qwen3.6-flash")), |
| 1484 | Some(65_536) |
| 1485 | ); |
| 1486 | assert_eq!( |
| 1487 | max_output_tokens_for_model(concat!("qwen/", "qwen3.6-max-preview")), |
| 1488 | Some(65_536) |
| 1489 | ); |
| 1490 | assert_eq!( |
| 1491 | max_output_tokens_for_model(concat!("qwen/", "qwen3.6-plus")), |
| 1492 | Some(65_536) |
| 1493 | ); |
| 1494 | assert_eq!( |
| 1495 | max_output_tokens_for_model(concat!("xiaomi/", "mimo-v2.5-pro")), |
| 1496 | Some(131_072) |
| 1497 | ); |
| 1498 | assert_eq!(max_output_tokens_for_model("mimo-v2.5-pro"), Some(131_072)); |
| 1499 | assert_eq!( |
| 1500 | max_output_tokens_for_model("mimo-v2.5-pro-ultraspeed"), |
| 1501 | Some(131_072) |
| 1502 | ); |
| 1503 | assert_eq!(max_output_tokens_for_model("mimo-v2.5"), Some(131_072)); |
| 1504 | assert_eq!( |
| 1505 | max_output_tokens_for_model("minimax/minimax-m3"), |
| 1506 | Some(524_288) |
| 1507 | ); |
| 1508 | assert_eq!(max_output_tokens_for_model("z-ai/glm-5.1"), Some(131_072)); |
| 1509 | assert_eq!(max_output_tokens_for_model("z-ai/glm-5.2"), Some(131_072)); |
| 1510 | assert_eq!(max_output_tokens_for_model("z-ai/glm-5.3"), Some(131_072)); |
| 1511 | assert_eq!( |
| 1512 | max_output_tokens_for_model("z-ai/glm-5-turbo"), |
| 1513 | Some(131_072) |
| 1514 | ); |
| 1515 | assert_eq!(max_output_tokens_for_model("glm-5-turbo"), Some(131_072)); |
| 1516 | } |
| 1517 | |
| 1518 | #[test] |
| 1519 | fn k3_route_ids_use_verified_contracts_not_legacy_128k() { |
| 1520 | // Open-platform K3 carries the verified 1M contract. |
| 1521 | assert_eq!(context_window_for_model("kimi-k3"), Some(1_048_576)); |
| 1522 | assert_eq!( |
| 1523 | context_window_for_model("opencode-go/kimi-k3"), |
| 1524 | Some(1_048_576) |
| 1525 | ); |
| 1526 | // Bare `k3` (Kimi Code membership) is plan-tier dependent, so it |
| 1527 | // keeps the documented safe floor — and must never fall through to |
| 1528 | // the 128K legacy default. |
| 1529 | assert_eq!(context_window_for_model("k3"), Some(262_144)); |
| 1530 | assert_eq!(context_window_for_model("k3-256k"), Some(262_144)); |
| 1531 | assert_eq!(max_output_tokens_for_model("k3"), Some(131_072)); |
| 1532 | assert_eq!(max_output_tokens_for_model("k3-256k"), Some(131_072)); |
| 1533 | assert_eq!(max_output_tokens_for_model("kimi-k3"), Some(131_072)); |
| 1534 | // Never project max output as the context window. |
| 1535 | assert_ne!( |
| 1536 | context_window_for_model("k3"), |
| 1537 | max_output_tokens_for_model("k3") |
| 1538 | ); |
| 1539 | assert_ne!( |
| 1540 | context_window_for_model("kimi-k3"), |
| 1541 | max_output_tokens_for_model("kimi-k3") |
| 1542 | ); |
| 1543 | } |
| 1544 | |
| 1545 | #[test] |
| 1546 | fn kimi_code_membership_ids_mirror_their_family_facts() { |
| 1547 | // The high-speed membership id rides the kimi-for-coding family |
| 1548 | // context fact (256K) and reasoning support via the same `kimi-` |
| 1549 | // native-id rule as `kimi-for-coding`. No client-side output ceiling |
| 1550 | // is claimed for the membership ids — the membership catalog is the |
| 1551 | // source of truth, so the generic lookup returns None. |
| 1552 | assert_eq!( |
| 1553 | context_window_for_model("kimi-for-coding-highspeed"), |
| 1554 | Some(262_144) |
| 1555 | ); |
| 1556 | assert_eq!( |
| 1557 | max_output_tokens_for_model("kimi-for-coding-highspeed"), |
| 1558 | None |
| 1559 | ); |
| 1560 | assert_eq!(max_output_tokens_for_model("kimi-for-coding"), None); |
| 1561 | assert!(model_supports_reasoning("kimi-for-coding-highspeed")); |
| 1562 | } |
| 1563 | |
| 1564 | #[test] |
| 1565 | fn bare_provider_model_ids_mirror_vendor_prefixed_rows() { |
| 1566 | // Direct-provider routes (Moonshot, MiniMax, Z.ai) serve bare model |
| 1567 | // ids without the OpenRouter vendor prefix; both spellings must |
| 1568 | // resolve identical metadata (#1310 ride-along on #3023). |
| 1569 | for (model, expected_window) in [ |
| 1570 | ("kimi-k3", 1_048_576), |
| 1571 | ("kimi-k2.7-code", 262_144), |
| 1572 | ("kimi-k2.6", 262_144), |
| 1573 | ("minimax-m3", 1_000_000), |
| 1574 | ("minimax-m2.7", 204_800), |
| 1575 | ("minimax-m2.5-highspeed", 204_800), |
| 1576 | ("minimax-m2", 204_800), |
| 1577 | ("glm-5.1", 202_752), |
| 1578 | ("glm-5.2", 1_000_000), |
| 1579 | // Inherited from glm-5.2 pending official Z.ai release metadata. |
| 1580 | ("glm-5.3", 1_000_000), |
| 1581 | ("glm-5.3-flash", 1_000_000), |
| 1582 | ("glm-5-turbo", 202_752), |
| 1583 | ] { |
| 1584 | assert_eq!(context_window_for_model(model), Some(expected_window)); |
| 1585 | assert!(model_supports_reasoning(model)); |
| 1586 | } |
| 1587 | assert_eq!(context_window_for_model("kimi-for-coding"), Some(262_144)); |
| 1588 | assert!(model_supports_reasoning("kimi-for-coding")); |
| 1589 | assert_eq!(context_window_for_model("glm-5v-turbo"), Some(202_752)); |
| 1590 | assert!(!model_supports_reasoning("glm-5v-turbo")); |
| 1591 | // GLM-5-Turbo is a fast text sibling (distinct from the glm-5v-turbo |
| 1592 | // vision model): same compact window as 5.1 but reasoning-capable. |
| 1593 | assert_eq!(context_window_for_model("z-ai/glm-5-turbo"), Some(202_752)); |
| 1594 | assert!(model_supports_reasoning("z-ai/glm-5-turbo")); |
| 1595 | assert_eq!( |
| 1596 | crate::model_catalog::resolved_max_output("kimi-k2.7-code"), |
| 1597 | Some(32_768) |
| 1598 | ); |
| 1599 | assert_eq!(max_output_tokens_for_model("kimi-k2.7-code"), Some(32_768)); |
| 1600 | assert_eq!(max_output_tokens_for_model("kimi-k2.6"), Some(32_768)); |
| 1601 | assert_eq!(max_output_tokens_for_model("kimi-for-coding"), None); |
| 1602 | assert_eq!(max_output_tokens_for_model("kimi-k3"), Some(131_072)); |
| 1603 | assert_eq!(max_output_tokens_for_model("minimax-m3"), Some(524_288)); |
| 1604 | assert_eq!(max_output_tokens_for_model("glm-5.1"), Some(131_072)); |
| 1605 | assert_eq!(max_output_tokens_for_model("glm-5.2"), Some(131_072)); |
| 1606 | assert_eq!(max_output_tokens_for_model("glm-5.3"), Some(131_072)); |
| 1607 | assert_eq!(max_output_tokens_for_model("glm-5.3-flash"), Some(131_072)); |
| 1608 | } |
| 1609 | |
| 1610 | #[test] |
| 1611 | fn deepseek_models_with_k_suffix_use_hint() { |
| 1612 | assert_eq!(context_window_for_model("deepseek-v3.2-32k"), Some(32_000)); |
| 1613 | assert_eq!( |
| 1614 | context_window_for_model("deepseek-v3.2-256k-preview"), |
| 1615 | Some(256_000) |
| 1616 | ); |
| 1617 | assert_eq!(context_window_for_model("deepseek-v3.2-2k-preview"), None); |
| 1618 | } |
| 1619 | |
| 1620 | #[test] |
| 1621 | fn compaction_threshold_scales_with_context_window() { |
| 1622 | assert_eq!( |
| 1623 | compaction_threshold_for_model_at_percent("deepseek-v3.2-128k", 80.0), |
| 1624 | 102_400 |
| 1625 | ); |
| 1626 | // v0.8.11 (#664): unknown-model fallback also resolves to 80% of |
| 1627 | // `LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS` (128K legacy DeepSeek |
| 1628 | // fallback) — same late-trigger discipline as the V4 path. Was |
| 1629 | // `50_000` pre-v0.8.11; that hardcoded value compacted at ~5% of a |
| 1630 | // 1M window when model detection silently fell through, which is |
| 1631 | // exactly the prefix-cache-burning behaviour we're getting away from. |
| 1632 | assert_eq!( |
| 1633 | compaction_threshold_for_model_at_percent("unknown-model", 80.0), |
| 1634 | 102_400 |
| 1635 | ); |
| 1636 | } |
| 1637 | |
| 1638 | #[test] |
| 1639 | fn compaction_scales_for_deepseek_v4_1m_context() { |
| 1640 | assert_eq!( |
| 1641 | compaction_threshold_for_model_at_percent("deepseek-v4-pro", 80.0), |
| 1642 | 800_000 |
| 1643 | ); |
| 1644 | } |
| 1645 | |
| 1646 | #[test] |
| 1647 | fn compaction_threshold_honors_configured_percent() { |
| 1648 | assert_eq!( |
| 1649 | compaction_threshold_for_model_at_percent("deepseek-v4-pro", 75.0), |
| 1650 | 750_000 |
| 1651 | ); |
| 1652 | assert_eq!( |
| 1653 | compaction_threshold_for_model_at_percent("trinity-large-thinking", 80.0), |
| 1654 | 209_715 |
| 1655 | ); |
| 1656 | } |
| 1657 | |
| 1658 | #[test] |
| 1659 | fn auto_compaction_defaults_on_for_known_supported_model_windows() { |
| 1660 | assert!(auto_compact_default_for_model("trinity-large-thinking")); |
| 1661 | assert!(auto_compact_default_for_model("deepseek-v3.2-128k")); |
| 1662 | assert!(auto_compact_default_for_model("deepseek-v4-pro")); |
| 1663 | assert!(auto_compact_default_for_model("mimo-v2.5-pro")); |
| 1664 | assert!(!auto_compact_default_for_model("unknown-model")); |
| 1665 | } |
| 1666 | } |
| 1667 |