返回 CodeWhale
models.rs
根目录 / crates / tui / src / models.rs
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
1278 lines RUST