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