返回 CodeWhale
chat.rs
根目录 / crates / tui / src / client / chat.rs
1 //! Chat Completions API helpers for DeepSeek's OpenAI-compatible endpoint.
2 //!
3 //! This is the production code path. Streaming (`create_message_stream`),
4 //! request building (`build_chat_messages*`), and SSE parsing
5 //! (`parse_sse_chunk_with_reasoning_style`) all live here.
6
7 use std::collections::HashMap;
8 use std::io::Write;
9 use std::pin::Pin;
10 use std::time::Duration;
11
12 use anyhow::{Context, Result, bail};
13 use serde::{Deserialize, Serialize};
14 use serde_json::{Value, json};
15 use tokio::time::timeout as tokio_timeout;
16
17 use crate::config::{
18 TOGETHER_INKLING_MODEL, is_exact_direct_moonshot_k3_route, is_exact_kimi_code_k3_route,
19 is_exact_xai_grok_4_6_route, is_exact_zai_chat_route, is_exact_zai_forced_thinking_route,
20 is_exact_zai_tiered_effort_route, is_kimi_code_membership_model,
21 minimax_m3_route_uses_max_completion_tokens, moonshot_base_url_is_exact_kimi_code,
22 wire_model_for_provider_route,
23 };
24
25 // The bounded response-header wait (`stream_open_timeout`) and its env
26 // override live in the shared stream-entry seam; every streaming adapter
27 // (Chat Completions / Anthropic Messages / Responses) uses the same policy.
28 use super::stream_entry::stream_open_timeout;
29
30 fn stream_idle_timeout_message(
31 idle: Duration,
32 bytes_received: usize,
33 stream_age: Duration,
34 since_last_chunk: Duration,
35 ) -> String {
36 // Shared seam: Chat Completions / Anthropic / Responses keep one message shape.
37 super::stream_entry::idle_timeout_message(idle, bytes_received, stream_age, since_last_chunk)
38 }
39
40 use crate::config::ApiProvider;
41 use crate::llm_client::StreamEventBox;
42 use crate::llm_client::sanitize_http_error_body;
43 use crate::logging;
44 use codewhale_models::{
45 ContentBlock, ContentBlockStart, Delta, Message, MessageDelta, MessageRequest, MessageResponse,
46 StreamEvent, SystemPrompt, Tool, ToolCaller, Usage, is_openai_gpt_56_api_model,
47 model_is_openai_reasoning_family, model_supports_reasoning,
48 };
49
50 use super::prepared::WireDialect;
51 use super::role_placement::{RolePlacement, role_placement};
52 use super::wire::{extract_sse_data_value, flush_sse_line, take_sse_line};
53 use super::{
54 CodewhaleClient, ERROR_BODY_MAX_BYTES, SSE_BACKPRESSURE_HIGH_WATERMARK,
55 SSE_BACKPRESSURE_SLEEP_MS, SSE_MAX_LINES_PER_CHUNK, acquire_stream_buffer,
56 apply_reasoning_effort, bounded_error_text, from_api_tool_name, parse_usage,
57 release_stream_buffer, system_to_instructions, to_api_tool_name,
58 };
59 use codewhale_models::Role;
60
61 fn apply_provider_token_limit(
62 body: &mut Value,
63 provider: ApiProvider,
64 base_url: &str,
65 model: &str,
66 max_tokens: u32,
67 ) {
68 let use_max_completion_tokens = provider == ApiProvider::XiaomiMimo
69 || (provider == ApiProvider::Openai && model_is_openai_reasoning_family(model))
70 || minimax_m3_route_uses_max_completion_tokens(provider, base_url, model)
71 || is_exact_direct_moonshot_k3_route(provider, base_url, model);
72 if !use_max_completion_tokens {
73 return;
74 }
75
76 if let Some(object) = body.as_object_mut() {
77 object.remove("max_tokens");
78 }
79 body["max_completion_tokens"] = json!(max_tokens);
80 }
81
82 fn apply_openai_reasoning_effort(
83 body: &mut Value,
84 provider: ApiProvider,
85 model: &str,
86 effort: Option<&str>,
87 ) {
88 let model_lower = model.trim().to_ascii_lowercase();
89 let is_gpt_56 =
90 provider == ApiProvider::Openai && is_openai_gpt_56_api_model(model_lower.as_str());
91 let is_openai_reasoning =
92 provider == ApiProvider::Openai && model_is_openai_reasoning_family(model);
93 let is_muse_spark = provider == ApiProvider::Meta
94 && (model_lower == "muse-spark" || model_lower.starts_with("muse-spark-"));
95 if !is_openai_reasoning && !is_muse_spark {
96 return;
97 }
98 let Some(effort) =
99 effort.and_then(|value| openai_compatible_reasoning_effort(value, is_gpt_56, !is_gpt_56))
100 else {
101 return;
102 };
103 body["reasoning_effort"] = json!(effort);
104 }
105
106 fn apply_xai_grok_4_6_reasoning_effort(
107 body: &mut Value,
108 provider: ApiProvider,
109 base_url: &str,
110 model: &str,
111 effort: Option<&str>,
112 ) {
113 if !(is_exact_xai_grok_4_6_route(provider, base_url, model)
114 || (provider == ApiProvider::Xai
115 && codewhale_config::provider::is_exact_xai_platform_route(
116 codewhale_config::ProviderKind::Xai,
117 base_url,
118 )
119 && model
120 .trim()
121 .eq_ignore_ascii_case(crate::config::XAI_GROK_4_5_MODEL)))
122 {
123 return;
124 }
125 let Some(effort) = effort else {
126 return;
127 };
128 let model = model.trim().to_ascii_lowercase();
129 let supports_xhigh = model == crate::config::XAI_GROK_4_6_MODEL;
130 let supports_effort = supports_xhigh || model == crate::config::XAI_GROK_4_5_MODEL;
131 if !supports_effort {
132 return;
133 }
134 let wire_effort = match effort.trim().to_ascii_lowercase().as_str() {
135 "auto" | "automatic" | "" => return,
136 "off" | "disabled" | "none" | "false" | "high" => "high",
137 "minimal" | "minimum" | "low" | "light" => "low",
138 "medium" | "mid" => "medium",
139 "xhigh" | "max" | "maximum" | "highest" | "ultra" | "ultracode" => {
140 if supports_xhigh {
141 "xhigh"
142 } else {
143 "high"
144 }
145 }
146 _ => return,
147 };
148 body["reasoning_effort"] = json!(wire_effort);
149 }
150
151 fn apply_inkling_reasoning_effort(
152 body: &mut Value,
153 provider: ApiProvider,
154 model: &str,
155 effort: Option<&str>,
156 ) {
157 if provider != ApiProvider::Together
158 || !model.trim().eq_ignore_ascii_case(TOGETHER_INKLING_MODEL)
159 {
160 return;
161 }
162
163 // Inkling's official chat template accepts OpenAI's top-level
164 // `reasoning_effort` field with this exact vocabulary. It does not use
165 // Together's generic `thinking` extension or the `xhigh` wire value.
166 if let Some(object) = body.as_object_mut() {
167 object.remove("thinking");
168 }
169 let Some(effort) = effort else {
170 return;
171 };
172 let wire_effort = match effort.trim().to_ascii_lowercase().as_str() {
173 "off" | "disabled" | "none" | "false" => "none",
174 "minimal" => "minimal",
175 "low" => "low",
176 "medium" | "mid" | "" => "medium",
177 "high" => "high",
178 "max" | "xhigh" | "highest" | "ultra" | "ultracode" => "max",
179 _ => return,
180 };
181 body["reasoning_effort"] = json!(wire_effort);
182 }
183
184 /// Apply Kimi Code K3's route-specific nested thinking effort after the
185 /// generic Moonshot shaping. Other Moonshot and Kimi-compatible routes accept
186 /// only the generic enabled/disabled form, so the exact endpoint and bare
187 /// model identifier are both part of this guard.
188 fn apply_kimi_code_k3_reasoning_effort(
189 body: &mut Value,
190 provider: ApiProvider,
191 base_url: &str,
192 model: &str,
193 effort: Option<&str>,
194 ) {
195 if !is_exact_kimi_code_k3_route(provider, base_url, model) {
196 return;
197 }
198 let Some(effort) = effort else {
199 return;
200 };
201
202 let thinking = match effort.trim().to_ascii_lowercase().as_str() {
203 "off" | "none" | "disabled" | "false" | "low" | "minimum" | "minimal" | "light" => {
204 json!({ "type": "enabled", "effort": "low" })
205 }
206 "medium" | "high" => json!({ "type": "enabled", "effort": "high" }),
207 "xhigh" | "ultra" | "max" => json!({ "type": "enabled", "effort": "max" }),
208 _ => return,
209 };
210
211 // K3 uses the nested `thinking.effort` dialect. Do not leave an
212 // OpenAI-style effort value behind if another shaping layer was added
213 // before this route-specific override.
214 if let Some(object) = body.as_object_mut() {
215 object.remove("reasoning_effort");
216 }
217 body["thinking"] = thinking;
218 }
219
220 /// Apply Moonshot's direct K3 reasoning dialect.
221 ///
222 /// The pay-as-you-go K3 endpoint is always-thinking and accepts only the
223 /// top-level `reasoning_effort` values low/high/max. In particular, a generic
224 /// Moonshot `thinking: {type: disabled}` payload is not truthful for this
225 /// route. Treat a legacy raw `off` as the lowest supported tier defensively;
226 /// route-aware callers normalize it before it reaches this layer.
227 fn apply_direct_moonshot_k3_reasoning_effort(
228 body: &mut Value,
229 provider: ApiProvider,
230 base_url: &str,
231 model: &str,
232 effort: Option<&str>,
233 ) {
234 if !is_exact_direct_moonshot_k3_route(provider, base_url, model) {
235 return;
236 }
237
238 if let Some(object) = body.as_object_mut() {
239 object.remove("thinking");
240 object.remove("reasoning_effort");
241 }
242 let Some(effort) = effort else {
243 return;
244 };
245 let wire_effort = match effort.trim().to_ascii_lowercase().as_str() {
246 "off" | "none" | "disabled" | "false" | "low" | "minimum" | "minimal" | "light" => "low",
247 "medium" | "mid" | "high" | "" => "high",
248 "xhigh" | "ultra" | "max" | "highest" | "ultracode" => "max",
249 // `auto` and unknown legacy values leave the field omitted so the
250 // direct API owns its documented default (`max`).
251 _ => return,
252 };
253 body["reasoning_effort"] = json!(wire_effort);
254 }
255
256 /// Keep Z.ai controls on exact first-party routes only. The tiered-effort GLM
257 /// models (5.2, and the forced-thinking 5.3 family) receive the documented
258 /// top-level effort, GLM-5.1 and GLM-5-Turbo keep only the generic thinking
259 /// toggle, and compatible gateways receive neither field because their
260 /// request dialect is not known from provider/model selection alone.
261 fn apply_zai_route_reasoning_controls(
262 body: &mut Value,
263 provider: ApiProvider,
264 base_url: &str,
265 model: &str,
266 effort: Option<&str>,
267 ) {
268 if provider != ApiProvider::Zai {
269 return;
270 }
271
272 if let Some(object) = body.as_object_mut() {
273 object.remove("reasoning_effort");
274 if !is_exact_zai_chat_route(provider, base_url) {
275 // A compatible gateway owns its own request dialect. Provider/model
276 // selection alone is not evidence that Z.ai's `thinking` object is
277 // supported there, so fail closed instead of leaking it.
278 object.remove("thinking");
279 return;
280 }
281 }
282 if !crate::config::is_exact_known_zai_reasoning_route(provider, base_url, model) {
283 if let Some(object) = body.as_object_mut() {
284 object.remove("thinking");
285 }
286 return;
287 }
288 if !is_exact_zai_tiered_effort_route(provider, base_url, model) {
289 // Exact first-party GLM-5-Turbo and GLM-5.1 keep only the generic
290 // enabled/disabled thinking control.
291 return;
292 }
293 if is_exact_zai_forced_thinking_route(provider, base_url, model) {
294 apply_zai_forced_thinking_effort(body, effort);
295 return;
296 }
297 match effort
298 .map(|value| value.trim().to_ascii_lowercase())
299 .as_deref()
300 {
301 Some("high") => body["reasoning_effort"] = json!("high"),
302 Some("xhigh") | Some("max") | Some("highest") | Some("ultra") | Some("ultracode") => {
303 body["reasoning_effort"] = json!("max");
304 }
305 // Off, lower tiers, omitted effort, and unknown legacy values retain
306 // only the generic Z.ai thinking control.
307 _ => {}
308 }
309 }
310
311 /// GLM-5.3 and GLM-5.3-Flash are forced-thinking on the exact first-party
312 /// Z.ai route: `thinking.type: "disabled"` is rejected with an error and
313 /// `reasoning_effort` accepts only low/high/max. The generic Z.ai layer emits
314 /// `disabled` for `off`, so a request that was valid for GLM-5.2 fails on
315 /// 5.3. Rewrite that payload the way the vendor migration note prescribes —
316 /// keep thinking enabled and send the lowest tier — and map the remaining
317 /// aliases onto the three documented values, leaving unknown legacy values
318 /// omitted so the API owns its documented default (`max`).
319 fn apply_zai_forced_thinking_effort(body: &mut Value, effort: Option<&str>) {
320 let thinking_disabled = body
321 .get("thinking")
322 .and_then(|thinking| thinking.get("type"))
323 .and_then(Value::as_str)
324 == Some("disabled");
325 if thinking_disabled {
326 body["thinking"] = json!({
327 "type": "enabled",
328 "clear_thinking": false,
329 });
330 }
331 let Some(effort) = effort else {
332 return;
333 };
334 let wire_effort = match effort.trim().to_ascii_lowercase().as_str() {
335 "off" | "none" | "disabled" | "false" | "low" | "minimum" | "minimal" | "light" => "low",
336 "medium" | "mid" | "high" => "high",
337 "xhigh" | "max" | "highest" | "ultra" | "ultracode" => "max",
338 _ => return,
339 };
340 body["reasoning_effort"] = json!(wire_effort);
341 }
342
343 /// Add MiniMax's Chat-only reasoning controls only when endpoint and model
344 /// prove the exact first-party M3 route. A provider label alone is not enough
345 /// to send MiniMax-specific fields to a compatible gateway or unknown model.
346 fn apply_minimax_route_reasoning_controls(
347 body: &mut Value,
348 provider: ApiProvider,
349 base_url: &str,
350 model: &str,
351 effort: Option<&str>,
352 ) {
353 if provider != ApiProvider::Minimax {
354 return;
355 }
356 if let Some(object) = body.as_object_mut() {
357 object.remove("reasoning_split");
358 object.remove("thinking");
359 }
360 if !crate::config::is_exact_minimax_m3_route(provider, base_url, model) {
361 return;
362 }
363
364 body["reasoning_split"] = json!(true);
365 match effort
366 .map(|value| value.trim().to_ascii_lowercase())
367 .as_deref()
368 {
369 Some("off" | "disabled" | "none" | "false") => {
370 body["thinking"] = json!({ "type": "disabled" });
371 }
372 Some(
373 "low" | "minimal" | "medium" | "mid" | "high" | "xhigh" | "max" | "highest" | "ultra"
374 | "ultracode" | "",
375 ) => {
376 body["thinking"] = json!({ "type": "adaptive" });
377 }
378 _ => {}
379 }
380 }
381
382 /// Model Studio's OpenAI-compatible API uses its own top-level reasoning
383 /// controls. Keep them on verified Alibaba Chat Completions routes: a custom
384 /// `base_url` points the same provider identity at an arbitrary gateway, and
385 /// that gateway must not be handed Alibaba's dialect.
386 ///
387 /// This is the *sole* writer of Model Studio reasoning fields —
388 /// `apply_reasoning_effort` deliberately writes nothing for the `Modelstudio*`
389 /// identities — so the strip below runs for all four variants, including the
390 /// two Anthropic-dialect ones. Those normally reach the Messages adapter
391 /// instead, but `wire = "openai"` can route them here, and an unmatched
392 /// `enable_thinking` left in the body would then go out unguarded.
393 fn apply_modelstudio_route_reasoning_controls(
394 body: &mut Value,
395 provider: ApiProvider,
396 base_url: &str,
397 model: &str,
398 effort: Option<&str>,
399 ) {
400 if !matches!(
401 provider,
402 ApiProvider::ModelstudioTokenPlan
403 | ApiProvider::ModelstudioTokenPlanAnthropic
404 | ApiProvider::ModelstudioCodingPlan
405 | ApiProvider::ModelstudioCodingPlanAnthropic
406 ) {
407 return;
408 }
409
410 if let Some(object) = body.as_object_mut() {
411 object.remove("thinking");
412 object.remove("enable_thinking");
413 object.remove("preserve_thinking");
414 object.remove("reasoning_effort");
415 }
416 if !is_exact_modelstudio_chat_route(provider, base_url) {
417 return;
418 }
419
420 let thinking_only = modelstudio_model_is_thinking_only(model);
421 if !thinking_only && !modelstudio_model_is_hybrid(model) {
422 return;
423 }
424
425 let thinking_enabled = !modelstudio_effort_disables_thinking(effort);
426 // Thinking-only models emit `reasoning_content` but reject an
427 // enable/disable control. Hybrid models use `enable_thinking`.
428 if !thinking_only {
429 body["enable_thinking"] = json!(thinking_enabled);
430 }
431 if modelstudio_model_supports_preserve_thinking(model) {
432 // Model Studio otherwise drops assistant `reasoning_content` from the
433 // next turn's context. This applies even when the provider default
434 // leaves thinking enabled and no explicit UI effort was selected.
435 body["preserve_thinking"] = json!(thinking_only || thinking_enabled);
436 }
437 if !thinking_only
438 && thinking_enabled
439 && let Some(effort) = effort.and_then(modelstudio_reasoning_effort_for_model)
440 && modelstudio_model_supports_reasoning_effort(model)
441 {
442 body["reasoning_effort"] = json!(effort);
443 }
444 }
445
446 /// Fail-closed host guard: only Alibaba's own OpenAI-compatible Chat
447 /// Completions URL shapes count. Anything else (a proxy, a self-hosted
448 /// gateway, a typo) gets the Model Studio fields stripped and nothing added.
449 fn is_exact_modelstudio_chat_route(provider: ApiProvider, base_url: &str) -> bool {
450 let trimmed = base_url.trim().trim_end_matches('/').to_ascii_lowercase();
451 let Some((host, path)) = trimmed
452 .strip_prefix("https://")
453 .and_then(|rest| rest.split_once('/'))
454 else {
455 return false;
456 };
457
458 // Includes Token Plan's default and workspace-scoped
459 // `{workspace}.<region>.maas.aliyuncs.com/compatible-mode/v1` hosts.
460 let token_plan_chat = host.ends_with(".maas.aliyuncs.com") && path == "compatible-mode/v1";
461 let coding_plan_chat = host == "coding-intl.dashscope.aliyuncs.com" && path == "v1";
462 // Alibaba's classic pay-as-you-go DashScope endpoints serve the same
463 // models and the same dialect; leaving them off the allowlist silently
464 // stripped every reasoning control on a genuine Alibaba host
465 // (2026-08-04 review). The intl spelling matches the repo's own
466 // provider defaults.
467 let classic_dashscope_chat = matches!(
468 host,
469 "dashscope.aliyuncs.com" | "dashscope-intl.aliyuncs.com"
470 ) && path == "compatible-mode/v1";
471
472 match provider {
473 // The primary Model Studio provider selects Coding Plan through
474 // `mode = "coding-plan"`, which resolves this base URL without
475 // changing the provider enum. Legacy Coding Plan identities remain
476 // supported as well, so recognize either official Chat route for the
477 // complete Model Studio OpenAI family. The `*Anthropic` identities
478 // speak the Messages dialect and are never verified here.
479 ApiProvider::ModelstudioTokenPlan | ApiProvider::ModelstudioCodingPlan => {
480 token_plan_chat || coding_plan_chat || classic_dashscope_chat
481 }
482 _ => false,
483 }
484 }
485
486 fn is_exact_modelstudio_thinking_only_route(
487 provider: ApiProvider,
488 base_url: &str,
489 model: &str,
490 ) -> bool {
491 is_exact_modelstudio_chat_route(provider, base_url) && modelstudio_model_is_thinking_only(model)
492 }
493
494 fn modelstudio_effort_disables_thinking(effort: Option<&str>) -> bool {
495 effort.is_some_and(|value| {
496 matches!(
497 value.trim().to_ascii_lowercase().as_str(),
498 "off" | "disabled" | "none" | "false"
499 )
500 })
501 }
502
503 /// Models with no enable/disable control at all. `models_dev.bundled.json`
504 /// lists `qwen3.8-max` as `thinking: always_on` and gives `qwen3.8-max-preview`
505 /// effort/budget options with no `toggle`, so sending `enable_thinking` to
506 /// either is at best ignored and at worst a 400.
507 fn modelstudio_model_is_thinking_only(model: &str) -> bool {
508 let model = model.trim().to_ascii_lowercase();
509 matches!(
510 model.as_str(),
511 "qwen3.8-max"
512 | "qwen3.8-max-preview"
513 // Kimi K2.7 Code is always-thinking. Keep both Alibaba-hosted and
514 // Moonshot-supplied exact IDs separate from hybrid Kimi variants
515 // so we never send the unsupported enable_thinking switch.
516 | "kimi-k2.7-code"
517 | "kimi/kimi-k2.7-code"
518 | "kimi/kimi-k2.7-code-highspeed"
519 )
520 }
521
522 fn modelstudio_model_is_hybrid(model: &str) -> bool {
523 let model = model.trim().to_ascii_lowercase();
524 model.starts_with("qwen3.7-")
525 || model.starts_with("qwen3.6-")
526 || model.starts_with("qwen3.5-")
527 || model.starts_with("qwen3-")
528 || model.starts_with("deepseek-v4")
529 || model.starts_with("deepseek-v3.2")
530 || model.starts_with("deepseek-v3.1")
531 || model.starts_with("kimi-k2.6")
532 || matches!(model.as_str(), "kimi/kimi-k2.6")
533 || model.starts_with("kimi-k2.5")
534 || model.starts_with("glm-")
535 }
536
537 fn modelstudio_model_supports_preserve_thinking(model: &str) -> bool {
538 let model = model.trim().to_ascii_lowercase();
539 matches!(
540 model.as_str(),
541 "qwen3.7-max"
542 | "qwen3.7-max-us"
543 | "qwen3.7-max-2026-05-17"
544 | "qwen3.7-max-2026-05-20"
545 | "qwen3.7-max-2026-06-08"
546 | "qwen3.7-max-preview"
547 | "qwen3.7-plus"
548 | "qwen3.7-plus-us"
549 | "qwen3.7-plus-2026-05-26"
550 | "qwen3.6-max-preview"
551 | "qwen3.6-plus"
552 | "qwen3.6-plus-2026-04-02"
553 | "qwen3.6-flash"
554 | "qwen3.6-flash-2026-04-16"
555 | "kimi-k2.6"
556 | "kimi-k2.7-code"
557 | "kimi/kimi-k2.6"
558 | "kimi/kimi-k2.7-code"
559 | "kimi/kimi-k2.7-code-highspeed"
560 )
561 }
562
563 fn modelstudio_model_supports_reasoning_effort(model: &str) -> bool {
564 let model = model.trim().to_ascii_lowercase();
565 model.starts_with("deepseek-v4") || matches!(model.as_str(), "glm-5.2" | "glm-5.1" | "glm-5")
566 }
567
568 fn modelstudio_reasoning_effort_for_model(effort: &str) -> Option<&'static str> {
569 match effort.trim().to_ascii_lowercase().as_str() {
570 // Model Studio documents low and medium as aliases for high.
571 "minimal" | "low" | "medium" | "mid" | "high" | "" => Some("high"),
572 "xhigh" | "max" | "highest" | "ultra" | "ultracode" => Some("max"),
573 _ => None,
574 }
575 }
576
577 /// Final reasoning-control pass shared by streaming and non-streaming Chat
578 /// Completions requests. Route-specific shapers run after the generic provider
579 /// layer so they can remove fields that are invalid for their exact endpoint.
580 pub(super) fn apply_route_reasoning_controls(
581 body: &mut Value,
582 provider: ApiProvider,
583 base_url: &str,
584 model: &str,
585 effort: Option<&str>,
586 ) {
587 apply_reasoning_effort(body, effort, provider);
588 apply_modelstudio_route_reasoning_controls(body, provider, base_url, model, effort);
589 apply_minimax_route_reasoning_controls(body, provider, base_url, model, effort);
590 apply_inkling_reasoning_effort(body, provider, model, effort);
591 apply_openai_reasoning_effort(body, provider, model, effort);
592 apply_xai_grok_4_6_reasoning_effort(body, provider, base_url, model, effort);
593 apply_direct_moonshot_k3_reasoning_effort(body, provider, base_url, model, effort);
594 apply_kimi_code_k3_reasoning_effort(body, provider, base_url, model, effort);
595 apply_zai_route_reasoning_controls(body, provider, base_url, model, effort);
596 apply_mistral_route_reasoning_controls(body, provider, base_url, model, effort);
597 apply_google_reasoning_effort(body, base_url, model, effort);
598 }
599
600 /// Mistral's polymorphic reasoning-content contract is only proven on its
601 /// first-party Chat Completions endpoints. A configured `mistral` provider may
602 /// point at an arbitrary OpenAI-compatible gateway, so provider identity alone
603 /// is not enough to opt that route into Mistral's request or response dialect.
604 fn is_exact_mistral_chat_route(provider: ApiProvider, base_url: &str) -> bool {
605 if provider != ApiProvider::Mistral {
606 return false;
607 }
608 let trimmed = base_url.trim().trim_end_matches('/').to_ascii_lowercase();
609 let Some((host, path)) = trimmed
610 .strip_prefix("https://")
611 .and_then(|rest| rest.split_once('/'))
612 else {
613 return false;
614 };
615 matches!(
616 host,
617 "api.mistral.ai" | "api.eu.mistral.ai" | "api.us.mistral.ai"
618 ) && path == "v1"
619 }
620
621 /// Google's OpenAI-compatibility route, identified by the **resolved base
622 /// URL** rather than by provider identity. Thought signatures are captured
623 /// from tool-call `extra_content.google.thought_signature` and replayed on
624 /// the assistant tool-call messages of later turns; thinking models fail
625 /// closed when a replayed call has no signature.
626 ///
627 /// The endpoint carries the signature contract, not the config row that
628 /// happens to name it: a manually configured `kind="openai-compatible"`
629 /// provider ([`ApiProvider::Custom`]) pointed at this exact host and path is
630 /// byte-for-byte the same endpoint as the built-in `google` row, so it must
631 /// preserve and replay signatures the same way. The converse still holds —
632 /// a `google` row pointed at some other gateway is not this route and never
633 /// carries Google-only fields off-endpoint.
634 fn is_google_openai_compat_chat_route(base_url: &str) -> bool {
635 let trimmed = base_url.trim().trim_end_matches('/').to_ascii_lowercase();
636 let Some((host, path)) = trimmed
637 .strip_prefix("https://")
638 .and_then(|rest| rest.split_once('/'))
639 else {
640 return false;
641 };
642 host == "generativelanguage.googleapis.com" && path == "v1beta/openai"
643 }
644
645 /// Gemini models whose thinking makes thought signatures load-bearing on
646 /// the OpenAI-compat route. Gemini 2.5 Flash-Lite ships with thinking off
647 /// by default, so a missing signature there degrades with a warning
648 /// instead of failing the turn.
649 fn google_model_requires_thought_signatures(model: &str) -> bool {
650 let model = model.trim().to_ascii_lowercase();
651 // Google names the same model both ways on this endpoint, and a route
652 // configured as `models/gemini-3-pro` matched none of the prefixes below:
653 // the model that most needs a signature looked like one that needs none,
654 // so the fail-closed check waved it through and Google rejected the replay
655 // instead (#6018).
656 let model = model.strip_prefix("models/").unwrap_or(&model);
657 if model.starts_with("gemini-3") {
658 return true;
659 }
660 if model.starts_with("gemini-2.5-pro") {
661 return true;
662 }
663 model.starts_with("gemini-2.5-flash") && !model.starts_with("gemini-2.5-flash-lite")
664 }
665
666 /// Google's compatibility endpoint accepts the ordinary `reasoning_effort`
667 /// field across Gemini 2.5 and 3. A top-level `google` object is rejected;
668 /// native thinking controls would require `extra_body.google` instead.
669 /// Use one control, since the endpoint rejects overlapping effort and native
670 /// thinking settings. https://ai.google.dev/gemini-api/docs/openai#thinking
671 fn apply_google_reasoning_effort(
672 body: &mut serde_json::Value,
673 base_url: &str,
674 model: &str,
675 effort: Option<&str>,
676 ) {
677 if !is_google_openai_compat_chat_route(base_url) {
678 return;
679 }
680 let Some(effort) = effort else {
681 return;
682 };
683 let model = model.trim().to_ascii_lowercase();
684 let model = model.strip_prefix("models/").unwrap_or(&model);
685 let can_disable = model.starts_with("gemini-2.5-") && !model.starts_with("gemini-2.5-pro");
686 let effort = match effort.trim().to_ascii_lowercase().as_str() {
687 "off" | "disabled" | "none" | "false" if can_disable => "none",
688 // Gemini 3 and 2.5 Pro cannot disable thinking. The compatibility
689 // layer maps minimal to the selected model's lowest supported level.
690 "off" | "disabled" | "none" | "false" | "minimal" => "minimal",
691 "low" => "low",
692 "medium" | "mid" | "" => "medium",
693 "high" | "xhigh" | "max" | "highest" | "ultra" | "ultracode" => "high",
694 _ => return,
695 };
696 body["reasoning_effort"] = json!(effort);
697 }
698
699 /// Fail closed before transport when Google's OpenAI-compat route would
700 /// replay tool calls without the thought signatures Google's thinking models
701 /// require. The error names the model and the tool call and tells the
702 /// operator how to recover instead of letting Google reject or corrupt the
703 /// tool loop.
704 ///
705 /// Models whose thinking is off by default (Gemini 2.5 Flash-Lite) degrade
706 /// instead of failing — but never silently: the unsigned replay is reported
707 /// through the same warning path the reasoning-replay sanitizer uses, so a
708 /// later tool-turn failure has a receipt. Only tool-call identifiers and the
709 /// model id are logged; signature bytes never are.
710 fn validate_google_thought_signature_replay(
711 base_url: &str,
712 model: &str,
713 messages: &[Value],
714 ) -> Result<()> {
715 if !is_google_openai_compat_chat_route(base_url) {
716 return Ok(());
717 }
718 let requires_signatures = google_model_requires_thought_signatures(model);
719 let mut unsigned_call_ids: Vec<&str> = Vec::new();
720 for message in messages {
721 let Some(tool_calls) = message.get("tool_calls").and_then(Value::as_array) else {
722 continue;
723 };
724 for call in tool_calls {
725 let missing = call
726 .pointer("/extra_content/google/thought_signature")
727 .and_then(Value::as_str)
728 .is_none();
729 if missing {
730 let id = call.get("id").and_then(Value::as_str).unwrap_or("?");
731 if requires_signatures {
732 anyhow::bail!(
733 "Gemini model `{model}` requires a thought signature to replay tool call \
734 `{id}`, but none was captured (the turn predates signature capture, or \
735 the provider omitted it). Start a new session before using tools on \
736 this route."
737 );
738 }
739 unsigned_call_ids.push(id);
740 }
741 }
742 }
743 if !unsigned_call_ids.is_empty() {
744 // Bounded: identifiers only, and only the first few of them.
745 let sample = unsigned_call_ids
746 .iter()
747 .take(3)
748 .copied()
749 .collect::<Vec<_>>()
750 .join(", ");
751 tracing::warn!(
752 model = %model,
753 unsigned_tool_calls = unsigned_call_ids.len(),
754 sample_tool_call_ids = %sample,
755 "replaying tool calls without Google thought signatures on the Gemini \
756 OpenAI-compatible route; later signed tool turns may be rejected"
757 );
758 }
759 Ok(())
760 }
761
762 /// Captured Google signatures ride on tool calls as
763 /// `extra_content.google.thought_signature`. Only Google's OpenAI-compat
764 /// endpoint may carry them on the wire; every other route gets them stripped
765 /// so a route switch never leaks Google-only fields to a foreign gateway.
766 ///
767 /// Returns how many tool calls lost a signature, so the caller can report a
768 /// route switch that silently drops signed history instead of dropping it
769 /// without a receipt. Never returns or logs the signature bytes.
770 fn strip_google_tool_call_extra_content(messages: &mut [Value]) -> usize {
771 let mut stripped = 0usize;
772 for message in messages {
773 let Some(tool_calls) = message.get_mut("tool_calls").and_then(Value::as_array_mut) else {
774 continue;
775 };
776 for call in tool_calls {
777 if let Some(extra) = call.get_mut("extra_content")
778 && let Some(obj) = extra.as_object_mut()
779 {
780 if obj.remove("google").is_some() {
781 stripped += 1;
782 }
783 if obj.is_empty() {
784 call.as_object_mut().map(|c| c.remove("extra_content"));
785 }
786 }
787 }
788 }
789 stripped
790 }
791
792 fn mistral_model_has_adjustable_reasoning(model: &str) -> bool {
793 let model = model.trim().to_ascii_lowercase();
794 model.starts_with("mistral-medium") || model.starts_with("mistral-small")
795 }
796
797 fn mistral_model_has_native_reasoning(model: &str) -> bool {
798 model.trim().to_ascii_lowercase().starts_with("magistral")
799 }
800
801 fn mistral_model_supports_reasoning(model: &str) -> bool {
802 mistral_model_has_adjustable_reasoning(model) || mistral_model_has_native_reasoning(model)
803 }
804
805 fn mistral_reasoning_effort_wire_value(effort: &str) -> Option<&'static str> {
806 match effort.trim().to_ascii_lowercase().as_str() {
807 "off" | "disabled" | "none" | "false" => Some("none"),
808 "high" | "xhigh" | "max" | "highest" | "ultra" | "ultracode" => Some("high"),
809 _ => None,
810 }
811 }
812
813 /// Rewrite assistant messages that carry `reasoning_content` back into the
814 /// polymorphic `content: [{type: thinking, thinking: [{type: text, text: ...}],
815 /// closed: bool}, {type: text, text: ...}]` shape that Mistral la Plateforme
816 /// emits and accepts on replay. Mistral tolerates plain-string history in a
817 /// thinking-capable conversation, but replaying the original thinking trace
818 /// keeps multi-turn reasoning quality high per the official docs
819 /// (docs.mistral.ai/capabilities/reasoning). Non-assistant messages and
820 /// assistant messages without stored thinking are left untouched.
821 fn reshape_mistral_messages_for_reasoning_replay(messages: &mut [Value]) {
822 for message in messages.iter_mut() {
823 let Some(object) = message.as_object_mut() else {
824 continue;
825 };
826 if object.get("role").and_then(Value::as_str) != Some("assistant") {
827 continue;
828 }
829 let Some(reasoning) = object.remove("reasoning_content") else {
830 continue;
831 };
832 let reasoning_text = reasoning
833 .as_str()
834 .map(str::to_string)
835 .filter(|s| !s.trim().is_empty());
836 let Some(reasoning_text) = reasoning_text else {
837 continue;
838 };
839 let text_content = object
840 .get("content")
841 .and_then(Value::as_str)
842 .map(str::to_string);
843 let mut blocks = vec![json!({
844 "type": "thinking",
845 "thinking": [{"type": "text", "text": reasoning_text}],
846 "closed": true,
847 })];
848 if let Some(text) = text_content.filter(|s| !s.trim().is_empty()) {
849 blocks.push(json!({"type": "text", "text": text}));
850 }
851 object.insert("content".to_string(), Value::Array(blocks));
852 }
853 }
854
855 /// Extract thinking and text content from a Mistral polymorphic `content`
856 /// value. Mistral la Plateforme returns `content` as either a plain string
857 /// (default) or an array of typed blocks (`{type: "thinking", thinking:
858 /// [{type: "text", text: "..."}], closed: bool}` and `{type: "text", text:
859 /// "..."}`). This helper flattens the thinking sub-array into a single
860 /// string and returns any inline text separately. It ignores plain-string
861 /// `content` (returns `(None, None)`) so the shared string fallback still
862 /// runs for non-reasoning responses.
863 fn extract_mistral_polymorphic_content(value: &Value) -> (Option<String>, Option<String>) {
864 let Some(array) = value.get("content").and_then(Value::as_array) else {
865 return (None, None);
866 };
867 let mut thinking = String::new();
868 let mut text = String::new();
869 for block in array {
870 let Some(kind) = block.get("type").and_then(Value::as_str) else {
871 continue;
872 };
873 match kind {
874 "thinking" => {
875 if let Some(inner) = block.get("thinking").and_then(Value::as_array) {
876 for sub in inner {
877 if let Some(sub_text) = sub
878 .get("text")
879 .and_then(Value::as_str)
880 .filter(|s| !s.is_empty())
881 {
882 thinking.push_str(sub_text);
883 }
884 }
885 } else if let Some(inline) = block
886 .get("thinking")
887 .and_then(Value::as_str)
888 .filter(|s| !s.is_empty())
889 {
890 thinking.push_str(inline);
891 }
892 }
893 "text" => {
894 if let Some(sub_text) = block
895 .get("text")
896 .and_then(Value::as_str)
897 .filter(|s| !s.is_empty())
898 {
899 text.push_str(sub_text);
900 }
901 }
902 _ => {}
903 }
904 }
905 let thinking = (!thinking.is_empty()).then_some(thinking);
906 let text = (!text.is_empty()).then_some(text);
907 (thinking, text)
908 }
909
910 fn apply_mistral_route_reasoning_controls(
911 body: &mut Value,
912 provider: ApiProvider,
913 base_url: &str,
914 model: &str,
915 effort: Option<&str>,
916 ) {
917 if provider != ApiProvider::Mistral {
918 return;
919 }
920 if let Some(object) = body.as_object_mut() {
921 object.remove("thinking");
922 object.remove("reasoning_effort");
923 }
924 if !is_exact_mistral_chat_route(provider, base_url)
925 || !mistral_model_has_adjustable_reasoning(model)
926 {
927 return;
928 }
929 let Some(effort) = effort else {
930 return;
931 };
932 if let Some(wire) = mistral_reasoning_effort_wire_value(effort) {
933 body["reasoning_effort"] = json!(wire);
934 }
935 }
936
937 /// The direct K3 Chat Completions schema exposes fixed sampling behavior and
938 /// omits `temperature` and `top_p`. Strip legacy/generic values only from the
939 /// exact first-party route so compatible gateways keep their own contract.
940 /// Source: <https://platform.kimi.ai/docs/guide/kimi-k3-quickstart> (verified 2026-07-20).
941 fn apply_direct_moonshot_k3_fixed_sampling(
942 body: &mut Value,
943 provider: ApiProvider,
944 base_url: &str,
945 model: &str,
946 ) {
947 if !is_exact_direct_moonshot_k3_route(provider, base_url, model) {
948 return;
949 }
950 if let Some(object) = body.as_object_mut() {
951 object.remove("temperature");
952 object.remove("top_p");
953 }
954 }
955
956 /// Kimi Code's documented membership models own their sampling behavior.
957 /// Strip generic controls only on the exact first-party membership route;
958 /// custom gateways and unknown model ids retain their own wire contract.
959 /// Source: <https://www.kimi.com/code/docs/en/third-party-tools/codex.html>
960 /// (verified 2026-08-26).
961 fn apply_kimi_code_fixed_sampling(
962 body: &mut Value,
963 provider: ApiProvider,
964 base_url: &str,
965 model: &str,
966 ) {
967 if provider != ApiProvider::Moonshot
968 || !moonshot_base_url_is_exact_kimi_code(base_url)
969 || !is_kimi_code_membership_model(model)
970 {
971 return;
972 }
973 if let Some(object) = body.as_object_mut() {
974 object.remove("temperature");
975 object.remove("top_p");
976 }
977 }
978
979 fn openai_compatible_reasoning_effort(
980 effort: &str,
981 supports_max: bool,
982 supports_minimal: bool,
983 ) -> Option<&'static str> {
984 match effort.trim().to_ascii_lowercase().as_str() {
985 "off" | "disabled" | "none" | "false" => Some("none"),
986 "minimal" if supports_minimal => Some("minimal"),
987 "minimal" => Some("low"),
988 "low" => Some("low"),
989 "medium" | "mid" | "" => Some("medium"),
990 "high" => Some("high"),
991 "xhigh" => Some("xhigh"),
992 "max" | "highest" | "ultra" | "ultracode" if supports_max => Some("max"),
993 "max" | "highest" | "ultra" | "ultracode" => Some("xhigh"),
994 _ => None,
995 }
996 }
997
998 fn mirror_minimax_reasoning_details_for_messages(messages: &mut [Value]) {
999 for message in messages {
1000 if message.get("role").and_then(Value::as_str) != Some("assistant") {
1001 continue;
1002 }
1003 if message.get("reasoning_details").is_some() {
1004 continue;
1005 }
1006 let Some(reasoning) = message
1007 .get("reasoning_content")
1008 .and_then(Value::as_str)
1009 .filter(|reasoning| !reasoning.trim().is_empty())
1010 .map(str::to_string)
1011 else {
1012 continue;
1013 };
1014 message["reasoning_details"] = json!([
1015 {
1016 "type": "text",
1017 "text": reasoning,
1018 }
1019 ]);
1020 }
1021 }
1022
1023 fn mirror_minimax_reasoning_details_for_body(body: &mut Value, provider: ApiProvider) {
1024 if provider != ApiProvider::Minimax {
1025 return;
1026 }
1027 let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else {
1028 return;
1029 };
1030 mirror_minimax_reasoning_details_for_messages(messages);
1031 }
1032
1033 /// Sanitize every Moonshot chat tool in place, dropping only the tools whose
1034 /// parameters cannot pass MFJS compatibility validation.
1035 ///
1036 /// Per-tool degradation: a single incompatible tool (e.g. a third-party MCP
1037 /// server whose schema uses keywords outside the MFJS whitelist) is excluded
1038 /// from this request with a warning instead of failing the whole request
1039 /// before transport. The tool name is safe to log — it is already visible in
1040 /// the UI — while the error's `Display` deliberately carries no schema values.
1041 ///
1042 /// Returns the names of the dropped tools, in catalog order.
1043 fn sanitize_moonshot_chat_tools(chat_tools: &mut Vec<Value>) -> Vec<String> {
1044 let mut dropped = Vec::new();
1045 chat_tools.retain_mut(|tool| {
1046 let Some(function) = tool
1047 .as_object_mut()
1048 .and_then(|tool| tool.get_mut("function"))
1049 .and_then(Value::as_object_mut)
1050 else {
1051 return true;
1052 };
1053 let Some(parameters) = function.get_mut("parameters") else {
1054 return true;
1055 };
1056 match crate::tools::schema_sanitize::sanitize_for_kimi_parameters(parameters) {
1057 Ok(note) => {
1058 if let Some(note) = note {
1059 let description = function
1060 .get("description")
1061 .and_then(Value::as_str)
1062 .unwrap_or_default();
1063 let description = if description.is_empty() {
1064 note
1065 } else {
1066 format!("{description} {note}")
1067 };
1068 function.insert("description".to_string(), json!(description));
1069 }
1070 true
1071 }
1072 Err(error) => {
1073 let name = function
1074 .get("name")
1075 .and_then(Value::as_str)
1076 .unwrap_or("<unnamed>")
1077 .to_string();
1078 tracing::warn!(
1079 tool = %name,
1080 error = %error,
1081 "dropping Moonshot tool from this request: parameters failed safe compatibility validation"
1082 );
1083 dropped.push(name);
1084 false
1085 }
1086 }
1087 });
1088 dropped
1089 }
1090
1091 /// The final Chat Completions wire payload for one request.
1092 ///
1093 /// Produced by [`build_chat_wire_body`], the single place where a
1094 /// `MessageRequest` becomes Chat-shaped JSON. It is reached only through
1095 /// [`super::CodewhaleClient::prepare_outbound_request`], the shared outbound
1096 /// seam that the blocking transport, the streaming transport, and
1097 /// `/preview-request` all consume — so a preview cannot drift from what would
1098 /// be sent, and no other dialect is projected through this builder.
1099 ///
1100 /// Seam concept harvested from PR #1099 (`build_sanitized_chat_completion_body`)
1101 /// by TaoMu (GTC2080); re-implemented against the current client shape.
1102 pub(crate) struct ChatWireBody {
1103 /// Provider-shaped JSON body, post-sanitizers.
1104 pub(crate) body: Value,
1105 /// The model id actually placed on the wire (may differ from the
1106 /// configured/display model for routed providers).
1107 pub(crate) model: String,
1108 /// Tokens re-sent because thinking-mode replay substituted
1109 /// `reasoning_content`. Only computed on the streaming path, which is the
1110 /// only path that runs the replay sanitizer today.
1111 pub(crate) replay_input_tokens: Option<u32>,
1112 /// Wire-normalized tool names omitted because Moonshot's MFJS validator
1113 /// rejected their parameter schemas. Kept outside the wire body so the
1114 /// caller can surface one bounded diagnostic without leaking schema data.
1115 pub(crate) omitted_tool_names: Vec<String>,
1116 }
1117
1118 /// Build the Chat Completions wire body for `request`.
1119 ///
1120 /// `stream` selects the streaming shape (`stream` + `stream_options`) and, to
1121 /// preserve historical behavior exactly, also gates the thinking-mode replay
1122 /// sanitizer — the blocking path has never run it.
1123 pub(crate) fn build_chat_wire_body(
1124 request: &MessageRequest,
1125 provider: ApiProvider,
1126 base_url: &str,
1127 stream: bool,
1128 ) -> Result<ChatWireBody> {
1129 let messages =
1130 build_chat_messages_for_request_and_provider_and_route(request, provider, base_url);
1131 let model = {
1132 let wire = wire_model_for_provider_route(provider, base_url, &request.model);
1133 codewhale_models::effective_muse_wire_id(&wire).to_string()
1134 };
1135 validate_google_thought_signature_replay(base_url, &model, &messages)?;
1136 let mut body = if stream {
1137 json!({
1138 "model": model.clone(),
1139 "messages": messages,
1140 "max_tokens": request.max_tokens,
1141 "stream": true,
1142 "stream_options": {
1143 "include_usage": true
1144 },
1145 })
1146 } else {
1147 json!({
1148 "model": model.clone(),
1149 "messages": messages,
1150 "max_tokens": request.max_tokens,
1151 })
1152 };
1153 apply_provider_token_limit(&mut body, provider, base_url, &model, request.max_tokens);
1154
1155 if let Some(temperature) = request.temperature {
1156 body["temperature"] = json!(temperature);
1157 }
1158 if let Some(top_p) = request.top_p {
1159 body["top_p"] = json!(top_p);
1160 }
1161 let mut omitted_tool_names = Vec::new();
1162 if let Some(tools) = request.tools.as_ref() {
1163 let mut chat_tools: Vec<_> = tools
1164 .iter()
1165 .map(|tool| tool_to_chat_for_base_url(tool, base_url))
1166 .collect();
1167 // Moonshot function parameters must end at a plain object root.
1168 // Flatten root composition, preserve valid nested anyOf, and drop
1169 // only the tools whose parameters cannot pass MFJS validation so one
1170 // incompatible tool never sinks the whole request.
1171 if matches!(provider, crate::config::ApiProvider::Moonshot) {
1172 omitted_tool_names = sanitize_moonshot_chat_tools(&mut chat_tools);
1173 }
1174 // xAI rejects a parameters root that is not a plain object schema
1175 // (e.g. apply_patch's root `oneOf` required-groups) with a 400.
1176 if matches!(provider, crate::config::ApiProvider::Xai) {
1177 for t in &mut chat_tools {
1178 let Some(function) = t
1179 .as_object_mut()
1180 .and_then(|t| t.get_mut("function"))
1181 .and_then(|f| f.as_object_mut())
1182 else {
1183 continue;
1184 };
1185 let note = function.get_mut("parameters").and_then(|parameters| {
1186 crate::tools::schema_sanitize::sanitize_for_xai_parameters(parameters)
1187 });
1188 if let Some(note) = note
1189 && let Some(description) = function
1190 .get_mut("description")
1191 .and_then(|d| d.as_str().map(str::to_string))
1192 {
1193 function.insert(
1194 "description".to_string(),
1195 json!(format!("{description} {note}")),
1196 );
1197 }
1198 }
1199 }
1200 // When per-tool degradation (or the caller) left no tools, omit the
1201 // key entirely: an empty `tools` array — or a `tool_choice` pointing
1202 // at a dropped tool — is itself a fresh 400 on strict providers.
1203 if !chat_tools.is_empty() {
1204 body["tools"] = json!(chat_tools);
1205 }
1206 }
1207 if should_send_tool_choice_for_chat(provider, request.reasoning_effort.as_deref())
1208 && let Some(choice) = request.tool_choice.as_ref()
1209 && let Some(mapped) = map_tool_choice_for_chat(choice)
1210 {
1211 if matches!(provider, crate::config::ApiProvider::Moonshot)
1212 && let Some(name) = mapped.pointer("/function/name").and_then(Value::as_str)
1213 && omitted_tool_names.iter().any(|omitted| omitted == name)
1214 {
1215 bail!(
1216 "Moonshot cannot force tool '{name}' because its input schema is incompatible with this route"
1217 );
1218 }
1219 if body.get("tools").is_some() {
1220 body["tool_choice"] = mapped;
1221 }
1222 }
1223 apply_route_reasoning_controls(
1224 &mut body,
1225 provider,
1226 base_url,
1227 &model,
1228 request.reasoning_effort.as_deref(),
1229 );
1230 apply_direct_moonshot_k3_fixed_sampling(&mut body, provider, base_url, &model);
1231 apply_kimi_code_fixed_sampling(&mut body, provider, base_url, &model);
1232
1233 // Bulletproof final sanitizer: walk the wire payload and force
1234 // `reasoning_content` onto any assistant message that has tool_calls
1235 // but no reasoning_content. DeepSeek's thinking-mode API rejects
1236 // such messages with a 400. This is the last line of defense after
1237 // engine-side and build-side substitution; if either upstream path
1238 // misses a case (e.g. a session restored from disk, a sub-agent
1239 // adding messages directly, or a cached prefix mismatch), this pass
1240 // still produces a valid request.
1241 let replay_input_tokens = if stream {
1242 sanitize_thinking_mode_messages_for_route(
1243 &mut body,
1244 &model,
1245 request.reasoning_effort.as_deref(),
1246 provider,
1247 base_url,
1248 )
1249 } else {
1250 None
1251 };
1252 mirror_minimax_reasoning_details_for_body(&mut body, provider);
1253
1254 Ok(ChatWireBody {
1255 body,
1256 model,
1257 replay_input_tokens,
1258 omitted_tool_names,
1259 })
1260 }
1261
1262 impl CodewhaleClient {
1263 pub(super) async fn create_message_chat(
1264 &self,
1265 prepared: &super::PreparedOutboundRequest,
1266 cacheable: bool,
1267 ) -> Result<MessageResponse> {
1268 let body = &prepared.body;
1269
1270 let response_cache_key = if cacheable {
1271 let wire_body =
1272 serde_json::to_vec(&body).context("Failed to serialize Chat API cache key")?;
1273 let key = crate::llm_response_cache::ResponseCache::make_key(
1274 self.api_provider.as_str(),
1275 &self.base_url,
1276 self.path_suffix.as_deref(),
1277 &self.api_key,
1278 &wire_body,
1279 );
1280 if let Some(cached) = crate::llm_response_cache::response_cache().get(&key) {
1281 return Ok(cached);
1282 }
1283 Some(key)
1284 } else {
1285 None
1286 };
1287
1288 // The endpoint was resolved by the shared seam alongside the body, so
1289 // a route-shape decision (e.g. DeepSeek's strict-tools `/beta` path)
1290 // cannot be made twice with two different answers.
1291 let url = prepared.endpoint.url.as_str();
1292 let response = self.send_json_with_retry(url, body).await?;
1293
1294 let status = response.status();
1295 crate::client::record_provider_response(self.api_provider, status.as_u16());
1296 if !status.is_success() {
1297 let raw_error_text = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await;
1298 let error_text = sanitize_http_error_body(
1299 Some(self.api_provider.display_name()),
1300 status.as_u16(),
1301 &raw_error_text,
1302 );
1303 anyhow::bail!(
1304 "Failed to call {} Chat Completions API: HTTP {status}: {error_text}",
1305 self.api_provider.display_name()
1306 );
1307 }
1308
1309 let response_text = response
1310 .text()
1311 .await
1312 .context("Failed to read Chat API response body")?;
1313 let value: Value =
1314 serde_json::from_str(&response_text).context("Failed to parse Chat API JSON")?;
1315 let parsed = parse_chat_message_for_route(&value, self.api_provider, &self.base_url)?;
1316 if let Some(key) = response_cache_key {
1317 crate::llm_response_cache::response_cache().put(key, parsed.clone());
1318 }
1319 Ok(parsed)
1320 }
1321 }
1322
1323 impl CodewhaleClient {
1324 async fn open_chat_stream_response(
1325 &self,
1326 url: &str,
1327 body: &Value,
1328 ) -> Result<(reqwest::Response, Duration)> {
1329 let open_req = super::stream_entry::StreamOpenRequest::new(
1330 stream_open_timeout(),
1331 self.stream_idle_timeout,
1332 );
1333 let idle_timeout = open_req.idle_timeout;
1334 let response = super::stream_entry::open_sse_response(&open_req, |policy| async move {
1335 match policy {
1336 // The prebuilt HTTP/1.1 twin carries the same default
1337 // headers/auth; send once, without the JSON retry loop
1338 // (matching the pre-seam H1-pin behavior).
1339 super::stream_entry::StreamHttpPolicy::Http1Only => {
1340 let client = super::stream_entry::client_for_policy(
1341 &self.http_client,
1342 self.http1_fallback_client(),
1343 policy,
1344 );
1345 Ok(client
1346 .post(url)
1347 .header(reqwest::header::CONTENT_TYPE, "application/json")
1348 .json(body)
1349 .send()
1350 .await?)
1351 }
1352 super::stream_entry::StreamHttpPolicy::DualWithH1Fallback => {
1353 self.send_json_with_retry(url, body).await
1354 }
1355 }
1356 })
1357 .await?;
1358 Ok((response, idle_timeout))
1359 }
1360
1361 pub(super) async fn handle_chat_completion_stream(
1362 &self,
1363 prepared: super::PreparedOutboundRequest,
1364 ) -> Result<StreamEventBox> {
1365 // Try true SSE streaming via chat completions (widely supported).
1366 // Body and endpoint both come from the shared prepared-request seam,
1367 // so a preview or a non-stream call can never diverge from the
1368 // streamed request.
1369 let super::PreparedOutboundRequest {
1370 body,
1371 wire_model: model,
1372 replay_input_tokens,
1373 endpoint,
1374 ..
1375 } = prepared;
1376 let url = endpoint.url;
1377
1378 let (response, stream_idle_timeout) = self.open_chat_stream_response(&url, &body).await?;
1379
1380 let status = response.status();
1381 crate::client::record_provider_response(self.api_provider, status.as_u16());
1382 if !status.is_success() {
1383 let raw_error_text = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await;
1384 let error_text = sanitize_http_error_body(
1385 Some(self.api_provider.display_name()),
1386 status.as_u16(),
1387 &raw_error_text,
1388 );
1389 // If DeepSeek rejected for missing reasoning_content despite the
1390 // sanitizer, dump the offending indices so we can diagnose where
1391 // they came from on the next failure.
1392 if error_text.contains("reasoning_content") {
1393 log_thinking_mode_violations(&body);
1394 }
1395 anyhow::bail!("SSE stream request failed: HTTP {status}: {error_text}");
1396 }
1397
1398 let api_provider = self.api_provider;
1399 let base_url = self.base_url.clone();
1400
1401 // Capture transport-shape headers before we consume `response` into
1402 // `bytes_stream()`. They are surfaced in the decode-error log path so
1403 // we can tell HTTP/2 RST_STREAM from chunked-encoding corruption from
1404 // gzip-compressor failure when investigating #103.
1405 let response_headers = format_stream_headers(response.headers());
1406 let byte_stream = response.bytes_stream();
1407 let configured_reasoning_stream_style = self.reasoning_stream_style.clone();
1408
1409 let stream = async_stream::stream! {
1410 use futures_util::StreamExt;
1411
1412 // Emit a synthetic MessageStart
1413 yield Ok(StreamEvent::MessageStart {
1414 message: MessageResponse {
1415 id: String::new(),
1416 r#type: "message".to_string(),
1417 role: "assistant".to_string(),
1418 content: Vec::new(),
1419 model: model.clone(),
1420 stop_reason: None,
1421 stop_sequence: None,
1422 container: None,
1423 usage: Usage {
1424 input_tokens: 0,
1425 output_tokens: 0,
1426 ..Usage::default()
1427 },
1428 },
1429 });
1430
1431 let mut line_buf = String::new();
1432 let mut byte_buf = acquire_stream_buffer();
1433 let mut content_index: u32 = 0;
1434 let mut text_started = false;
1435 let mut thinking_started = false;
1436 let mut tool_indices: std::collections::HashMap<u32, u32> = std::collections::HashMap::new();
1437 let mut reasoning_detail_buffers: std::collections::HashMap<u32, String> = std::collections::HashMap::new();
1438 let mut inline_reasoning_tags = InlineReasoningTagState::default();
1439 let reasoning_stream_style = reasoning_stream_style_for_route(
1440 api_provider,
1441 &base_url,
1442 &model,
1443 configured_reasoning_stream_style.as_deref(),
1444 );
1445
1446 let mut byte_stream = std::pin::pin!(byte_stream);
1447 let idle = stream_idle_timeout;
1448
1449 // Telemetry for #103 stream-decode diagnostics: bytes received
1450 // since the start of this stream and last successful event time.
1451 // Surfaces in the error log when reqwest yields a chunk error so
1452 // we can tell HTTP/2 RST_STREAM from chunk-decode-failure from
1453 // gzip-corruption when investigating a flaky session.
1454 let stream_start = std::time::Instant::now();
1455 let mut last_event_at = std::time::Instant::now();
1456 let mut bytes_received: usize = 0;
1457 // Set when a `[DONE]` sentinel was seen, so the post-loop flush does
1458 // not re-process trailing post-DONE bytes.
1459 let mut saw_done = false;
1460 // A number of OpenAI-compatible providers omit `[DONE]` but send a
1461 // terminal `finish_reason`. Either is valid terminal proof. A raw
1462 // HTTP EOF with neither is not: treating that as MessageStop turns
1463 // a truncated provider response into a successful empty turn.
1464 let mut saw_finish_reason = false;
1465 // Once an error has been emitted, do not follow it with a synthetic
1466 // MessageStop (or a second, less-specific premature-EOF error).
1467 let mut stream_failed = false;
1468 // Set when a complete line or unterminated flush failed UTF-8.
1469 // Skip further data-frame parsing so U+FFFD cannot enter the transcript.
1470 let mut decode_failed = false;
1471
1472 'stream: loop {
1473 let chunk_result = match tokio_timeout(idle, byte_stream.next()).await {
1474 Ok(Some(result)) => result,
1475 Ok(None) => break, // Stream ended normally
1476 Err(_elapsed) => {
1477 stream_failed = true;
1478 yield Err(anyhow::anyhow!(stream_idle_timeout_message(
1479 idle,
1480 bytes_received,
1481 stream_start.elapsed(),
1482 last_event_at.elapsed(),
1483 )));
1484 break;
1485 }
1486 };
1487 let chunk = match chunk_result {
1488 Ok(bytes) => bytes,
1489 Err(e) => {
1490 stream_failed = true;
1491 // Walk the error source chain so reqwest's underlying
1492 // hyper / h2 / io error is visible — without this the
1493 // outer "error decoding response body" message tells
1494 // us nothing about WHY the stream died.
1495 let mut error_chain = format!("{e}");
1496 let mut current: Option<&(dyn std::error::Error + 'static)> =
1497 std::error::Error::source(&e);
1498 while let Some(source) = current {
1499 error_chain.push_str(&format!(" -> {source}"));
1500 current = std::error::Error::source(source);
1501 }
1502 crate::logging::warn(format!(
1503 "Stream read error: {error_chain} \
1504 (elapsed: {}ms, bytes_received: {}, ms_since_last_event: {}, headers: {})",
1505 stream_start.elapsed().as_millis(),
1506 bytes_received,
1507 last_event_at.elapsed().as_millis(),
1508 response_headers,
1509 ));
1510 yield Err(anyhow::anyhow!("Stream read error: {e}"));
1511 break;
1512 }
1513 };
1514
1515 bytes_received = bytes_received.saturating_add(chunk.len());
1516 last_event_at = std::time::Instant::now();
1517 byte_buf.extend_from_slice(&chunk);
1518
1519 // Guard against unbounded buffer growth (e.g., malformed stream without newlines)
1520 const MAX_SSE_BUF: usize = 10 * 1024 * 1024; // 10 MB
1521 if byte_buf.len() > MAX_SSE_BUF {
1522 stream_failed = true;
1523 yield Err(anyhow::anyhow!("SSE buffer exceeded {MAX_SSE_BUF} bytes — aborting stream"));
1524 break;
1525 }
1526
1527 if byte_buf.len() > SSE_BACKPRESSURE_HIGH_WATERMARK {
1528 tokio::time::sleep(Duration::from_millis(SSE_BACKPRESSURE_SLEEP_MS)).await;
1529 }
1530
1531 // Process complete SSE lines from the buffer. Decode only after
1532 // a `\n` so an HTTP/2 DATA split mid-character cannot become
1533 // U+FFFD; genuine invalid bytes fail closed.
1534 let mut lines_processed = 0usize;
1535 loop {
1536 let line = match take_sse_line(&mut byte_buf) {
1537 Ok(Some(line)) => line,
1538 Ok(None) => break,
1539 Err(err) => {
1540 decode_failed = true;
1541 stream_failed = true;
1542 yield Err(anyhow::anyhow!("{err}"));
1543 break 'stream;
1544 }
1545 };
1546
1547 if line.is_empty() {
1548 // Empty line = event boundary, process accumulated data
1549 if !line_buf.is_empty() {
1550 let data = std::mem::take(&mut line_buf);
1551 match parse_sse_data_frame(
1552 &data,
1553 &mut content_index,
1554 &mut text_started,
1555 &mut thinking_started,
1556 &mut tool_indices,
1557 &mut reasoning_detail_buffers,
1558 &mut inline_reasoning_tags,
1559 reasoning_stream_style,
1560 ) {
1561 SseDataFrame::Done => {
1562 saw_done = true;
1563 break 'stream;
1564 }
1565 SseDataFrame::Events(events) => {
1566 for mut event in events {
1567 saw_finish_reason |= matches!(
1568 &event,
1569 StreamEvent::MessageDelta { delta, .. }
1570 if delta.stop_reason.as_deref().is_some_and(|reason| !reason.trim().is_empty())
1571 );
1572 // Stamp the client-side replay-token estimate
1573 // onto the final usage so the UI can surface
1574 // it (#30). We compute it pre-request and
1575 // overlay it on the server-reported usage at
1576 // stream completion.
1577 if let Some(tokens) = replay_input_tokens
1578 && let StreamEvent::MessageDelta {
1579 usage: Some(usage),
1580 ..
1581 } = &mut event
1582 {
1583 usage.reasoning_replay_tokens = Some(tokens);
1584 }
1585 yield Ok(event);
1586 }
1587 }
1588 }
1589 }
1590 continue;
1591 }
1592
1593 if let Some(data) = extract_sse_data_value(&line) {
1594 // The SSE spec joins multiple `data:` fields within one
1595 // event with '\n'; concatenating with no separator would
1596 // yield `{…}{…}` and fail JSON parsing, silently dropping
1597 // the frame.
1598 if !line_buf.is_empty() {
1599 line_buf.push('\n');
1600 }
1601 line_buf.push_str(data);
1602 }
1603 // Ignore other SSE fields (event:, id:, retry:)
1604
1605 lines_processed = lines_processed.saturating_add(1);
1606 if lines_processed >= SSE_MAX_LINES_PER_CHUNK {
1607 // Backpressure relief: hand the executor a turn so a
1608 // slow consumer is not starved. Keep draining after
1609 // that — leaving complete lines buffered would strand
1610 // them, because the outer loop only resumes draining
1611 // once ANOTHER chunk arrives and the end-of-stream
1612 // flush treats the whole remainder as a single
1613 // unterminated line.
1614 lines_processed = 0;
1615 tokio::task::yield_now().await;
1616 }
1617 }
1618 }
1619
1620 // Flush a final SSE frame that arrived without a terminating blank
1621 // line (the stream closed straight after the last `data:` line, or
1622 // that line lacked a trailing newline). Without this the final delta
1623 // — last tokens, finish_reason, and usage — is silently dropped.
1624 // Skipped after `[DONE]`, whose frame was already processed, and
1625 // after a fail-closed UTF-8 error.
1626 if !saw_done && !decode_failed {
1627 match flush_sse_line(&mut byte_buf) {
1628 Ok(Some(line)) => {
1629 if let Some(data) = extract_sse_data_value(&line) {
1630 if !line_buf.is_empty() {
1631 line_buf.push('\n');
1632 }
1633 line_buf.push_str(data);
1634 }
1635 }
1636 Ok(None) => {}
1637 Err(err) => {
1638 decode_failed = true;
1639 stream_failed = true;
1640 yield Err(anyhow::anyhow!("{err}"));
1641 }
1642 }
1643 if !decode_failed && !line_buf.is_empty() {
1644 let data = std::mem::take(&mut line_buf);
1645 match parse_sse_data_frame(
1646 &data,
1647 &mut content_index,
1648 &mut text_started,
1649 &mut thinking_started,
1650 &mut tool_indices,
1651 &mut reasoning_detail_buffers,
1652 &mut inline_reasoning_tags,
1653 reasoning_stream_style,
1654 ) {
1655 SseDataFrame::Done => saw_done = true,
1656 SseDataFrame::Events(events) => {
1657 for mut event in events {
1658 saw_finish_reason |= matches!(
1659 &event,
1660 StreamEvent::MessageDelta { delta, .. }
1661 if delta.stop_reason.as_deref().is_some_and(|reason| !reason.trim().is_empty())
1662 );
1663 if let Some(tokens) = replay_input_tokens
1664 && let StreamEvent::MessageDelta {
1665 usage: Some(usage), ..
1666 } = &mut event
1667 {
1668 usage.reasoning_replay_tokens = Some(tokens);
1669 }
1670 yield Ok(event);
1671 }
1672 }
1673 }
1674 }
1675 }
1676
1677 // Close any open blocks — content_index points to the
1678 // currently active open block (it is only incremented
1679 // *after* a block is closed, not when opened).
1680 if thinking_started || text_started {
1681 yield Ok(StreamEvent::ContentBlockStop { index: content_index });
1682 }
1683
1684 release_stream_buffer(byte_buf);
1685 if !stream_failed && (saw_done || saw_finish_reason) {
1686 yield Ok(StreamEvent::MessageStop);
1687 } else if !stream_failed {
1688 yield Err(anyhow::anyhow!(
1689 "Chat Completions stream closed before [DONE] or finish_reason"
1690 ));
1691 }
1692 };
1693
1694 Ok(Pin::from(Box::new(stream)
1695 as Box<
1696 dyn futures_util::Stream<Item = Result<StreamEvent>> + Send,
1697 >))
1698 }
1699 }
1700
1701 // === Chat Completions Helpers ===
1702
1703 #[cfg(test)]
1704 pub(super) fn build_chat_messages(
1705 system: Option<&SystemPrompt>,
1706 messages: &[Message],
1707 model: &str,
1708 ) -> Vec<Value> {
1709 build_chat_messages_with_reasoning(
1710 system,
1711 messages,
1712 model,
1713 should_replay_reasoning_content(model, None),
1714 false,
1715 )
1716 }
1717
1718 #[cfg(test)]
1719 pub(super) fn build_chat_messages_for_request(request: &MessageRequest) -> Vec<Value> {
1720 PromptBuilder::for_request(request).build()
1721 }
1722
1723 #[cfg(test)]
1724 pub(super) fn build_chat_messages_for_request_and_provider(
1725 request: &MessageRequest,
1726 provider: ApiProvider,
1727 ) -> Vec<Value> {
1728 build_chat_messages_for_request_and_provider_and_route(request, provider, "")
1729 }
1730
1731 /// Build a wire prompt for one fully resolved provider route.
1732 ///
1733 /// Most provider behavior is keyed only by the provider kind and model. Kimi
1734 /// Code K3 is deliberately narrower: the bare `k3` model owns reasoning
1735 /// replay only on its official membership-plan endpoint, so callers that have
1736 /// a concrete base URL must retain it through prompt construction.
1737 pub(super) fn build_chat_messages_for_request_and_provider_and_route(
1738 request: &MessageRequest,
1739 provider: ApiProvider,
1740 base_url: &str,
1741 ) -> Vec<Value> {
1742 PromptBuilder::for_request(request).build_for_provider_and_route(provider, base_url)
1743 }
1744
1745 pub(crate) fn inspect_prompt_for_request(request: &MessageRequest) -> PromptInspection {
1746 PromptBuilder::for_request(request).inspect()
1747 }
1748
1749 pub(crate) fn build_cache_warmup_request(request: &MessageRequest) -> MessageRequest {
1750 PromptBuilder::for_request(request).build_cache_warmup_request()
1751 }
1752
1753 struct PromptBuilder<'a> {
1754 system: Option<&'a SystemPrompt>,
1755 messages: &'a [Message],
1756 tools: Option<&'a [Tool]>,
1757 model: &'a str,
1758 reasoning_effort: Option<&'a str>,
1759 }
1760
1761 impl<'a> PromptBuilder<'a> {
1762 fn for_request(request: &'a MessageRequest) -> Self {
1763 Self {
1764 system: request.system.as_ref(),
1765 messages: &request.messages,
1766 tools: request.tools.as_deref(),
1767 model: &request.model,
1768 reasoning_effort: request.reasoning_effort.as_deref(),
1769 }
1770 }
1771
1772 #[cfg(test)]
1773 fn build(self) -> Vec<Value> {
1774 build_chat_messages_with_reasoning(
1775 self.system,
1776 self.messages,
1777 self.model,
1778 should_replay_reasoning_content(self.model, self.reasoning_effort),
1779 false,
1780 )
1781 }
1782
1783 fn build_for_provider_and_route(self, provider: ApiProvider, base_url: &str) -> Vec<Value> {
1784 let mut messages = build_chat_messages_with_reasoning(
1785 self.system,
1786 self.messages,
1787 self.model,
1788 should_replay_reasoning_content_for_provider_on_route(
1789 provider,
1790 base_url,
1791 self.model,
1792 self.reasoning_effort,
1793 ),
1794 false,
1795 );
1796 dump_system_prompt_if_requested(&messages);
1797 if provider == ApiProvider::Arcee {
1798 apply_arcee_waf_safe_message_encoding(&mut messages);
1799 }
1800 if provider == ApiProvider::Minimax {
1801 mirror_minimax_reasoning_details_for_messages(&mut messages);
1802 }
1803 if is_exact_mistral_chat_route(provider, base_url) {
1804 reshape_mistral_messages_for_reasoning_replay(&mut messages);
1805 }
1806 if !is_google_openai_compat_chat_route(base_url) {
1807 // A signature captured on Google's endpoint is meaningless — and
1808 // potentially a leak — anywhere else, so it is stripped. Say so:
1809 // the model will behave differently on the replayed tool history,
1810 // and a silent strip is exactly what made this defect invisible.
1811 let stripped = strip_google_tool_call_extra_content(&mut messages);
1812 if stripped > 0 {
1813 tracing::warn!(
1814 provider = ?provider,
1815 stripped_tool_calls = stripped,
1816 "dropping captured Google thought signatures: this route is not Google's \
1817 OpenAI-compatible endpoint, so the replayed tool history is unsigned"
1818 );
1819 }
1820 }
1821 messages
1822 }
1823
1824 fn inspect(self) -> PromptInspection {
1825 let messages = build_chat_messages_with_reasoning(
1826 self.system,
1827 self.messages,
1828 self.model,
1829 should_replay_reasoning_content(self.model, self.reasoning_effort),
1830 true,
1831 );
1832 inspect_wire_request(self.tools, &messages)
1833 }
1834
1835 fn build_cache_warmup_request(self) -> MessageRequest {
1836 let system = stable_system_prompt(self.system);
1837 let mut messages = stable_history_messages(self.messages);
1838 let tools = self
1839 .tools
1840 .filter(|tools| !tools.is_empty())
1841 .map(<[Tool]>::to_vec);
1842 let tool_choice = tools.as_ref().map(|_| json!("none"));
1843 messages.push(Message {
1844 role: Role::User,
1845 content: vec![ContentBlock::Text {
1846 text: CACHE_WARMUP_USER_TAIL.to_string(),
1847 cache_control: None,
1848 }],
1849 });
1850
1851 MessageRequest {
1852 model: self.model.to_string(),
1853 messages,
1854 max_tokens: CACHE_WARMUP_MAX_TOKENS,
1855 system,
1856 tools,
1857 tool_choice,
1858 metadata: None,
1859 thinking: None,
1860 // Warmup has an intentionally tiny answer contract ("OK"). Do not
1861 // let hidden reasoning consume that allowance before the cacheable
1862 // prefix is accepted by the provider.
1863 reasoning_effort: Some("off".to_string()),
1864 stream: None,
1865 temperature: None,
1866 top_p: None,
1867 }
1868 }
1869 }
1870
1871 const SYSTEM_PROMPT_DUMP_ENV: &str = "CODEWHALE_DUMP_SYSTEM_PROMPT";
1872 const SYSTEM_PROMPT_DUMP_BEGIN: &str = "<<<CODEWHALE_SYSTEM_PROMPT_BEGIN>>>";
1873 const SYSTEM_PROMPT_DUMP_END: &str = "<<<CODEWHALE_SYSTEM_PROMPT_END>>>";
1874 const ARCEE_WAF_TEXT_SPLIT_TRIGGERS: &[(&str, &str, &str)] = &[("python -c", "python ", "-c")];
1875
1876 fn dump_system_prompt_if_requested(messages: &[Value]) {
1877 let Ok(flag) = std::env::var(SYSTEM_PROMPT_DUMP_ENV) else {
1878 return;
1879 };
1880 if !matches!(flag.trim(), "1" | "true" | "TRUE" | "yes" | "YES") {
1881 return;
1882 }
1883 let Some(prompt) = messages.iter().find_map(system_message_text) else {
1884 return;
1885 };
1886 let mut stderr = std::io::stderr().lock();
1887 let _ = writeln!(stderr, "{SYSTEM_PROMPT_DUMP_BEGIN}");
1888 let _ = writeln!(stderr, "{prompt}");
1889 let _ = writeln!(stderr, "{SYSTEM_PROMPT_DUMP_END}");
1890 }
1891
1892 fn system_message_text(message: &Value) -> Option<String> {
1893 if message.get("role").and_then(Value::as_str) != Some("system") {
1894 return None;
1895 }
1896 match message.get("content")? {
1897 Value::String(text) => Some(text.clone()),
1898 Value::Array(parts) => {
1899 let text = parts
1900 .iter()
1901 .filter_map(|part| part.get("text").and_then(Value::as_str))
1902 .collect::<Vec<_>>()
1903 .join("");
1904 (!text.is_empty()).then_some(text)
1905 }
1906 _ => None,
1907 }
1908 }
1909
1910 fn apply_arcee_waf_safe_message_encoding(messages: &mut [Value]) {
1911 for message in messages {
1912 if message.get("role").and_then(Value::as_str) != Some("system") {
1913 continue;
1914 }
1915 let Some(content) = message.get("content").and_then(Value::as_str) else {
1916 continue;
1917 };
1918 let Some(parts) = arcee_waf_safe_text_parts(content) else {
1919 continue;
1920 };
1921 message["content"] = json!(parts);
1922 }
1923 }
1924
1925 fn arcee_waf_safe_text_parts(content: &str) -> Option<Vec<Value>> {
1926 let mut parts = Vec::new();
1927 let mut cursor = 0usize;
1928 let mut split_any = false;
1929
1930 while cursor < content.len() {
1931 let Some((trigger_start, trigger, left, right)) = next_arcee_waf_trigger(content, cursor)
1932 else {
1933 push_text_part(&mut parts, &content[cursor..]);
1934 break;
1935 };
1936
1937 push_text_part(&mut parts, &content[cursor..trigger_start]);
1938 push_text_part(&mut parts, left);
1939 push_text_part(&mut parts, right);
1940 cursor = trigger_start + trigger.len();
1941 split_any = true;
1942 }
1943
1944 split_any.then_some(parts)
1945 }
1946
1947 fn next_arcee_waf_trigger(content: &str, cursor: usize) -> Option<(usize, &str, &str, &str)> {
1948 ARCEE_WAF_TEXT_SPLIT_TRIGGERS
1949 .iter()
1950 .filter_map(|(trigger, left, right)| {
1951 content[cursor..]
1952 .find(trigger)
1953 .map(|offset| (cursor + offset, *trigger, *left, *right))
1954 })
1955 .min_by_key(|(start, _, _, _)| *start)
1956 }
1957
1958 fn push_text_part(parts: &mut Vec<Value>, text: &str) {
1959 if !text.is_empty() {
1960 parts.push(json!({
1961 "type": "text",
1962 "text": text,
1963 }));
1964 }
1965 }
1966
1967 pub(crate) const CACHE_WARMUP_USER_TAIL: &str = "请只回复 OK";
1968 pub(crate) const CACHE_WARMUP_MAX_TOKENS: u32 = 8;
1969 const TOOL_RESULT_SENT_CHAR_BUDGET: usize = 12_000;
1970
1971 fn tool_result_sent_char_budget() -> usize {
1972 crate::tools::large_output_router::WorkshopConfig::active_tool_result_max_bytes()
1973 .map(|bytes| bytes.clamp(TOOL_RESULT_SENT_CHAR_BUDGET, 2 * 1024 * 1024))
1974 .unwrap_or(TOOL_RESULT_SENT_CHAR_BUDGET)
1975 }
1976 const TOOL_RESULT_HEAD_CHARS: usize = 4_000;
1977 const TOOL_RESULT_TAIL_CHARS: usize = 4_000;
1978 /// Tool results shorter than this stay inline even when repeated. The
1979 /// extra prompt bytes are cheaper than adding an earlier-message reference
1980 /// for tiny command outputs.
1981 const TOOL_RESULT_DEDUP_MIN_CHARS: usize = 1_024;
1982
1983 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1984 pub(crate) struct PromptInspection {
1985 pub base_static_prefix_hash: String,
1986 pub full_request_prefix_hash: String,
1987 /// Hash of the rendered tool catalog JSON, or empty when no tools were supplied.
1988 pub tool_catalog_hash: String,
1989 pub layers: Vec<PromptLayerInspection>,
1990 }
1991
1992 /// Identifies the stable prefix that a cache warmup primes.
1993 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1994 pub(crate) struct CacheWarmupKey {
1995 pub provider: String,
1996 pub model: String,
1997 pub base_url: String,
1998 pub static_prefix_hash: String,
1999 pub tool_catalog_hash: String,
2000 pub project_pack_hash: String,
2001 pub skills_hash: String,
2002 }
2003
2004 impl CacheWarmupKey {
2005 pub(crate) fn from_inspection(
2006 provider: &str,
2007 model: &str,
2008 base_url: &str,
2009 inspection: &PromptInspection,
2010 ) -> Self {
2011 Self {
2012 provider: provider.to_string(),
2013 model: model.to_string(),
2014 base_url: base_url.to_string(),
2015 static_prefix_hash: inspection.base_static_prefix_hash.clone(),
2016 tool_catalog_hash: inspection.tool_catalog_hash.clone(),
2017 project_pack_hash: layer_hash(inspection, "Project context pack"),
2018 skills_hash: layer_hash(inspection, "Skills"),
2019 }
2020 }
2021
2022 pub(crate) fn hash_short(&self) -> String {
2023 let json = serde_json::to_string(self).unwrap_or_default();
2024 let hash = sha256_hex(json.as_bytes());
2025 hash[..hash.len().min(12)].to_string()
2026 }
2027 }
2028
2029 fn layer_hash(inspection: &PromptInspection, name: &str) -> String {
2030 inspection
2031 .layers
2032 .iter()
2033 .find(|layer| layer.name == name)
2034 .map(|layer| layer.sha256.clone())
2035 .unwrap_or_default()
2036 }
2037
2038 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2039 pub(crate) struct PromptLayerInspection {
2040 pub name: String,
2041 pub stability: PromptLayerStability,
2042 pub char_len: usize,
2043 pub byte_len: usize,
2044 /// Rough token estimate for quick before/after cache-hit reports.
2045 pub token_estimate: usize,
2046 pub sha256: String,
2047 pub tool_result: Option<ToolResultInspection>,
2048 pub turn_meta: Option<TurnMetaInspection>,
2049 }
2050
2051 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2052 pub(crate) struct ToolResultInspection {
2053 pub original_chars: usize,
2054 pub sent_chars: usize,
2055 pub truncated: bool,
2056 pub deduplicated: bool,
2057 }
2058
2059 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2060 pub(crate) struct TurnMetaInspection {
2061 pub original_chars: usize,
2062 pub sent_chars: usize,
2063 pub deduplicated: bool,
2064 pub sha256: String,
2065 }
2066
2067 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2068 pub(crate) enum PromptLayerStability {
2069 Static,
2070 History,
2071 Dynamic,
2072 }
2073
2074 impl PromptLayerStability {
2075 pub(crate) fn label(self) -> &'static str {
2076 match self {
2077 Self::Static => "static",
2078 Self::History => "history",
2079 Self::Dynamic => "dynamic",
2080 }
2081 }
2082 }
2083
2084 fn inspect_wire_request(tools: Option<&[Tool]>, messages: &[Value]) -> PromptInspection {
2085 let mut layers = Vec::new();
2086 let mut base_static_prefix_parts = Vec::new();
2087 let mut full_request_prefix_parts = Vec::new();
2088 let mut tool_catalog_hash = String::new();
2089 let mut start_index = 0;
2090
2091 if let Some(message) = messages.first() {
2092 let role = message
2093 .get("role")
2094 .and_then(Value::as_str)
2095 .unwrap_or("unknown");
2096 let content = message_content_for_inspect(message);
2097 if role == "system" {
2098 for (name, stability, body) in split_system_layers(&content) {
2099 if stability == PromptLayerStability::Static {
2100 base_static_prefix_parts.push(body.to_string());
2101 }
2102 if stability != PromptLayerStability::Dynamic {
2103 full_request_prefix_parts.push(body.to_string());
2104 }
2105 layers.push(prompt_layer(name, stability, body));
2106 }
2107 start_index = 1;
2108 }
2109 }
2110
2111 if let Some(tool_catalog) = tool_catalog_for_inspect(tools) {
2112 tool_catalog_hash = sha256_hex(tool_catalog.as_bytes());
2113 base_static_prefix_parts.push(tool_catalog.clone());
2114 full_request_prefix_parts.push(tool_catalog.clone());
2115 layers.push(prompt_layer(
2116 "Tool catalog".to_string(),
2117 PromptLayerStability::Static,
2118 &tool_catalog,
2119 ));
2120 }
2121
2122 for (index, message) in messages.iter().enumerate().skip(start_index) {
2123 let role = message
2124 .get("role")
2125 .and_then(Value::as_str)
2126 .unwrap_or("unknown");
2127 let content = message_content_for_inspect(message);
2128 let is_last = index + 1 == messages.len();
2129 let stability = if (is_last && role == "user") || role == "tool" {
2130 PromptLayerStability::Dynamic
2131 } else {
2132 PromptLayerStability::History
2133 };
2134 let name = if is_last && role == "user" {
2135 "User task".to_string()
2136 } else {
2137 format!("Message #{index} {role}")
2138 };
2139 if stability != PromptLayerStability::Dynamic {
2140 full_request_prefix_parts.push(content.clone());
2141 }
2142 let mut layer = prompt_layer(name, stability, &content);
2143 layer.tool_result = tool_result_inspection_for_message(message);
2144 layer.turn_meta = turn_meta_inspection_for_message(message);
2145 layers.push(layer);
2146 }
2147
2148 let base_static_prefix = base_static_prefix_parts.join("\n");
2149 let full_request_prefix = full_request_prefix_parts.join("\n");
2150
2151 PromptInspection {
2152 base_static_prefix_hash: sha256_hex(base_static_prefix.as_bytes()),
2153 full_request_prefix_hash: sha256_hex(full_request_prefix.as_bytes()),
2154 tool_catalog_hash,
2155 layers,
2156 }
2157 }
2158
2159 fn tool_catalog_for_inspect(tools: Option<&[Tool]>) -> Option<String> {
2160 let tools = tools.filter(|tools| !tools.is_empty())?;
2161 serde_json::to_string(&tools.iter().map(tool_to_chat).collect::<Vec<_>>()).ok()
2162 }
2163
2164 fn message_content_for_inspect(message: &Value) -> String {
2165 let mut parts = Vec::new();
2166 if let Some(content) = message.get("content").and_then(Value::as_str)
2167 && !content.is_empty()
2168 {
2169 parts.push(content.to_string());
2170 }
2171 if let Some(content) = message.get("content").and_then(Value::as_array) {
2172 for part in content {
2173 match part.get("type").and_then(Value::as_str) {
2174 Some("text") => {
2175 if let Some(text) = part.get("text").and_then(Value::as_str)
2176 && !text.is_empty()
2177 {
2178 parts.push(text.to_string());
2179 }
2180 }
2181 Some("image_url") => {
2182 let url = part
2183 .get("image_url")
2184 .and_then(|image_url| image_url.get("url"))
2185 .and_then(Value::as_str)
2186 .unwrap_or("");
2187 parts.push(format!(
2188 "[image_url:{}]",
2189 summarize_image_url_for_inspect(url)
2190 ));
2191 }
2192 _ => {}
2193 }
2194 }
2195 }
2196 if let Some(reasoning) = message.get("reasoning_content").and_then(Value::as_str)
2197 && !reasoning.is_empty()
2198 {
2199 parts.push(reasoning.to_string());
2200 }
2201 if let Some(tool_calls) = message.get("tool_calls") {
2202 parts.push(tool_calls.to_string());
2203 }
2204 parts.join("\n")
2205 }
2206
2207 fn summarize_image_url_for_inspect(url: &str) -> String {
2208 let Some((prefix, encoded)) = url.split_once(";base64,") else {
2209 return first_chars(url, 96);
2210 };
2211 format!("{prefix};base64,<{} chars>", encoded.len())
2212 }
2213
2214 fn tool_result_inspection_for_message(message: &Value) -> Option<ToolResultInspection> {
2215 if message.get("role").and_then(Value::as_str) != Some("tool") {
2216 return None;
2217 }
2218 let budget = message.get("_tool_result_budget")?;
2219 Some(ToolResultInspection {
2220 original_chars: budget
2221 .get("original_chars")
2222 .and_then(Value::as_u64)
2223 .and_then(|n| usize::try_from(n).ok())?,
2224 sent_chars: budget
2225 .get("sent_chars")
2226 .and_then(Value::as_u64)
2227 .and_then(|n| usize::try_from(n).ok())?,
2228 truncated: budget
2229 .get("truncated")
2230 .and_then(Value::as_bool)
2231 .unwrap_or(false),
2232 deduplicated: budget
2233 .get("deduplicated")
2234 .and_then(Value::as_bool)
2235 .unwrap_or(false),
2236 })
2237 }
2238
2239 fn turn_meta_inspection_for_message(message: &Value) -> Option<TurnMetaInspection> {
2240 let budget = message.get("_turn_meta_budget")?;
2241 Some(TurnMetaInspection {
2242 original_chars: budget
2243 .get("original_chars")
2244 .and_then(Value::as_u64)
2245 .and_then(|n| usize::try_from(n).ok())?,
2246 sent_chars: budget
2247 .get("sent_chars")
2248 .and_then(Value::as_u64)
2249 .and_then(|n| usize::try_from(n).ok())?,
2250 deduplicated: budget
2251 .get("deduplicated")
2252 .and_then(Value::as_bool)
2253 .unwrap_or(false),
2254 sha256: budget
2255 .get("sha256")
2256 .and_then(Value::as_str)
2257 .map(str::to_string)?,
2258 })
2259 }
2260
2261 fn split_system_layers(content: &str) -> Vec<(String, PromptLayerStability, &str)> {
2262 let markers = [
2263 ("Project context", "<project_instructions"),
2264 ("Project context pack", "## Project Context Pack"),
2265 ("Environment", "## Environment"),
2266 ("Configured instructions", "<instructions "),
2267 ("User memory", "## User Memory"),
2268 ("Current session goal", "## Current Session Goal"),
2269 ("Skills", "## Skills"),
2270 ("Core execution", "## Core Execution"),
2271 ("Compact template", "## Compact"),
2272 ("Previous session relay", "## Previous Session Relay"),
2273 ];
2274
2275 let mut starts: Vec<(usize, &str)> = markers
2276 .iter()
2277 .filter_map(|(name, marker)| content.find(marker).map(|idx| (idx, *name)))
2278 .collect();
2279 starts.sort_by_key(|(idx, _)| *idx);
2280
2281 let mut layers = Vec::new();
2282 let first_marker = starts.first().map_or(content.len(), |(idx, _)| *idx);
2283 if first_marker > 0 {
2284 layers.push((
2285 "Global system prefix".to_string(),
2286 PromptLayerStability::Static,
2287 content[..first_marker].trim(),
2288 ));
2289 }
2290
2291 for (i, (start, name)) in starts.iter().enumerate() {
2292 let end = starts.get(i + 1).map_or(content.len(), |(idx, _)| *idx);
2293 let stability = if *name == "Previous session relay" {
2294 PromptLayerStability::Dynamic
2295 } else if is_static_base_layer(name) {
2296 PromptLayerStability::Static
2297 } else {
2298 PromptLayerStability::History
2299 };
2300 layers.push(((*name).to_string(), stability, content[*start..end].trim()));
2301 }
2302
2303 if layers.is_empty() {
2304 layers.push((
2305 "Global system prefix".to_string(),
2306 PromptLayerStability::Static,
2307 content.trim(),
2308 ));
2309 }
2310 layers
2311 }
2312
2313 fn is_static_base_layer(name: &str) -> bool {
2314 matches!(
2315 name,
2316 "Global system prefix"
2317 | "Environment"
2318 | "Skills"
2319 | "Project context"
2320 | "Project context pack"
2321 | "Core execution"
2322 | "Compact template"
2323 )
2324 }
2325
2326 fn stable_system_prompt(system: Option<&SystemPrompt>) -> Option<SystemPrompt> {
2327 let instructions = system_to_instructions(system.cloned())?;
2328 let stable = split_system_layers(&instructions)
2329 .into_iter()
2330 .filter_map(|(_, stability, body)| {
2331 (stability == PromptLayerStability::Static).then_some(body)
2332 })
2333 .collect::<Vec<_>>()
2334 .join("\n\n");
2335 if stable.trim().is_empty() {
2336 None
2337 } else {
2338 Some(SystemPrompt::Text(stable))
2339 }
2340 }
2341
2342 fn stable_history_messages(messages: &[Message]) -> Vec<Message> {
2343 let mut end = messages.len();
2344 if messages
2345 .last()
2346 .is_some_and(|message| message.role.as_str() == "user")
2347 {
2348 end = end.saturating_sub(1);
2349 }
2350 messages[..end].to_vec()
2351 }
2352
2353 fn prompt_layer(
2354 name: String,
2355 stability: PromptLayerStability,
2356 content: &str,
2357 ) -> PromptLayerInspection {
2358 let char_len = content.chars().count();
2359 let token_estimate = if char_len == 0 {
2360 0
2361 } else if content.is_ascii() {
2362 (char_len / 4).max(1)
2363 } else {
2364 char_len.max(1)
2365 };
2366 PromptLayerInspection {
2367 name,
2368 stability,
2369 char_len,
2370 byte_len: content.len(),
2371 token_estimate,
2372 sha256: sha256_hex(content.as_bytes()),
2373 tool_result: None,
2374 turn_meta: None,
2375 }
2376 }
2377
2378 fn sha256_hex(bytes: &[u8]) -> String {
2379 crate::hashing::sha256_hex(bytes)
2380 }
2381
2382 #[derive(Clone)]
2383 struct PendingToolCallInfo {
2384 tool_name: String,
2385 input: Value,
2386 }
2387
2388 struct SeenToolResult {
2389 message_label: String,
2390 original_chars: usize,
2391 }
2392
2393 struct WireToolResult {
2394 content: String,
2395 original_chars: usize,
2396 sent_chars: usize,
2397 truncated: bool,
2398 deduplicated: bool,
2399 }
2400
2401 #[derive(Clone)]
2402 struct TurnMetaBudget {
2403 original_chars: usize,
2404 sent_chars: usize,
2405 deduplicated: bool,
2406 sha256: String,
2407 }
2408
2409 struct LastFullTurnMeta {
2410 sha256: String,
2411 }
2412
2413 fn render_turn_meta_for_wire(
2414 text: &str,
2415 last_full_turn_meta: &mut Option<LastFullTurnMeta>,
2416 ) -> (String, TurnMetaBudget) {
2417 let original_chars = text.chars().count();
2418 let sha = sha256_hex(text.as_bytes());
2419
2420 if last_full_turn_meta
2421 .as_ref()
2422 .is_some_and(|previous| previous.sha256 == sha)
2423 {
2424 // Keep the repeated metadata slot short without surfacing an
2425 // opaque hash the model cannot resolve.
2426 let rendered = "<turn_meta_unchanged />".to_string();
2427 let budget = TurnMetaBudget {
2428 original_chars,
2429 sent_chars: rendered.chars().count(),
2430 deduplicated: true,
2431 sha256: sha,
2432 };
2433 return (rendered, budget);
2434 }
2435
2436 *last_full_turn_meta = Some(LastFullTurnMeta {
2437 sha256: sha.clone(),
2438 });
2439 (
2440 text.to_string(),
2441 TurnMetaBudget {
2442 original_chars,
2443 sent_chars: original_chars,
2444 deduplicated: false,
2445 sha256: sha,
2446 },
2447 )
2448 }
2449
2450 fn is_turn_meta_text(text: &str) -> bool {
2451 text.trim_start().starts_with("<turn_meta>")
2452 }
2453
2454 fn turn_meta_budget_json(turn_meta: &TurnMetaBudget) -> Value {
2455 json!({
2456 "original_chars": turn_meta.original_chars,
2457 "sent_chars": turn_meta.sent_chars,
2458 "deduplicated": turn_meta.deduplicated,
2459 "sha256": turn_meta.sha256,
2460 })
2461 }
2462
2463 /// Mutating/write tools whose result body is a *confirmation* (it embeds
2464 /// the unified diff + summary of what was just written), not retrievable
2465 /// reference data. Two identical large `write_file` calls must each keep
2466 /// their full confirmation inline: collapsing the later one to a
2467 /// `<TOOL_RESULT_REF sha="..." />` makes the model lose the write-success
2468 /// context and behave as if the file is missing (issue #1695). Read-style
2469 /// tools (`read_file`, `grep_files`, `exec_shell`, …) may deduplicate medium
2470 /// outputs by pointing at an earlier full message in the same request. They
2471 /// never advertise a process-wide SHA as retrievable: that store cannot prove
2472 /// session ownership.
2473 fn is_mutation_tool(tool_name: &str) -> bool {
2474 matches!(
2475 tool_name,
2476 "write" | "edit" | "write_file" | "edit_file" | "apply_patch"
2477 )
2478 }
2479
2480 fn compact_tool_result_for_wire(
2481 tool_name: &str,
2482 input: &Value,
2483 content: &str,
2484 message_label: &str,
2485 seen_tool_results: &mut HashMap<String, SeenToolResult>,
2486 ) -> WireToolResult {
2487 let original_chars = content.chars().count();
2488 let sha = sha256_hex(content.as_bytes());
2489
2490 // Only medium, non-mutation results can point back to a full earlier
2491 // message in this one request. Oversized results are already excerpts, so
2492 // a back-reference would falsely imply the exact bytes remain available.
2493 let sent_budget = tool_result_sent_char_budget();
2494 let dedup_eligible = (TOOL_RESULT_DEDUP_MIN_CHARS..=sent_budget).contains(&original_chars)
2495 && !is_mutation_tool(tool_name);
2496
2497 if dedup_eligible && let Some(previous) = seen_tool_results.get(&sha) {
2498 let content = format!(
2499 "<TOOL_RESULT_REF sha=\"{sha}\" original_message=\"{label}\" chars=\"{chars}\">\n\
2500 source: full content appears in {label} earlier in this request\n\
2501 </TOOL_RESULT_REF>",
2502 label = previous.message_label,
2503 chars = previous.original_chars,
2504 );
2505 return WireToolResult {
2506 sent_chars: content.chars().count(),
2507 content,
2508 original_chars,
2509 truncated: false,
2510 deduplicated: true,
2511 };
2512 }
2513
2514 if dedup_eligible {
2515 seen_tool_results.insert(
2516 sha.clone(),
2517 SeenToolResult {
2518 message_label: message_label.to_string(),
2519 original_chars,
2520 },
2521 );
2522 }
2523
2524 if original_chars <= sent_budget {
2525 return WireToolResult {
2526 content: content.to_string(),
2527 original_chars,
2528 sent_chars: original_chars,
2529 truncated: false,
2530 deduplicated: false,
2531 };
2532 }
2533
2534 // Content already bounded by the adaptive evidence envelope carries its
2535 // own honest footer: the omitted count, the on-disk artifact path, and a
2536 // recovery instruction. Truncating it again here would destroy that
2537 // recovery contract and falsely report that no session-owned artifact
2538 // was recorded, so pass it through untouched.
2539 if content.contains(crate::tools::truncate::SPILLOVER_RECOVERY_HINT) {
2540 return WireToolResult {
2541 content: content.to_string(),
2542 original_chars,
2543 sent_chars: original_chars,
2544 truncated: false,
2545 deduplicated: false,
2546 };
2547 }
2548
2549 let head = first_chars(content, TOOL_RESULT_HEAD_CHARS);
2550 let tail = last_chars(content, TOOL_RESULT_TAIL_CHARS);
2551 let kept = head.chars().count() + tail.chars().count();
2552 let omitted = original_chars.saturating_sub(kept);
2553 let compacted = format!(
2554 "[TOOL_RESULT_TRUNCATED]\n\
2555 tool_name: {tool_name}\n\
2556 command_or_query: {}\n\
2557 exit_status: {}\n\
2558 original_chars: {original_chars}\n\
2559 sha256: {sha}\n\
2560 exact_detail: unavailable; no session-owned artifact was recorded\n\
2561 first_chars:\n\
2562 {head}\n\n\
2563 [... truncated {omitted} chars from middle ...]\n\n\
2564 last_chars:\n\
2565 {tail}",
2566 tool_command_or_query(input),
2567 tool_exit_status(content)
2568 );
2569
2570 WireToolResult {
2571 sent_chars: compacted.chars().count(),
2572 content: compacted,
2573 original_chars,
2574 truncated: true,
2575 deduplicated: false,
2576 }
2577 }
2578
2579 fn tool_command_or_query(input: &Value) -> String {
2580 for key in ["command", "cmd", "query", "q", "pattern", "path", "url"] {
2581 if let Some(value) = input.get(key) {
2582 return summarize_for_metadata(value, 500);
2583 }
2584 }
2585 summarize_for_metadata(input, 500)
2586 }
2587
2588 fn tool_exit_status(content: &str) -> String {
2589 if let Ok(value) = serde_json::from_str::<Value>(content) {
2590 for key in ["exit_code", "exit_status", "status", "code"] {
2591 if let Some(value) = value.get(key) {
2592 return summarize_for_metadata(value, 120);
2593 }
2594 }
2595 }
2596
2597 for line in content.lines().take(20) {
2598 let trimmed = line.trim();
2599 for prefix in ["Exit code:", "exit code:", "Exit status:", "exit status:"] {
2600 if let Some(value) = trimmed.strip_prefix(prefix) {
2601 return value.trim().to_string();
2602 }
2603 }
2604 }
2605 "unknown".to_string()
2606 }
2607
2608 fn summarize_for_metadata(value: &Value, max_chars: usize) -> String {
2609 let raw = value
2610 .as_str()
2611 .map(str::to_string)
2612 .unwrap_or_else(|| value.to_string());
2613 let mut summarized = first_chars(&raw.replace('\n', "\\n"), max_chars);
2614 if raw.chars().count() > max_chars {
2615 summarized.push_str("...");
2616 }
2617 summarized
2618 }
2619
2620 fn first_chars(value: &str, count: usize) -> String {
2621 value.chars().take(count).collect()
2622 }
2623
2624 fn last_chars(value: &str, count: usize) -> String {
2625 let mut chars: Vec<char> = value.chars().rev().take(count).collect();
2626 chars.reverse();
2627 chars.into_iter().collect()
2628 }
2629
2630 fn merge_adjacent_user_content(previous: Value, current: Value) -> Value {
2631 match (previous, current) {
2632 (Value::String(left), Value::String(right)) => json!(format!("{left}\n\n{right}")),
2633 (left, right) => {
2634 let mut parts = Vec::new();
2635 for content in [left, right] {
2636 match content {
2637 Value::Array(items) => parts.extend(items),
2638 Value::String(text) => parts.push(json!({"type": "text", "text": text})),
2639 other => parts.push(other),
2640 }
2641 }
2642 Value::Array(parts)
2643 }
2644 }
2645 }
2646
2647 fn build_chat_messages_with_reasoning(
2648 system: Option<&SystemPrompt>,
2649 messages: &[Message],
2650 _model: &str,
2651 include_reasoning: bool,
2652 include_tool_budget_metadata: bool,
2653 ) -> Vec<Value> {
2654 let mut out = Vec::new();
2655 let mut pending_tool_calls: HashMap<String, PendingToolCallInfo> = HashMap::new();
2656 // Chat Completions requires every result for one assistant tool-call batch
2657 // to be contiguous. Keep tool-result media aside until the complete batch
2658 // has been emitted as `role: tool` messages.
2659 let mut deferred_tool_result_images = Vec::new();
2660 let mut seen_tool_results: HashMap<String, SeenToolResult> = HashMap::new();
2661 let mut last_full_turn_meta: Option<LastFullTurnMeta> = None;
2662
2663 if let Some(instructions) = system_to_instructions(system.cloned())
2664 && !instructions.trim().is_empty()
2665 {
2666 out.push(json!({
2667 "role": "system",
2668 "content": instructions,
2669 }));
2670 }
2671
2672 // Persisted compaction keeps its summary after the bounded last round.
2673 // On strict paired chat templates a user message after a tool result is
2674 // invalid. Reorder only a generated summary immediately after a tool
2675 // result; its independent provenance block rules out quoted user text.
2676 // The session log retains every original message and tool ID.
2677 // Limitation: this normalization applies to Chat Completions only.
2678 let summary_index = messages
2679 .iter()
2680 .enumerate()
2681 .rev()
2682 .find_map(|(index, message)| {
2683 (index > 0
2684 && crate::compaction::is_wire_compaction_checkpoint_message(message)
2685 && messages[index - 1]
2686 .content
2687 .iter()
2688 .any(|block| matches!(block, ContentBlock::ToolResult { .. })))
2689 .then_some(index)
2690 });
2691 let summary_target = summary_index.and_then(|summary_index| {
2692 messages[..summary_index]
2693 .iter()
2694 .rposition(|message| {
2695 crate::runtime_handoff::classify_user_turn_prompt(message)
2696 != crate::runtime_handoff::UserTurnPromptKind::NotPrompt
2697 })
2698 .or_else(|| {
2699 messages[..summary_index]
2700 .iter()
2701 .position(|message| message.role.is_assistant_like())
2702 })
2703 });
2704 let wire_messages = (0..messages.len())
2705 .filter(|index| Some(*index) != summary_index || summary_target.is_none())
2706 .flat_map(|index| {
2707 if Some(index) == summary_target {
2708 [summary_index, Some(index)]
2709 .into_iter()
2710 .flatten()
2711 .collect::<Vec<_>>()
2712 } else {
2713 vec![index]
2714 }
2715 });
2716
2717 for message_index in wire_messages {
2718 let message = &messages[message_index];
2719 // Which wire channel this message belongs in is decided by the shared
2720 // placement table, not by an `if` chain local to this adapter.
2721 let placement = role_placement(&message.role, WireDialect::ChatCompletions);
2722 let mut text_parts = Vec::new();
2723 let mut image_parts = Vec::new();
2724 let mut thinking_parts = Vec::new();
2725 let mut tool_calls = Vec::new();
2726 let mut tool_call_infos = Vec::new();
2727 let mut tool_results: Vec<(String, String, String, Vec<Value>)> = Vec::new();
2728 let mut turn_meta_budget: Option<TurnMetaBudget> = None;
2729
2730 for block in &message.content {
2731 match block {
2732 ContentBlock::Text { text, .. } => {
2733 if is_turn_meta_text(text) {
2734 let (rendered, budget) =
2735 render_turn_meta_for_wire(text, &mut last_full_turn_meta);
2736 text_parts.push(rendered);
2737 turn_meta_budget = Some(budget);
2738 } else {
2739 text_parts.push(text.clone());
2740 }
2741 }
2742 ContentBlock::ImageUrl { image_url } => {
2743 image_parts.push(json!({
2744 "type": "image_url",
2745 "image_url": {
2746 "url": image_url.url.clone(),
2747 },
2748 }));
2749 }
2750 ContentBlock::Thinking { thinking, .. } => thinking_parts.push(thinking.clone()),
2751 ContentBlock::ToolUse {
2752 id,
2753 name,
2754 input,
2755 caller,
2756 thought_signature,
2757 } => {
2758 let args = serde_json::to_string(input).unwrap_or_else(|_| input.to_string());
2759 let mut call = json!({
2760 "id": id,
2761 "type": "function",
2762 "function": {
2763 "name": to_api_tool_name(name),
2764 "arguments": args,
2765 }
2766 });
2767 if let Some(signature) = thought_signature {
2768 call["extra_content"]["google"]["thought_signature"] = json!(signature);
2769 }
2770 if let Some(caller) = caller {
2771 call["caller"] = json!({
2772 "type": caller.caller_type,
2773 "tool_id": caller.tool_id,
2774 });
2775 }
2776 tool_calls.push(call);
2777 tool_call_infos.push((
2778 id.clone(),
2779 PendingToolCallInfo {
2780 tool_name: name.clone(),
2781 input: input.clone(),
2782 },
2783 ));
2784 }
2785 ContentBlock::ToolResult {
2786 tool_use_id,
2787 content,
2788 content_blocks,
2789 ..
2790 } => {
2791 let message_label = format!("Message #{message_index}");
2792 tool_results.push((
2793 tool_use_id.clone(),
2794 content.clone(),
2795 message_label,
2796 content_blocks.clone().unwrap_or_default(),
2797 ));
2798 }
2799 ContentBlock::ServerToolUse { .. }
2800 | ContentBlock::ToolSearchToolResult { .. }
2801 | ContentBlock::CodeExecutionToolResult { .. } => {}
2802 }
2803 }
2804
2805 let out_len_before_role_projection = out.len();
2806 if placement.is_assistant_channel() {
2807 let content = if placement == RolePlacement::InterruptedAssistant {
2808 format!(
2809 "{}{}",
2810 codewhale_models::INTERRUPTED_ASSISTANT_CONTEXT_PREFIX,
2811 text_parts.join("\n")
2812 )
2813 } else {
2814 text_parts.join("\n")
2815 };
2816 let mut reasoning_content = thinking_parts.join("\n");
2817 let has_text = !content.trim().is_empty();
2818 let has_tool_calls = !tool_calls.is_empty();
2819 // Reasoning replay must be a function of the stored message ONLY,
2820 // never of later history. DeepSeek's prefix cache hashes the raw
2821 // bytes of every message; flipping `reasoning_content` on/off
2822 // depending on whether a follow-up user turn exists rewrites a
2823 // historical message between turns and busts the cache from that
2824 // point onwards. Always emit `reasoning_content` when the model
2825 // requires replay AND the stored message carries thinking text.
2826 // Tool-call messages with empty thinking still need a placeholder
2827 // (DeepSeek 400s without it), but text-only assistant messages
2828 // simply omit the field when there's nothing to replay.
2829 let mut has_reasoning = include_reasoning && !reasoning_content.trim().is_empty();
2830 if include_reasoning && has_tool_calls && !has_reasoning {
2831 logging::warn(
2832 "Substituting placeholder reasoning_content for DeepSeek tool-call assistant message",
2833 );
2834 reasoning_content = String::from(REASONING_REPLAY_PLACEHOLDER);
2835 has_reasoning = true;
2836 }
2837
2838 // DeepSeek rejects assistant messages where both `content` and
2839 // `tool_calls` are missing/null. Skip such entries even if they
2840 // carry reasoning-only metadata unless we can send a non-null
2841 // placeholder content field.
2842 if !has_text && !has_tool_calls && !has_reasoning {
2843 pending_tool_calls.clear();
2844 deferred_tool_result_images.clear();
2845 continue;
2846 }
2847
2848 let mut msg = json!({
2849 "role": "assistant",
2850 "content": if has_text {
2851 json!(content)
2852 } else if has_reasoning {
2853 json!("")
2854 } else {
2855 Value::Null
2856 },
2857 });
2858 if has_reasoning {
2859 msg["reasoning_content"] = json!(reasoning_content);
2860 }
2861 if has_tool_calls {
2862 msg["tool_calls"] = json!(tool_calls);
2863 let expected_tool_result_count = tool_call_infos.len();
2864 pending_tool_calls = tool_call_infos.into_iter().collect();
2865 deferred_tool_result_images.clear();
2866 if pending_tool_calls.len() != expected_tool_result_count {
2867 logging::warn(
2868 "Rejecting assistant tool-call batch with duplicate tool_call IDs",
2869 );
2870 pending_tool_calls.clear();
2871 }
2872 } else {
2873 pending_tool_calls.clear();
2874 deferred_tool_result_images.clear();
2875 }
2876 out.push(msg);
2877 } else if matches!(placement, RolePlacement::System | RolePlacement::Developer) {
2878 let content = text_parts.join("\n");
2879 if !content.trim().is_empty() {
2880 let mut msg = json!({
2881 "role": if placement == RolePlacement::Developer {
2882 "developer"
2883 } else {
2884 "system"
2885 },
2886 "content": content,
2887 });
2888 if include_tool_budget_metadata && let Some(turn_meta) = &turn_meta_budget {
2889 msg["_turn_meta_budget"] = turn_meta_budget_json(turn_meta);
2890 }
2891 out.push(msg);
2892 }
2893 } else if placement == RolePlacement::User {
2894 let content = text_parts.join("\n");
2895 let has_text = !content.trim().is_empty();
2896 let has_images = !image_parts.is_empty();
2897 if has_text || has_images {
2898 let wire_content = if has_images {
2899 let mut parts = Vec::new();
2900 if has_text {
2901 parts.push(json!({
2902 "type": "text",
2903 "text": content,
2904 }));
2905 }
2906 parts.extend(image_parts);
2907 json!(parts)
2908 } else {
2909 json!(content)
2910 };
2911 let mut msg = json!({
2912 "role": "user",
2913 "content": wire_content,
2914 });
2915 if include_tool_budget_metadata && let Some(turn_meta) = &turn_meta_budget {
2916 msg["_turn_meta_budget"] = turn_meta_budget_json(turn_meta);
2917 }
2918 if (Some(message_index) == summary_index
2919 || Some(message_index) == summary_target
2920 || crate::compaction::is_wire_compaction_checkpoint_message(message)
2921 || crate::runtime_handoff::is_agent_topology_checkpoint(message)
2922 || crate::runtime_handoff::is_restored_agent_topology_checkpoint(message))
2923 && let Some(previous) = out.last_mut()
2924 && previous.get("role").and_then(Value::as_str) == Some("user")
2925 {
2926 let previous_content = previous["content"].take();
2927 let current_content = msg["content"].take();
2928 previous["content"] =
2929 merge_adjacent_user_content(previous_content, current_content);
2930 if previous.get("_turn_meta_budget").is_none()
2931 && let Some(meta) = msg.get("_turn_meta_budget")
2932 {
2933 previous["_turn_meta_budget"] = meta.clone();
2934 }
2935 } else {
2936 out.push(msg);
2937 }
2938 }
2939 }
2940
2941 // A user/system/developer wire message closes the contiguous run that
2942 // must follow an assistant tool-call message. If the same stored
2943 // message also carries a later tool result, reject it here instead of
2944 // briefly accepting the result and letting any synthesized media
2945 // escape after the safety pass strips the malformed batch.
2946 if out.len() > out_len_before_role_projection
2947 && !placement.is_assistant_channel()
2948 && !pending_tool_calls.is_empty()
2949 {
2950 logging::warn("Dropping tool-call batch interrupted by non-tool content");
2951 pending_tool_calls.clear();
2952 deferred_tool_result_images.clear();
2953 }
2954
2955 if !tool_results.is_empty() {
2956 if pending_tool_calls.is_empty() {
2957 logging::warn("Dropping tool results without matching tool_calls");
2958 } else {
2959 for (tool_id, content, message_label, content_blocks) in tool_results {
2960 if let Some(tool_info) = pending_tool_calls.remove(&tool_id) {
2961 let (image, omitted) = crate::image_attach::provider_tool_result_image_refs(
2962 Some(&content_blocks),
2963 );
2964 let content =
2965 crate::image_attach::tool_result_text_with_omission(&content, omitted);
2966 let wire_result = compact_tool_result_for_wire(
2967 &tool_info.tool_name,
2968 &tool_info.input,
2969 &content,
2970 &message_label,
2971 &mut seen_tool_results,
2972 );
2973 let mut tool_msg = json!({
2974 "role": "tool",
2975 "tool_call_id": tool_id,
2976 "content": wire_result.content,
2977 });
2978 if include_tool_budget_metadata {
2979 tool_msg["_tool_result_budget"] = json!({
2980 "original_chars": wire_result.original_chars,
2981 "sent_chars": wire_result.sent_chars,
2982 "truncated": wire_result.truncated,
2983 "deduplicated": wire_result.deduplicated,
2984 });
2985 }
2986 out.push(tool_msg);
2987 if let Some((mime_type, data)) = image {
2988 deferred_tool_result_images.push(json!({
2989 "type": "text",
2990 "text": format!(
2991 "Image returned by tool `{}` (call `{tool_id}`):",
2992 tool_info.tool_name,
2993 ),
2994 }));
2995 deferred_tool_result_images.push(json!({
2996 "type": "image_url",
2997 "image_url": {
2998 "url": format!("data:{mime_type};base64,{data}")
2999 },
3000 }));
3001 }
3002 } else {
3003 logging::warn(format!(
3004 "Dropping tool result for unknown tool_call_id: {tool_id}"
3005 ));
3006 }
3007 }
3008 if pending_tool_calls.is_empty() && !deferred_tool_result_images.is_empty() {
3009 out.push(json!({
3010 "role": "user",
3011 "content": std::mem::take(&mut deferred_tool_result_images),
3012 }));
3013 }
3014 }
3015 } else if !placement.is_assistant_channel() {
3016 pending_tool_calls.clear();
3017 deferred_tool_result_images.clear();
3018 }
3019 }
3020
3021 // Safety net: after compaction, an assistant message may have tool_calls
3022 // whose results were summarized away. The API rejects these, so strip
3023 // the tool_calls (downgrading to a plain assistant message) and remove
3024 // the now-orphaned tool result messages.
3025 let mut i = 0;
3026 while i < out.len() {
3027 let is_assistant_with_tools = out[i].get("role").and_then(Value::as_str)
3028 == Some("assistant")
3029 && out[i].get("tool_calls").is_some();
3030
3031 if is_assistant_with_tools {
3032 let expected_ids: Vec<String> = out[i]
3033 .get("tool_calls")
3034 .and_then(Value::as_array)
3035 .map(|calls| {
3036 calls
3037 .iter()
3038 .filter_map(|c| c.get("id").and_then(Value::as_str).map(String::from))
3039 .collect()
3040 })
3041 .unwrap_or_default();
3042
3043 // Collect tool result IDs immediately following this assistant message.
3044 let mut found_ids = Vec::new();
3045 let mut tool_result_end = i + 1;
3046 while tool_result_end < out.len() {
3047 if out[tool_result_end].get("role").and_then(Value::as_str) == Some("tool") {
3048 if let Some(id) = out[tool_result_end]
3049 .get("tool_call_id")
3050 .and_then(Value::as_str)
3051 {
3052 found_ids.push(id.to_string());
3053 }
3054 tool_result_end += 1;
3055 } else {
3056 break;
3057 }
3058 }
3059
3060 // Chat Completions accepts only the immediately contiguous tool
3061 // run after its assistant tool-call message. Do not accept a
3062 // later tool result after user/system content has intervened.
3063 let results_match = expected_ids.len() == found_ids.len()
3064 && expected_ids.iter().all(|id| found_ids.contains(id));
3065 if !results_match {
3066 let missing: Vec<_> = expected_ids
3067 .iter()
3068 .filter(|id| !found_ids.contains(*id))
3069 .collect();
3070 logging::warn(format!(
3071 "Stripping orphaned tool_calls from assistant message \
3072 (expected {} tool results, found {}, missing: {:?})",
3073 expected_ids.len(),
3074 found_ids.len(),
3075 missing
3076 ));
3077 if let Some(obj) = out[i].as_object_mut() {
3078 obj.remove("tool_calls");
3079 }
3080 // If tool_calls were the only assistant content, remove the now-invalid
3081 // assistant message entirely (DeepSeek requires content or tool_calls).
3082 let assistant_content_empty = out[i]
3083 .get("content")
3084 .is_none_or(|v| v.is_null() || v.as_str().is_some_and(str::is_empty));
3085 if assistant_content_empty {
3086 // Remove orphaned tool results tied to this stripped assistant call set.
3087 let mut j = out.len();
3088 while j > i + 1 {
3089 j -= 1;
3090 if out[j].get("role").and_then(Value::as_str) == Some("tool")
3091 && let Some(id) = out[j].get("tool_call_id").and_then(Value::as_str)
3092 && expected_ids.iter().any(|expected| expected == id)
3093 {
3094 out.remove(j);
3095 }
3096 }
3097 out.remove(i);
3098 i = i.saturating_sub(1);
3099 continue;
3100 }
3101 // Remove contiguous tool results first
3102 if tool_result_end > i + 1 {
3103 out.drain((i + 1)..tool_result_end);
3104 }
3105 // Remove any remaining non-contiguous tool results referencing expected_ids
3106 // (scan backward to avoid index shifting issues)
3107 let mut j = out.len();
3108 while j > i + 1 {
3109 j -= 1;
3110 if out[j].get("role").and_then(Value::as_str) == Some("tool")
3111 && let Some(id) = out[j].get("tool_call_id").and_then(Value::as_str)
3112 && expected_ids.iter().any(|expected| expected == id)
3113 {
3114 out.remove(j);
3115 }
3116 }
3117 }
3118 }
3119 i += 1;
3120 }
3121
3122 out
3123 }
3124
3125 pub(super) fn tool_to_chat(tool: &Tool) -> Value {
3126 let mut value = json!({
3127 "type": "function",
3128 "function": {
3129 "name": to_api_tool_name(&tool.name),
3130 "description": tool.description,
3131 "parameters": tool.input_schema,
3132 }
3133 });
3134 if let Some(strict) = tool.strict
3135 && let Some(function) = value.get_mut("function")
3136 {
3137 function["strict"] = json!(strict);
3138 }
3139 value
3140 }
3141
3142 pub(super) fn tool_to_chat_for_base_url(tool: &Tool, base_url: &str) -> Value {
3143 let mut value = tool_to_chat(tool);
3144 if !deepseek_base_url_supports_strict_tools(base_url)
3145 && let Some(function) = value.get_mut("function")
3146 && let Some(obj) = function.as_object_mut()
3147 {
3148 obj.remove("strict");
3149 }
3150 value
3151 }
3152
3153 fn deepseek_base_url_supports_strict_tools(base_url: &str) -> bool {
3154 let trimmed = base_url.trim_end_matches('/').to_ascii_lowercase();
3155 let is_deepseek = trimmed == "https://api.deepseek.com"
3156 || trimmed == "https://api.deepseek.com/v1"
3157 || trimmed == "https://api.deepseek.com/beta"
3158 || trimmed == "https://api.deepseeki.com"
3159 || trimmed == "https://api.deepseeki.com/v1"
3160 || trimmed == "https://api.deepseeki.com/beta";
3161 !is_deepseek || trimmed.ends_with("/beta")
3162 }
3163
3164 fn map_tool_choice_for_chat(choice: &Value) -> Option<Value> {
3165 if let Some(choice_str) = choice.as_str() {
3166 return Some(json!(choice_str));
3167 }
3168 let Some(choice_type) = choice.get("type").and_then(Value::as_str) else {
3169 return Some(choice.clone());
3170 };
3171
3172 match choice_type {
3173 "auto" | "none" => Some(json!(choice_type)),
3174 "any" => Some(json!("auto")),
3175 "tool" => choice.get("name").and_then(Value::as_str).map(|name| {
3176 json!({
3177 "type": "function",
3178 "function": { "name": to_api_tool_name(name) }
3179 })
3180 }),
3181 _ => Some(choice.clone()),
3182 }
3183 }
3184
3185 fn should_send_tool_choice_for_chat(provider: ApiProvider, effort: Option<&str>) -> bool {
3186 if !matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {
3187 return true;
3188 }
3189 !reasoning_effort_enables_thinking(effort)
3190 }
3191
3192 fn reasoning_effort_enables_thinking(effort: Option<&str>) -> bool {
3193 let Some(effort) = effort else {
3194 return false;
3195 };
3196 !matches!(
3197 effort.trim().to_ascii_lowercase().as_str(),
3198 "off" | "disabled" | "none" | "false"
3199 )
3200 }
3201
3202 /// Final-pass sanitizer over the outgoing chat-completions JSON payload.
3203 /// Forces a non-empty `reasoning_content` onto assistant messages that carry
3204 /// `tool_calls`, when the model + effort combination requires it. DeepSeek's
3205 /// thinking-mode API rejects such messages with a 400 error; substituting a
3206 /// placeholder keeps the conversation chain intact. Non-tool assistant
3207 /// reasoning can stay omitted once a later user text turn begins.
3208 ///
3209 /// Also tallies the size of all replayed `reasoning_content` and logs it, so
3210 /// users on `RUST_LOG=codewhale_tui=debug` can see how much of their input
3211 /// budget is being spent re-sending prior thinking traces.
3212 #[cfg(test)]
3213 pub(super) fn sanitize_thinking_mode_messages(
3214 body: &mut Value,
3215 model: &str,
3216 effort: Option<&str>,
3217 provider: ApiProvider,
3218 ) -> Option<u32> {
3219 sanitize_thinking_mode_messages_for_route(body, model, effort, provider, "")
3220 }
3221
3222 /// Route-aware variant of `sanitize_thinking_mode_messages`.
3223 ///
3224 /// The wrapper above remains intentionally route-agnostic for existing test
3225 /// helpers and generic callers. Production chat requests call this version so
3226 /// exact Kimi Code K3 assistant tool turns retain the reasoning trace that
3227 /// K3 expects on the next request.
3228 pub(super) fn sanitize_thinking_mode_messages_for_route(
3229 body: &mut Value,
3230 model: &str,
3231 effort: Option<&str>,
3232 provider: ApiProvider,
3233 base_url: &str,
3234 ) -> Option<u32> {
3235 // Mistral replay is encoded inside polymorphic `content` blocks, not the
3236 // DeepSeek `reasoning_content` field. Running the DeepSeek placeholder
3237 // sanitizer after reshaping would add a second, invalid reasoning dialect
3238 // to assistant tool-call turns.
3239 if is_exact_mistral_chat_route(provider, base_url) {
3240 return None;
3241 }
3242 if !should_replay_reasoning_content_for_provider_on_route(provider, base_url, model, effort) {
3243 return None;
3244 }
3245 let messages = body.get_mut("messages").and_then(Value::as_array_mut)?;
3246 let mut substitutions: u32 = 0;
3247 let mut replay_chars: u64 = 0;
3248 let mut replay_messages: u32 = 0;
3249 for (idx, msg) in messages.iter_mut().enumerate() {
3250 if msg.get("role").and_then(Value::as_str) != Some("assistant") {
3251 continue;
3252 }
3253 let has_tool_calls = msg.get("tool_calls").is_some();
3254 let needs_placeholder = msg
3255 .get("reasoning_content")
3256 .and_then(Value::as_str)
3257 .is_none_or(|s| s.trim().is_empty());
3258 if has_tool_calls && needs_placeholder {
3259 msg["reasoning_content"] = json!(REASONING_REPLAY_PLACEHOLDER);
3260 substitutions = substitutions.saturating_add(1);
3261 logging::warn(format!(
3262 "Final sanitizer: forced reasoning_content placeholder on assistant[{idx}]",
3263 ));
3264 }
3265 if let Some(reasoning) = msg.get("reasoning_content").and_then(Value::as_str) {
3266 let len = reasoning.len() as u64;
3267 if len > 0 {
3268 replay_chars = replay_chars.saturating_add(len);
3269 replay_messages = replay_messages.saturating_add(1);
3270 }
3271 }
3272 }
3273 if substitutions > 0 {
3274 logging::warn(format!(
3275 "Final sanitizer: {substitutions} assistant message(s) needed reasoning_content placeholder",
3276 ));
3277 }
3278 if replay_messages == 0 {
3279 return None;
3280 }
3281 // ~4 chars/token is the standard rough estimate; DeepSeek tokens skew
3282 // a touch shorter on Chinese/code but this is order-of-magnitude info.
3283 let approx_tokens = (replay_chars / 4).min(u64::from(u32::MAX)) as u32;
3284 logging::info(format!(
3285 "Reasoning-content replay: {replay_messages} assistant message(s), ~{approx_tokens} input tokens ({replay_chars} chars) being re-sent in this request",
3286 ));
3287 Some(approx_tokens)
3288 }
3289
3290 /// Sums the byte length of `reasoning_content` across all assistant messages in
3291 /// an outgoing chat-completions body. Used by tests; the production sanitizer
3292 /// computes the same number inline and logs it.
3293 #[cfg(test)]
3294 pub(super) fn count_reasoning_replay_chars(body: &Value) -> u64 {
3295 let Some(messages) = body.get("messages").and_then(Value::as_array) else {
3296 return 0;
3297 };
3298 messages
3299 .iter()
3300 .filter(|m| m.get("role").and_then(Value::as_str) == Some("assistant"))
3301 .filter_map(|m| m.get("reasoning_content").and_then(Value::as_str))
3302 .map(|s| s.len() as u64)
3303 .sum()
3304 }
3305
3306 /// Render the transport-shape headers we care about for #103 diagnostics.
3307 /// Always returns SOMETHING printable so the decode-error log line is parseable
3308 /// even when the server stripped a header we expected.
3309 fn format_stream_headers(headers: &reqwest::header::HeaderMap) -> String {
3310 const FIELDS: &[&str] = &[
3311 "content-encoding",
3312 "transfer-encoding",
3313 "connection",
3314 "server",
3315 ];
3316 let mut parts: Vec<String> = Vec::with_capacity(FIELDS.len());
3317 for field in FIELDS {
3318 let rendered = headers
3319 .get(*field)
3320 .and_then(|v| v.to_str().ok())
3321 .unwrap_or("(absent)");
3322 parts.push(format!("{field}={rendered}"));
3323 }
3324 parts.join(", ")
3325 }
3326
3327 /// Diagnostic logger fired when DeepSeek rejects the request despite the
3328 /// sanitizer. Walks the body and logs which assistant messages have tool_calls
3329 /// but no `reasoning_content` — useful to track down a code path that bypasses
3330 /// the sanitizer entirely.
3331 fn log_thinking_mode_violations(body: &Value) {
3332 let Some(messages) = body.get("messages").and_then(Value::as_array) else {
3333 logging::warn("400-after-sanitizer: body has no `messages` array");
3334 return;
3335 };
3336 let mut violations: Vec<String> = Vec::new();
3337 for (idx, msg) in messages.iter().enumerate() {
3338 if msg.get("role").and_then(Value::as_str) != Some("assistant") {
3339 continue;
3340 }
3341 let reasoning = msg
3342 .get("reasoning_content")
3343 .and_then(Value::as_str)
3344 .unwrap_or("");
3345 let has_tc = msg.get("tool_calls").is_some();
3346 if reasoning.trim().is_empty() {
3347 violations.push(format!(
3348 "assistant[{idx}] (reasoning_content missing, tool_calls={has_tc})"
3349 ));
3350 }
3351 }
3352 if violations.is_empty() {
3353 logging::warn(
3354 "400-after-sanitizer: all assistant messages have reasoning_content — DeepSeek rejected for a different reason",
3355 );
3356 } else {
3357 logging::warn(format!(
3358 "400-after-sanitizer: {} assistant message(s) lack reasoning_content despite sanitizer: {}",
3359 violations.len(),
3360 violations.join(", ")
3361 ));
3362 }
3363 }
3364
3365 fn requires_reasoning_content(model: &str) -> bool {
3366 let lower = model.to_lowercase();
3367 // V4-family direct model IDs.
3368 lower.contains("deepseek-v4")
3369 // Public DeepSeek API aliases routed server-side to the V4 family.
3370 // `deepseek-chat` resolves to `deepseek-v4-flash` and `deepseek-reasoner`
3371 // resolves to `deepseek-v4-pro`; both have thinking mode enabled by
3372 // default, so any assistant message carrying tool_calls must replay
3373 // `reasoning_content` on subsequent turns or the API returns 400.
3374 || lower.starts_with("deepseek-chat")
3375 || lower.starts_with("deepseek-reasoner")
3376 || has_deepseek_r_series_marker(&lower)
3377 // #6044: the V4.1 official id dropped the version number entirely
3378 // (`deepseek-flash`), so the literal arms above cannot see it and
3379 // the decode/replay classifiers depended on a later catalog fallback
3380 // to catch it. The catalog owns the capability — consult it here so
3381 // every caller (stream style, wire replay, prompt inspection) agrees
3382 // without another hardcoded id.
3383 || (lower.starts_with("deepseek-") && model_supports_reasoning(model))
3384 }
3385
3386 fn should_replay_reasoning_content(model: &str, effort: Option<&str>) -> bool {
3387 if effort
3388 .map(|value| {
3389 matches!(
3390 value.trim().to_ascii_lowercase().as_str(),
3391 "off" | "disabled" | "none" | "false"
3392 )
3393 })
3394 .unwrap_or(false)
3395 {
3396 return false;
3397 }
3398
3399 requires_reasoning_content(model)
3400 }
3401
3402 #[cfg(test)]
3403 fn should_replay_reasoning_content_for_provider(
3404 provider: ApiProvider,
3405 model: &str,
3406 effort: Option<&str>,
3407 ) -> bool {
3408 should_replay_reasoning_content_for_provider_on_route(provider, "", model, effort)
3409 }
3410
3411 /// Route-aware reasoning replay policy.
3412 ///
3413 /// Keep the bare K3 identifier out of the global model catalog: direct
3414 /// Moonshot and arbitrary OpenAI-compatible routes can also expose a `k3`
3415 /// model name, but only Kimi Code's exact membership-plan endpoint has this
3416 /// replay contract.
3417 fn should_replay_reasoning_content_for_provider_on_route(
3418 provider: ApiProvider,
3419 base_url: &str,
3420 model: &str,
3421 effort: Option<&str>,
3422 ) -> bool {
3423 // Exact always-thinking routes replay their reasoning trace regardless of
3424 // a stale caller effort: the API contract requires the assistant
3425 // reasoning field on later tool turns for multi-turn continuity.
3426 if is_exact_direct_moonshot_k3_route(provider, base_url, model)
3427 || is_exact_kimi_code_k3_route(provider, base_url, model)
3428 || (is_exact_mistral_chat_route(provider, base_url)
3429 && mistral_model_has_native_reasoning(model))
3430 {
3431 return true;
3432 }
3433
3434 // The exact Model Studio route policy evaluates BEFORE any generic
3435 // model-name heuristic. Only models Alibaba documents as accepting
3436 // `preserve_thinking` replay historical `reasoning_content`, plus the
3437 // concrete DeepSeek V4 family ids whose own API contract requires the
3438 // reasoning field on tool turns. A model named `foo-thinking` or
3439 // `foo-reasoner` proves nothing about DashScope's request dialect and
3440 // must not gain replay here — replaying stale Thinking blocks feeds the
3441 // model its own past reasoning and re-triggers it every turn (observed
3442 // as a repeated handoff loop with the always-thinking qwen3.8 family).
3443 // Pi does not replay those either.
3444 if is_exact_modelstudio_chat_route(provider, base_url) {
3445 if modelstudio_model_supports_preserve_thinking(model) {
3446 // Thinking-only preserve models (kimi-k2.7-code) are
3447 // always-thinking and replay even with a stale `off`; hybrid
3448 // preserve models defer to the effort gate like every other
3449 // hybrid.
3450 if is_exact_modelstudio_thinking_only_route(provider, base_url, model)
3451 || !modelstudio_effort_disables_thinking(effort)
3452 {
3453 return true;
3454 }
3455 return false;
3456 }
3457 let lower = model.trim().to_ascii_lowercase();
3458 return lower.contains("deepseek-v4")
3459 || lower.starts_with("deepseek-chat")
3460 || lower.starts_with("deepseek-reasoner");
3461 }
3462
3463 if effort
3464 .map(|value| {
3465 matches!(
3466 value.trim().to_ascii_lowercase().as_str(),
3467 "off" | "disabled" | "none" | "false"
3468 )
3469 })
3470 .unwrap_or(false)
3471 {
3472 return false;
3473 }
3474
3475 if requires_reasoning_content(model) {
3476 return true;
3477 }
3478
3479 if is_exact_mistral_chat_route(provider, base_url)
3480 && mistral_model_has_adjustable_reasoning(model)
3481 {
3482 return true;
3483 }
3484
3485 if !provider_accepts_reasoning_content(provider) {
3486 // Generic non-DeepSeek model on a provider that rejects the field:
3487 // keep stripping it (preserves the #1542 fix). But a known DeepSeek
3488 // reasoning model pointed at a DeepSeek-compatible endpoint via the
3489 // generic `openai` provider still requires reasoning_content replay,
3490 // or the thinking-mode API returns 400 (#1739 / #1694).
3491 return false;
3492 }
3493
3494 model_supports_reasoning(model)
3495 }
3496
3497 /// Should the SSE parser treat incoming `reasoning_content` deltas as thinking
3498 /// (vs. inlining them as answer text)?
3499 ///
3500 /// DeepSeek-family models are classified on any provider because their API
3501 /// requires `reasoning_content` replay on later turns (#1739 / #1694). Other
3502 /// known reasoning-capable large models are classified only on providers whose
3503 /// streaming shape exposes reasoning fields, so `reasoning`/`reasoning_content`
3504 /// deltas become Thinking cells instead of leaking as normal answer text.
3505 #[cfg(test)]
3506 fn is_reasoning_model_for_stream(provider: ApiProvider, model: &str) -> bool {
3507 is_reasoning_model_for_stream_on_route(provider, "", model)
3508 }
3509
3510 /// Route-aware stream classification for providers that share model names.
3511 fn is_reasoning_model_for_stream_on_route(
3512 provider: ApiProvider,
3513 base_url: &str,
3514 model: &str,
3515 ) -> bool {
3516 if is_exact_kimi_code_k3_route(provider, base_url, model)
3517 || is_exact_direct_moonshot_k3_route(provider, base_url, model)
3518 {
3519 return true;
3520 }
3521
3522 if is_exact_modelstudio_chat_route(provider, base_url)
3523 && (modelstudio_model_is_thinking_only(model)
3524 || modelstudio_model_supports_preserve_thinking(model))
3525 {
3526 return true;
3527 }
3528
3529 if requires_reasoning_content(model) {
3530 return true;
3531 }
3532
3533 // Model Studio's OpenAI-compatible endpoints (Token Plan / Coding Plan)
3534 // stream hybrid-model reasoning as `delta.reasoning_content` (DashScope
3535 // dialect) whenever thinking is on — and for the qwen3.x families thinking
3536 // is on by server default. Surface those deltas as Thinking instead of
3537 // inlining them into the answer text. `reasoning_content` is deliberately
3538 // NOT replayed back on later turns (the provider is absent from
3539 // `provider_accepts_reasoning_content`): DashScope does not require the
3540 // reasoning field in request history.
3541 if matches!(
3542 provider,
3543 ApiProvider::ModelstudioTokenPlan
3544 | ApiProvider::ModelstudioTokenPlanAnthropic
3545 | ApiProvider::ModelstudioCodingPlan
3546 | ApiProvider::ModelstudioCodingPlanAnthropic
3547 ) && model_supports_reasoning(model)
3548 {
3549 return true;
3550 }
3551
3552 // ModelScope's OpenAI-compatible inference API streams hybrid-model
3553 // reasoning as `delta.reasoning_content` (the DashScope dialect) for the
3554 // Qwen and ZhipuAI families, whose model ids carry a `qwen/` or
3555 // `zhipuai/` namespace prefix. Surface those deltas as Thinking instead
3556 // of inlining them into the answer text. As with Model Studio above,
3557 // `reasoning_content` is deliberately NOT replayed back on later turns:
3558 // the provider is absent from `provider_accepts_reasoning_content`, and
3559 // the DashScope dialect does not require the reasoning field in history.
3560 if provider == ApiProvider::Modelscope {
3561 let lower = model.to_ascii_lowercase();
3562 if lower.starts_with("qwen/") || lower.starts_with("zhipuai/") {
3563 return true;
3564 }
3565 }
3566
3567 provider_accepts_reasoning_content(provider) && model_supports_reasoning(model)
3568 }
3569
3570 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
3571 pub(super) enum ReasoningStreamStyle {
3572 SeparateField,
3573 InlineTags,
3574 MistralBlocks,
3575 None,
3576 }
3577
3578 #[cfg(test)]
3579 fn reasoning_stream_style_for_stream(
3580 provider: ApiProvider,
3581 model: &str,
3582 configured: Option<&str>,
3583 ) -> ReasoningStreamStyle {
3584 reasoning_stream_style_for_route(provider, "", model, configured)
3585 }
3586
3587 /// Choose stream decoding semantics for a fully resolved provider route.
3588 fn reasoning_stream_style_for_route(
3589 provider: ApiProvider,
3590 base_url: &str,
3591 model: &str,
3592 configured: Option<&str>,
3593 ) -> ReasoningStreamStyle {
3594 if is_exact_mistral_chat_route(provider, base_url) && mistral_model_supports_reasoning(model) {
3595 return ReasoningStreamStyle::MistralBlocks;
3596 }
3597 if let Some(configured) = configured {
3598 if let Some(style) = parse_reasoning_stream_style(configured) {
3599 return style;
3600 }
3601 logging::warn(format!(
3602 "Ignoring unrecognized reasoning_stream_style `{configured}`; expected separate_field, inline_tags, or none"
3603 ));
3604 }
3605 if is_reasoning_model_for_stream_on_route(provider, base_url, model) {
3606 ReasoningStreamStyle::SeparateField
3607 } else {
3608 ReasoningStreamStyle::None
3609 }
3610 }
3611
3612 fn parse_reasoning_stream_style(value: &str) -> Option<ReasoningStreamStyle> {
3613 match value.trim().to_ascii_lowercase().replace('-', "_").as_str() {
3614 "separate_field" | "separate" | "field" => Some(ReasoningStreamStyle::SeparateField),
3615 "inline_tags" | "inline" | "think_tags" | "thinking_tags" => {
3616 Some(ReasoningStreamStyle::InlineTags)
3617 }
3618 "none" | "text" | "disabled" | "off" => Some(ReasoningStreamStyle::None),
3619 _ => None,
3620 }
3621 }
3622
3623 /// Providers whose chat-completions API both returns and accepts a dedicated
3624 /// `reasoning_content` field on assistant messages.
3625 ///
3626 /// Arcee is intentionally included. Trinity-Large-Thinking natively emits
3627 /// `<think>...</think>` traces, but Arcee's hosted API serves it through vLLM
3628 /// with `--reasoning-parser deepseek_r1`, which parses those blocks into a
3629 /// `reasoning_content` field (verified live against `api.arcee.ai`: thinking
3630 /// streams as `delta.reasoning_content`, the answer as `delta.content`, with no
3631 /// `<think>` tags on the wire). Arcee's docs require replaying `reasoning_content`
3632 /// on assistant tool-call turns; dropping it makes the model emit tool calls as
3633 /// raw XML inside its thinking ("xml_in_reasoning" pitfall). Do not remove Arcee
3634 /// here without new live evidence — see docs.arcee.ai/capabilities/reasoning-traces.
3635 fn provider_accepts_reasoning_content(provider: ApiProvider) -> bool {
3636 matches!(
3637 provider,
3638 ApiProvider::Deepseek
3639 | ApiProvider::DeepseekCN
3640 | ApiProvider::NvidiaNim
3641 | ApiProvider::Openrouter
3642 | ApiProvider::XiaomiMimo
3643 | ApiProvider::Novita
3644 | ApiProvider::Fireworks
3645 | ApiProvider::Siliconflow
3646 | ApiProvider::SiliconflowCn
3647 | ApiProvider::Volcengine
3648 | ApiProvider::Arcee
3649 | ApiProvider::Minimax
3650 | ApiProvider::Sglang
3651 | ApiProvider::Zai
3652 | ApiProvider::Moonshot // #3016: Kimi thinking traces use reasoning_content
3653 )
3654 }
3655
3656 fn has_deepseek_r_series_marker(model_lower: &str) -> bool {
3657 const PREFIX: &str = "deepseek-r";
3658 model_lower.match_indices(PREFIX).any(|(idx, _)| {
3659 model_lower[idx + PREFIX.len()..]
3660 .chars()
3661 .next()
3662 .is_some_and(|ch| ch.is_ascii_digit())
3663 })
3664 }
3665
3666 /// Transport-only reasoning replay placeholder. DeepSeek-family chat wires
3667 /// reject assistant tool-call messages with an empty `reasoning_content`, so
3668 /// the request serializer substitutes this string for replay only. Providers
3669 /// that mirror assistant history (GLM-5.x) stream the substituted field back
3670 /// as a live reasoning delta; ingest must drop that exact echo so a wire-only
3671 /// placeholder never becomes a persisted or displayed thinking block.
3672 pub(crate) const REASONING_REPLAY_PLACEHOLDER: &str = "(reasoning omitted)";
3673
3674 #[must_use]
3675 pub(crate) fn is_reasoning_replay_placeholder(text: &str) -> bool {
3676 text.trim() == REASONING_REPLAY_PLACEHOLDER
3677 }
3678
3679 fn reasoning_delta(
3680 value: &Value,
3681 choice_index: u32,
3682 reasoning_detail_buffers: &mut std::collections::HashMap<u32, String>,
3683 ) -> Option<String> {
3684 if let Some(reasoning) = value
3685 .get("reasoning_content")
3686 .or_else(|| value.get("reasoning"))
3687 .and_then(Value::as_str)
3688 {
3689 if is_reasoning_replay_placeholder(reasoning) {
3690 return None;
3691 }
3692 return Some(reasoning.to_string());
3693 }
3694
3695 let details = value.get("reasoning_details").and_then(Value::as_array)?;
3696 let full_text = details
3697 .iter()
3698 .filter_map(|detail| detail.get("text").and_then(Value::as_str))
3699 .collect::<String>();
3700 if full_text.is_empty() {
3701 return None;
3702 }
3703
3704 let previous = reasoning_detail_buffers.entry(choice_index).or_default();
3705 let delta = full_text
3706 .strip_prefix(previous.as_str())
3707 .unwrap_or(&full_text)
3708 .to_string();
3709 *previous = full_text;
3710 Some(delta)
3711 }
3712
3713 fn reasoning_message_text(value: &Value) -> Option<String> {
3714 if let Some(reasoning) = value
3715 .get("reasoning_content")
3716 .or_else(|| value.get("reasoning"))
3717 .and_then(Value::as_str)
3718 {
3719 if is_reasoning_replay_placeholder(reasoning) {
3720 return None;
3721 }
3722 return Some(reasoning.to_string());
3723 }
3724 value
3725 .get("reasoning_details")
3726 .and_then(Value::as_array)
3727 .map(|details| {
3728 details
3729 .iter()
3730 .filter_map(|detail| detail.get("text").and_then(Value::as_str))
3731 .collect::<String>()
3732 })
3733 }
3734
3735 #[cfg(test)]
3736 pub(super) fn parse_chat_message(payload: &Value) -> Result<MessageResponse> {
3737 parse_chat_message_for_route(payload, ApiProvider::Openai, "")
3738 }
3739
3740 fn parse_chat_message_for_route(
3741 payload: &Value,
3742 provider: ApiProvider,
3743 base_url: &str,
3744 ) -> Result<MessageResponse> {
3745 let id = payload
3746 .get("id")
3747 .and_then(Value::as_str)
3748 .unwrap_or("chatcmpl")
3749 .to_string();
3750 let model = payload
3751 .get("model")
3752 .and_then(Value::as_str)
3753 .unwrap_or("unknown")
3754 .to_string();
3755
3756 let choices = payload
3757 .get("choices")
3758 .and_then(Value::as_array)
3759 .context("Chat API response missing choices")?;
3760 let choice = choices
3761 .first()
3762 .context("Chat API response missing first choice")?;
3763 let message = choice
3764 .get("message")
3765 .context("Chat API response missing message")?;
3766
3767 let mut content_blocks = Vec::new();
3768 if let Some(reasoning) =
3769 reasoning_message_text(message).filter(|reasoning| !reasoning.trim().is_empty())
3770 {
3771 content_blocks.push(ContentBlock::Thinking {
3772 signature: None,
3773 state: None,
3774 thinking: reasoning.to_string(),
3775 });
3776 }
3777 let (mistral_thinking, mistral_text) = if is_exact_mistral_chat_route(provider, base_url) {
3778 extract_mistral_polymorphic_content(message)
3779 } else {
3780 (None, None)
3781 };
3782 if let Some(thinking) = mistral_thinking.filter(|s| !s.trim().is_empty()) {
3783 content_blocks.push(ContentBlock::Thinking {
3784 signature: None,
3785 state: None,
3786 thinking,
3787 });
3788 }
3789 if let Some(text) = mistral_text.filter(|s| !s.trim().is_empty()) {
3790 content_blocks.push(ContentBlock::Text {
3791 text,
3792 cache_control: None,
3793 });
3794 } else if let Some(text) = message.get("content").and_then(Value::as_str)
3795 && !text.trim().is_empty()
3796 {
3797 content_blocks.push(ContentBlock::Text {
3798 text: text.to_string(),
3799 cache_control: None,
3800 });
3801 }
3802
3803 if let Some(tool_calls) = message.get("tool_calls").and_then(Value::as_array) {
3804 for call in tool_calls {
3805 let id = call
3806 .get("id")
3807 .and_then(Value::as_str)
3808 .unwrap_or("tool_call")
3809 .to_string();
3810 let function = call.get("function");
3811 let name = tool_name_or_fallback(
3812 function.and_then(|f| f.get("name")).and_then(Value::as_str),
3813 &id,
3814 "Non-streaming response",
3815 );
3816 let arguments = function
3817 .and_then(|f| f.get("arguments"))
3818 .and_then(Value::as_str)
3819 .map(|raw| serde_json::from_str(raw).unwrap_or(Value::String(raw.to_string())))
3820 .unwrap_or(Value::Null);
3821 let caller = call.get("caller").and_then(|v| {
3822 v.get("type")
3823 .and_then(Value::as_str)
3824 .map(|caller_type| ToolCaller {
3825 caller_type: caller_type.to_string(),
3826 tool_id: v
3827 .get("tool_id")
3828 .and_then(Value::as_str)
3829 .map(std::string::ToString::to_string),
3830 })
3831 });
3832
3833 let thought_signature = call
3834 .pointer("/extra_content/google/thought_signature")
3835 .and_then(Value::as_str)
3836 .map(str::to_string);
3837 content_blocks.push(ContentBlock::ToolUse {
3838 id,
3839 name: from_api_tool_name(&name),
3840 input: arguments,
3841 caller,
3842 thought_signature,
3843 });
3844 }
3845 }
3846
3847 let usage = parse_usage(payload.get("usage"));
3848
3849 Ok(MessageResponse {
3850 id,
3851 r#type: "message".to_string(),
3852 role: "assistant".to_string(),
3853 content: content_blocks,
3854 model,
3855 stop_reason: choice
3856 .get("finish_reason")
3857 .and_then(Value::as_str)
3858 .map(str::to_string),
3859 stop_sequence: None,
3860 container: None,
3861 usage,
3862 })
3863 }
3864
3865 #[derive(Debug, Default)]
3866 struct InlineReasoningTagState {
3867 inside_think: bool,
3868 pending: String,
3869 }
3870
3871 #[derive(Debug, PartialEq, Eq)]
3872 enum ReasoningSegment {
3873 Text(String),
3874 Thinking(String),
3875 }
3876
3877 fn inline_reasoning_segments(
3878 content: &str,
3879 state: &mut InlineReasoningTagState,
3880 flush: bool,
3881 ) -> Vec<ReasoningSegment> {
3882 state.pending.push_str(content);
3883 let mut segments = Vec::new();
3884
3885 loop {
3886 if state.pending.is_empty() {
3887 break;
3888 }
3889
3890 if state.inside_think {
3891 if let Some(close_at) = state.pending.find("</think>") {
3892 push_reasoning_segment(
3893 &mut segments,
3894 ReasoningSegment::Thinking(state.pending[..close_at].to_string()),
3895 );
3896 state.pending.drain(..close_at + "</think>".len());
3897 state.inside_think = false;
3898 continue;
3899 }
3900
3901 let hold_len = if flush {
3902 0
3903 } else {
3904 trailing_tag_prefix_len(&state.pending, "</think>")
3905 };
3906 let emit_len = state.pending.len().saturating_sub(hold_len);
3907 if emit_len > 0 {
3908 push_reasoning_segment(
3909 &mut segments,
3910 ReasoningSegment::Thinking(state.pending[..emit_len].to_string()),
3911 );
3912 state.pending.drain(..emit_len);
3913 }
3914 break;
3915 }
3916
3917 if let Some(open_at) = state.pending.find("<think>") {
3918 push_reasoning_segment(
3919 &mut segments,
3920 ReasoningSegment::Text(state.pending[..open_at].to_string()),
3921 );
3922 state.pending.drain(..open_at + "<think>".len());
3923 state.inside_think = true;
3924 continue;
3925 }
3926
3927 let hold_len = if flush {
3928 0
3929 } else {
3930 trailing_tag_prefix_len(&state.pending, "<think>")
3931 };
3932 let emit_len = state.pending.len().saturating_sub(hold_len);
3933 if emit_len > 0 {
3934 push_reasoning_segment(
3935 &mut segments,
3936 ReasoningSegment::Text(state.pending[..emit_len].to_string()),
3937 );
3938 state.pending.drain(..emit_len);
3939 }
3940 break;
3941 }
3942
3943 segments
3944 }
3945
3946 fn trailing_tag_prefix_len(content: &str, tag: &str) -> usize {
3947 let max_len = tag.len().min(content.len());
3948 for len in (1..=max_len).rev() {
3949 let start = content.len() - len;
3950 if content.is_char_boundary(start) && tag.starts_with(&content[start..]) {
3951 return len;
3952 }
3953 }
3954 0
3955 }
3956
3957 fn push_reasoning_segment(segments: &mut Vec<ReasoningSegment>, segment: ReasoningSegment) {
3958 match &segment {
3959 ReasoningSegment::Text(text) | ReasoningSegment::Thinking(text) if text.is_empty() => {}
3960 _ => segments.push(segment),
3961 }
3962 }
3963
3964 fn push_text_delta(
3965 events: &mut Vec<StreamEvent>,
3966 content_index: &mut u32,
3967 text_started: &mut bool,
3968 thinking_started: &mut bool,
3969 text: String,
3970 ) {
3971 if *thinking_started {
3972 events.push(StreamEvent::ContentBlockStop {
3973 index: *content_index,
3974 });
3975 *content_index += 1;
3976 *thinking_started = false;
3977 }
3978 if !*text_started {
3979 events.push(StreamEvent::ContentBlockStart {
3980 index: *content_index,
3981 content_block: ContentBlockStart::Text {
3982 text: String::new(),
3983 },
3984 });
3985 *text_started = true;
3986 }
3987 events.push(StreamEvent::ContentBlockDelta {
3988 index: *content_index,
3989 delta: Delta::TextDelta { text },
3990 });
3991 }
3992
3993 fn push_thinking_delta(
3994 events: &mut Vec<StreamEvent>,
3995 content_index: &mut u32,
3996 text_started: &mut bool,
3997 thinking_started: &mut bool,
3998 thinking: String,
3999 ) {
4000 if *text_started {
4001 events.push(StreamEvent::ContentBlockStop {
4002 index: *content_index,
4003 });
4004 *content_index += 1;
4005 *text_started = false;
4006 }
4007 if !*thinking_started {
4008 events.push(StreamEvent::ContentBlockStart {
4009 index: *content_index,
4010 content_block: ContentBlockStart::Thinking {
4011 thinking: String::new(),
4012 },
4013 });
4014 *thinking_started = true;
4015 }
4016 events.push(StreamEvent::ContentBlockDelta {
4017 index: *content_index,
4018 delta: Delta::ThinkingDelta { thinking },
4019 });
4020 }
4021
4022 // === SSE Chunk Parser ===
4023
4024 enum SseDataFrame {
4025 Done,
4026 Events(Vec<StreamEvent>),
4027 }
4028
4029 // The six `&mut` streaming-state fields plus the style flag are a deliberate,
4030 // shared parser-state set (mirrored by `parse_sse_chunk*`); bundling them into a
4031 // struct would only add reborrow noise on this hot SSE path.
4032 #[allow(clippy::too_many_arguments)]
4033 fn parse_sse_data_frame(
4034 data: &str,
4035 content_index: &mut u32,
4036 text_started: &mut bool,
4037 thinking_started: &mut bool,
4038 tool_indices: &mut std::collections::HashMap<u32, u32>,
4039 reasoning_detail_buffers: &mut std::collections::HashMap<u32, String>,
4040 inline_reasoning_tags: &mut InlineReasoningTagState,
4041 reasoning_stream_style: ReasoningStreamStyle,
4042 ) -> SseDataFrame {
4043 if data.trim() == "[DONE]" {
4044 return SseDataFrame::Done;
4045 }
4046 let events = serde_json::from_str::<Value>(data).map_or_else(
4047 |_| Vec::new(),
4048 |chunk_json| {
4049 parse_sse_chunk_with_reasoning_style(
4050 &chunk_json,
4051 content_index,
4052 text_started,
4053 thinking_started,
4054 tool_indices,
4055 reasoning_detail_buffers,
4056 inline_reasoning_tags,
4057 reasoning_stream_style,
4058 )
4059 },
4060 );
4061 SseDataFrame::Events(events)
4062 }
4063
4064 /// Parse a single SSE chunk from the Chat Completions streaming API into
4065 /// our internal `StreamEvent` representation.
4066 #[cfg(test)]
4067 pub(super) fn parse_sse_chunk(
4068 chunk: &Value,
4069 content_index: &mut u32,
4070 text_started: &mut bool,
4071 thinking_started: &mut bool,
4072 tool_indices: &mut std::collections::HashMap<u32, u32>,
4073 reasoning_detail_buffers: &mut std::collections::HashMap<u32, String>,
4074 is_reasoning_model: bool,
4075 ) -> Vec<StreamEvent> {
4076 let mut inline_reasoning_tags = InlineReasoningTagState::default();
4077 let reasoning_stream_style = if is_reasoning_model {
4078 ReasoningStreamStyle::SeparateField
4079 } else {
4080 ReasoningStreamStyle::None
4081 };
4082 parse_sse_chunk_with_reasoning_style(
4083 chunk,
4084 content_index,
4085 text_started,
4086 thinking_started,
4087 tool_indices,
4088 reasoning_detail_buffers,
4089 &mut inline_reasoning_tags,
4090 reasoning_stream_style,
4091 )
4092 }
4093
4094 // Same deliberate shared parser-state set as `parse_sse_data_frame`.
4095 #[allow(clippy::too_many_arguments)]
4096 fn parse_sse_chunk_with_reasoning_style(
4097 chunk: &Value,
4098 content_index: &mut u32,
4099 text_started: &mut bool,
4100 thinking_started: &mut bool,
4101 tool_indices: &mut std::collections::HashMap<u32, u32>,
4102 reasoning_detail_buffers: &mut std::collections::HashMap<u32, String>,
4103 inline_reasoning_tags: &mut InlineReasoningTagState,
4104 reasoning_stream_style: ReasoningStreamStyle,
4105 ) -> Vec<StreamEvent> {
4106 let mut events = Vec::new();
4107
4108 // OpenAI-compatible providers surface mid-stream failures as a chunk-level
4109 // `error` object (sometimes with `type: "error"`), delivered before
4110 // `[DONE]`. Silently dropping it turned rate-limit / context-length /
4111 // server errors into a truncated turn that looked successful — the frame
4112 // is now surfaced through the same `StreamEvent::Error` contract the
4113 // Anthropic path uses (#3014, ops R3).
4114 if let Some(error) = chunk.get("error") {
4115 let error = match error {
4116 Value::Object(_) => error.clone(),
4117 Value::String(message) => serde_json::json!({ "message": message }),
4118 _ => serde_json::json!({ "message": "provider stream error" }),
4119 };
4120 events.push(StreamEvent::Error { error });
4121 return events;
4122 }
4123
4124 let Some(choices) = chunk.get("choices").and_then(Value::as_array) else {
4125 // Usage-only chunk (sent at end with stream_options)
4126 if let Some(usage_val) = chunk.get("usage") {
4127 let usage = parse_usage(Some(usage_val));
4128 events.push(StreamEvent::MessageDelta {
4129 delta: MessageDelta {
4130 stop_reason: None,
4131 stop_sequence: None,
4132 },
4133 usage: Some(usage),
4134 });
4135 }
4136 return events;
4137 };
4138
4139 if choices.is_empty() {
4140 if let Some(usage_val) = chunk.get("usage") {
4141 let usage = parse_usage(Some(usage_val));
4142 events.push(StreamEvent::MessageDelta {
4143 delta: MessageDelta {
4144 stop_reason: None,
4145 stop_sequence: None,
4146 },
4147 usage: Some(usage),
4148 });
4149 }
4150 return events;
4151 }
4152
4153 for choice in choices {
4154 let choice_index = choice.get("index").and_then(Value::as_u64).unwrap_or(0) as u32;
4155 let delta = choice.get("delta");
4156 let finish_reason = choice
4157 .get("finish_reason")
4158 .and_then(Value::as_str)
4159 .map(str::to_string);
4160
4161 if let Some(delta) = delta {
4162 let reasoning_text = reasoning_delta(delta, choice_index, reasoning_detail_buffers)
4163 .filter(|s| !s.is_empty());
4164 // Mistral la Plateforme streams reasoning as a polymorphic
4165 // `delta.content` value: an array of typed {type: thinking|text}
4166 // blocks while thinking, then a plain string once the final
4167 // answer starts. Flatten thinking sub-blocks into a single
4168 // reasoning delta and treat text sub-blocks as normal content
4169 // before the shared string fallback below.
4170 let (mistral_thinking, mistral_text) =
4171 if reasoning_stream_style == ReasoningStreamStyle::MistralBlocks {
4172 extract_mistral_polymorphic_content(delta)
4173 } else {
4174 (None, None)
4175 };
4176 if let Some(reasoning) = mistral_thinking.as_deref() {
4177 push_thinking_delta(
4178 &mut events,
4179 content_index,
4180 text_started,
4181 thinking_started,
4182 reasoning.to_string(),
4183 );
4184 }
4185 let content_text = mistral_text.or_else(|| {
4186 delta
4187 .get("content")
4188 .and_then(Value::as_str)
4189 .filter(|s| !s.is_empty())
4190 .map(str::to_string)
4191 });
4192
4193 // Handle reasoning_content / reasoning thinking deltas.
4194 if reasoning_stream_style == ReasoningStreamStyle::SeparateField
4195 && let Some(reasoning) = reasoning_text.as_deref()
4196 {
4197 push_thinking_delta(
4198 &mut events,
4199 content_index,
4200 text_started,
4201 thinking_started,
4202 reasoning.to_string(),
4203 );
4204 }
4205
4206 // Generic OpenAI-compatible proxies sometimes stream answer text
4207 // in `reasoning_content`. If this route is configured with no
4208 // reasoning semantics, render that field as normal text when no
4209 // `content` delta is present.
4210 match (content_text, reasoning_stream_style) {
4211 (Some(content), ReasoningStreamStyle::InlineTags) => {
4212 for segment in inline_reasoning_segments(&content, inline_reasoning_tags, false)
4213 {
4214 match segment {
4215 ReasoningSegment::Text(text) => push_text_delta(
4216 &mut events,
4217 content_index,
4218 text_started,
4219 thinking_started,
4220 text,
4221 ),
4222 ReasoningSegment::Thinking(thinking) => push_thinking_delta(
4223 &mut events,
4224 content_index,
4225 text_started,
4226 thinking_started,
4227 thinking,
4228 ),
4229 }
4230 }
4231 }
4232 (Some(content), _) => push_text_delta(
4233 &mut events,
4234 content_index,
4235 text_started,
4236 thinking_started,
4237 content,
4238 ),
4239 (None, ReasoningStreamStyle::None) => {
4240 if let Some(content) = reasoning_text {
4241 push_text_delta(
4242 &mut events,
4243 content_index,
4244 text_started,
4245 thinking_started,
4246 content,
4247 );
4248 }
4249 }
4250 (None, _) => {}
4251 }
4252
4253 // Handle tool calls
4254 if let Some(tool_calls) = delta.get("tool_calls").and_then(Value::as_array) {
4255 for tc in tool_calls {
4256 let tc_index = tc.get("index").and_then(Value::as_u64).unwrap_or(0) as u32;
4257 let tool_block_index = match tool_indices.entry(tc_index) {
4258 std::collections::hash_map::Entry::Occupied(entry) => *entry.get(),
4259 std::collections::hash_map::Entry::Vacant(entry) => {
4260 // Close text block if transitioning to tool use
4261 if *text_started {
4262 events.push(StreamEvent::ContentBlockStop {
4263 index: *content_index,
4264 });
4265 *content_index += 1;
4266 *text_started = false;
4267 }
4268 if *thinking_started {
4269 events.push(StreamEvent::ContentBlockStop {
4270 index: *content_index,
4271 });
4272 *content_index += 1;
4273 *thinking_started = false;
4274 }
4275
4276 let block_index = *content_index;
4277 let id = tc
4278 .get("id")
4279 .and_then(Value::as_str)
4280 .map(str::to_string)
4281 // Some upstream gateways (and the responses-API
4282 // bridge) elide the `id` on the first chunk of a
4283 // tool call. Falling back to a constant string
4284 // collides when the model emits parallel tool
4285 // calls in the same delta — every call ended up
4286 // with the same id and downstream tool-result
4287 // routing matched the first one twice. Index by
4288 // the content-block position to keep the
4289 // fallback unique within the response.
4290 .unwrap_or_else(|| format!("call_{block_index}"));
4291 let name = tc
4292 .get("function")
4293 .and_then(|f| f.get("name"))
4294 .and_then(Value::as_str);
4295 let name = tool_name_or_fallback(name, &id, "Streaming response chunk");
4296 let caller = tc.get("caller").and_then(|v| {
4297 v.get("type").and_then(Value::as_str).map(|caller_type| {
4298 ToolCaller {
4299 caller_type: caller_type.to_string(),
4300 tool_id: v
4301 .get("tool_id")
4302 .and_then(Value::as_str)
4303 .map(std::string::ToString::to_string),
4304 }
4305 })
4306 });
4307
4308 let thought_signature = tc
4309 .pointer("/extra_content/google/thought_signature")
4310 .and_then(Value::as_str)
4311 .map(str::to_string);
4312 events.push(StreamEvent::ContentBlockStart {
4313 index: block_index,
4314 content_block: ContentBlockStart::ToolUse {
4315 id,
4316 name: from_api_tool_name(&name),
4317 input: json!({}),
4318 caller,
4319 thought_signature,
4320 },
4321 });
4322 *content_index = (*content_index).saturating_add(1);
4323 entry.insert(block_index);
4324 block_index
4325 }
4326 };
4327
4328 // Stream tool call arguments
4329 if let Some(args) = tc
4330 .get("function")
4331 .and_then(|f| f.get("arguments"))
4332 .and_then(Value::as_str)
4333 && !args.is_empty()
4334 {
4335 events.push(StreamEvent::ContentBlockDelta {
4336 index: tool_block_index,
4337 delta: Delta::InputJsonDelta {
4338 partial_json: args.to_string(),
4339 },
4340 });
4341 }
4342 }
4343 }
4344 }
4345
4346 // Handle finish reason
4347 if let Some(reason) = finish_reason {
4348 if reasoning_stream_style == ReasoningStreamStyle::InlineTags {
4349 for segment in inline_reasoning_segments("", inline_reasoning_tags, true) {
4350 match segment {
4351 ReasoningSegment::Text(text) => push_text_delta(
4352 &mut events,
4353 content_index,
4354 text_started,
4355 thinking_started,
4356 text,
4357 ),
4358 ReasoningSegment::Thinking(thinking) => push_thinking_delta(
4359 &mut events,
4360 content_index,
4361 text_started,
4362 thinking_started,
4363 thinking,
4364 ),
4365 }
4366 }
4367 }
4368 // Close any open blocks
4369 if *text_started {
4370 events.push(StreamEvent::ContentBlockStop {
4371 index: *content_index,
4372 });
4373 *text_started = false;
4374 }
4375 if *thinking_started {
4376 events.push(StreamEvent::ContentBlockStop {
4377 index: *content_index,
4378 });
4379 *thinking_started = false;
4380 }
4381 // Close tool blocks
4382 let mut open_tool_indices: Vec<u32> =
4383 tool_indices.drain().map(|(_, idx)| idx).collect();
4384 open_tool_indices.sort_unstable();
4385 for tool_block_index in open_tool_indices {
4386 events.push(StreamEvent::ContentBlockStop {
4387 index: tool_block_index,
4388 });
4389 }
4390
4391 // Emit usage from the chunk if available
4392 let chunk_usage = chunk.get("usage").map(|u| parse_usage(Some(u)));
4393 events.push(StreamEvent::MessageDelta {
4394 delta: MessageDelta {
4395 stop_reason: Some(reason),
4396 stop_sequence: None,
4397 },
4398 usage: chunk_usage,
4399 });
4400 }
4401 }
4402
4403 events
4404 }
4405
4406 fn tool_name_or_fallback(name: Option<&str>, id: &str, source: &str) -> String {
4407 let trimmed = name.unwrap_or("").trim();
4408 if trimmed.is_empty() {
4409 logging::warn(format!(
4410 "{source} returned an empty tool name for call {id}; using unknown_tool"
4411 ));
4412 "unknown_tool".to_string()
4413 } else {
4414 trimmed.to_string()
4415 }
4416 }
4417
4418 // === #103 Phase 1: stream-decode diagnostics ===================================
4419
4420 #[cfg(test)]
4421 mod stream_diagnostics_tests {
4422 use super::*;
4423 use reqwest::header::{HeaderMap, HeaderValue};
4424
4425 #[test]
4426 fn stream_idle_timeout_reports_progress_and_timing() {
4427 let message = stream_idle_timeout_message(
4428 Duration::from_secs(240),
4429 8192,
4430 Duration::from_millis(73_500),
4431 Duration::from_millis(41_250),
4432 );
4433
4434 assert_eq!(
4435 message,
4436 "SSE stream idle timeout after 240s — no data received \
4437 (bytes_received=8192, stream_age_ms=73500, ms_since_last_chunk=41250)"
4438 );
4439 }
4440
4441 #[test]
4442 fn chat_completions_error_frames_surface_as_stream_events() {
4443 let mut content_index = 0u32;
4444 let mut text_started = false;
4445 let mut thinking_started = false;
4446 let mut tool_indices = std::collections::HashMap::new();
4447 let mut reasoning_buffers = std::collections::HashMap::new();
4448 for chunk in [
4449 json!({ "error": { "message": "rate limit exceeded", "type": "rate_limit_error" } }),
4450 json!({ "type": "error", "error": { "message": "context length exceeded" } }),
4451 json!({ "error": "server error" }),
4452 ] {
4453 let events = parse_sse_chunk(
4454 &chunk,
4455 &mut content_index,
4456 &mut text_started,
4457 &mut thinking_started,
4458 &mut tool_indices,
4459 &mut reasoning_buffers,
4460 false,
4461 );
4462 assert_eq!(
4463 events.len(),
4464 1,
4465 "a chunk-level error frame must not be swallowed ({chunk})"
4466 );
4467 match &events[0] {
4468 StreamEvent::Error { error } => assert!(
4469 !error.is_null()
4470 && (error.get("message").and_then(Value::as_str).is_some()
4471 || error.is_string()),
4472 "the provider error message must survive parsing: {error}"
4473 ),
4474 other => panic!("expected StreamEvent::Error, got {other:?}"),
4475 }
4476 }
4477 // A normal content chunk still parses as a delta after the error path.
4478 let deltas = parse_sse_chunk(
4479 &json!({"choices": [{"index": 0, "delta": {"content": "ok"}}]}),
4480 &mut content_index,
4481 &mut text_started,
4482 &mut thinking_started,
4483 &mut tool_indices,
4484 &mut reasoning_buffers,
4485 false,
4486 );
4487 assert!(
4488 deltas.iter().any(|event| {
4489 matches!(
4490 event,
4491 StreamEvent::ContentBlockDelta {
4492 delta: Delta::TextDelta { text }, ..
4493 } if text == "ok"
4494 )
4495 }),
4496 "content deltas still parse: {deltas:?}"
4497 );
4498 }
4499
4500 #[test]
4501 fn deepseek_thinking_omits_tool_choice() {
4502 for effort in [Some("high"), Some("max"), Some("medium"), Some("")] {
4503 assert!(
4504 !should_send_tool_choice_for_chat(ApiProvider::Deepseek, effort),
4505 "DeepSeek thinking rejects explicit tool_choice for {effort:?}"
4506 );
4507 assert!(
4508 !should_send_tool_choice_for_chat(ApiProvider::DeepseekCN, effort),
4509 "DeepSeek CN thinking rejects explicit tool_choice for {effort:?}"
4510 );
4511 }
4512
4513 for effort in [
4514 None,
4515 Some("off"),
4516 Some("disabled"),
4517 Some("none"),
4518 Some("false"),
4519 ] {
4520 assert!(should_send_tool_choice_for_chat(
4521 ApiProvider::Deepseek,
4522 effort
4523 ));
4524 }
4525 assert!(should_send_tool_choice_for_chat(
4526 ApiProvider::Openrouter,
4527 Some("high")
4528 ));
4529 }
4530
4531 #[test]
4532 fn format_stream_headers_renders_all_fields_when_present() {
4533 let mut headers = HeaderMap::new();
4534 headers.insert("content-encoding", HeaderValue::from_static("gzip"));
4535 headers.insert("transfer-encoding", HeaderValue::from_static("chunked"));
4536 headers.insert("connection", HeaderValue::from_static("keep-alive"));
4537 headers.insert("server", HeaderValue::from_static("openresty/1.25.3.1"));
4538
4539 let rendered = format_stream_headers(&headers);
4540 // Order is fixed by FIELDS in the helper; assert each field appears.
4541 assert!(
4542 rendered.contains("content-encoding=gzip"),
4543 "got: {rendered}"
4544 );
4545 assert!(
4546 rendered.contains("transfer-encoding=chunked"),
4547 "got: {rendered}"
4548 );
4549 assert!(
4550 rendered.contains("connection=keep-alive"),
4551 "got: {rendered}"
4552 );
4553 assert!(
4554 rendered.contains("server=openresty/1.25.3.1"),
4555 "got: {rendered}"
4556 );
4557 }
4558
4559 #[test]
4560 fn format_stream_headers_marks_missing_fields_as_absent() {
4561 // DeepSeek frequently omits content-encoding when not compressing.
4562 // The diagnostic must still produce a parseable line so log scrapers
4563 // don't lose the slot.
4564 let headers = HeaderMap::new();
4565 let rendered = format_stream_headers(&headers);
4566 assert!(
4567 rendered.contains("content-encoding=(absent)"),
4568 "missing field must be explicitly marked; got: {rendered}"
4569 );
4570 assert!(
4571 rendered.contains("transfer-encoding=(absent)"),
4572 "missing field must be explicitly marked; got: {rendered}"
4573 );
4574 }
4575
4576 #[test]
4577 fn format_stream_headers_handles_non_ascii_value_gracefully() {
4578 // If a header value isn't UTF-8, `.to_str()` fails — we must not panic
4579 // and should still produce a parseable line.
4580 let mut headers = HeaderMap::new();
4581 // 0xFF is a valid byte but invalid UTF-8 start byte.
4582 headers.insert(
4583 "server",
4584 HeaderValue::from_bytes(b"\xff\xfemystery").expect("header value"),
4585 );
4586 let rendered = format_stream_headers(&headers);
4587 assert!(
4588 rendered.contains("server=(absent)"),
4589 "non-UTF8 header values fall back to (absent); got: {rendered}"
4590 );
4591 }
4592 }
4593
4594 #[cfg(test)]
4595 mod arcee_waf_message_encoding_tests {
4596 use super::build_chat_messages_for_request_and_provider;
4597 use crate::config::ApiProvider;
4598 use codewhale_models::{MessageRequest, SystemPrompt};
4599 use serde_json::Value;
4600
4601 fn request_with_system(system: &str) -> MessageRequest {
4602 MessageRequest {
4603 model: "trinity-large-thinking".to_string(),
4604 messages: Vec::new(),
4605 max_tokens: 16,
4606 system: Some(SystemPrompt::Text(system.to_string())),
4607 tools: None,
4608 tool_choice: None,
4609 metadata: None,
4610 thinking: None,
4611 reasoning_effort: None,
4612 stream: None,
4613 temperature: None,
4614 top_p: None,
4615 }
4616 }
4617
4618 fn decoded_content(content: &Value) -> String {
4619 if let Some(text) = content.as_str() {
4620 return text.to_string();
4621 }
4622 content
4623 .as_array()
4624 .expect("content parts")
4625 .iter()
4626 .map(|part| part.get("text").and_then(Value::as_str).expect("text part"))
4627 .collect()
4628 }
4629
4630 #[test]
4631 fn arcee_splits_waf_trigger_without_changing_decoded_system_prompt() {
4632 let system = "Run calculations with `python -c 'print(1)'` when a tool is available.";
4633 let request = request_with_system(system);
4634
4635 let messages = build_chat_messages_for_request_and_provider(&request, ApiProvider::Arcee);
4636 let content = &messages[0]["content"];
4637
4638 assert!(
4639 content.is_array(),
4640 "Arcee system content with a WAF trigger should be encoded as text parts"
4641 );
4642 assert_eq!(decoded_content(content), system);
4643 let serialized = serde_json::to_string(&messages).expect("serialize messages");
4644 assert!(
4645 !serialized.contains("python -c"),
4646 "wire JSON should not contain the Cloudflare trigger contiguously: {serialized}"
4647 );
4648 }
4649
4650 #[test]
4651 fn non_arcee_providers_keep_system_prompt_as_string() {
4652 let system = "Run calculations with `python -c 'print(1)'` when a tool is available.";
4653 let request = request_with_system(system);
4654
4655 let messages = build_chat_messages_for_request_and_provider(&request, ApiProvider::Openai);
4656
4657 assert_eq!(messages[0]["content"].as_str(), Some(system));
4658 }
4659
4660 #[test]
4661 fn arcee_keeps_non_triggering_system_prompt_as_string() {
4662 let system = "Use read-only tools to inspect files before reporting results.";
4663 let request = request_with_system(system);
4664
4665 let messages = build_chat_messages_for_request_and_provider(&request, ApiProvider::Arcee);
4666
4667 assert_eq!(messages[0]["content"].as_str(), Some(system));
4668 }
4669 }
4670
4671 #[cfg(test)]
4672 mod minimax_reasoning_replay_tests {
4673 use super::{
4674 build_chat_messages_for_request_and_provider,
4675 build_chat_messages_for_request_and_provider_and_route,
4676 };
4677 use crate::config::{
4678 ApiProvider, DEFAULT_KIMI_CODE_BASE_URL, DEFAULT_MINIMAX_MODEL,
4679 DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL, DEFAULT_MOONSHOT_BASE_URL, KIMI_CODE_K3_MODEL,
4680 };
4681 use codewhale_models::Role;
4682 use codewhale_models::{ContentBlock, Message, MessageRequest};
4683
4684 fn request_with_assistant_thinking() -> MessageRequest {
4685 MessageRequest {
4686 model: DEFAULT_MINIMAX_MODEL.to_string(),
4687 messages: vec![Message {
4688 role: Role::Assistant,
4689 content: vec![
4690 ContentBlock::Thinking {
4691 thinking: "Inspect tool state".to_string(),
4692 signature: None,
4693 state: None,
4694 },
4695 ContentBlock::Text {
4696 text: "Done.".to_string(),
4697 cache_control: None,
4698 },
4699 ],
4700 }],
4701 max_tokens: 16,
4702 system: None,
4703 tools: None,
4704 tool_choice: None,
4705 metadata: None,
4706 thinking: None,
4707 reasoning_effort: None,
4708 stream: None,
4709 temperature: None,
4710 top_p: None,
4711 }
4712 }
4713
4714 #[test]
4715 fn minimax_history_replays_thinking_as_reasoning_details() {
4716 let request = request_with_assistant_thinking();
4717
4718 let messages = build_chat_messages_for_request_and_provider(&request, ApiProvider::Minimax);
4719 let assistant = &messages[0];
4720
4721 assert_eq!(
4722 assistant
4723 .get("reasoning_content")
4724 .and_then(|value| value.as_str()),
4725 Some("Inspect tool state")
4726 );
4727 assert_eq!(
4728 assistant
4729 .pointer("/reasoning_details/0/type")
4730 .and_then(|value| value.as_str()),
4731 Some("text")
4732 );
4733 assert_eq!(
4734 assistant
4735 .pointer("/reasoning_details/0/text")
4736 .and_then(|value| value.as_str()),
4737 Some("Inspect tool state")
4738 );
4739 }
4740
4741 #[test]
4742 fn kimi_code_k3_replays_thinking_only_on_the_exact_membership_route() {
4743 let mut request = request_with_assistant_thinking();
4744 request.model = KIMI_CODE_K3_MODEL.to_string();
4745
4746 let exact = build_chat_messages_for_request_and_provider_and_route(
4747 &request,
4748 ApiProvider::Moonshot,
4749 DEFAULT_KIMI_CODE_BASE_URL,
4750 );
4751 assert_eq!(
4752 exact[0]
4753 .get("reasoning_content")
4754 .and_then(serde_json::Value::as_str),
4755 Some("Inspect tool state")
4756 );
4757
4758 let neighbor = build_chat_messages_for_request_and_provider_and_route(
4759 &request,
4760 ApiProvider::Moonshot,
4761 DEFAULT_MOONSHOT_BASE_URL,
4762 );
4763 assert!(
4764 neighbor[0].get("reasoning_content").is_none(),
4765 "a generic Moonshot k3 identifier must not inherit Kimi Code replay"
4766 );
4767 }
4768
4769 #[test]
4770 fn modelstudio_qwen38_wire_body_replays_no_historical_reasoning_across_tool_loop() {
4771 // One user handoff, one assistant Thinking + Text + ToolUse turn, and
4772 // its matching ToolResult. On the exact Model Studio route the
4773 // always-thinking qwen3.8 family must not receive historical
4774 // `reasoning_content`, while the handoff occurs exactly once and the
4775 // tool call id, arguments, and result stay intact.
4776 let mut request = request_with_assistant_thinking();
4777 request.model = "qwen3.8-max".to_string();
4778 request.messages = vec![
4779 Message {
4780 role: Role::User,
4781 content: vec![ContentBlock::Text {
4782 text: "HANDOFF-SENTINEL: fix the widget.".to_string(),
4783 cache_control: None,
4784 }],
4785 },
4786 Message {
4787 role: Role::Assistant,
4788 content: vec![
4789 ContentBlock::Thinking {
4790 thinking: "stale thinking from the prior turn".to_string(),
4791 signature: None,
4792 state: None,
4793 },
4794 ContentBlock::Text {
4795 text: "I'll read the widget first.".to_string(),
4796 cache_control: None,
4797 },
4798 ContentBlock::ToolUse {
4799 id: "call_qwen38_001".to_string(),
4800 name: "read".to_string(),
4801 input: serde_json::json!({ "path": "widget.rs" }),
4802 caller: None,
4803 thought_signature: None,
4804 },
4805 ],
4806 },
4807 Message {
4808 role: Role::User,
4809 content: vec![ContentBlock::ToolResult {
4810 tool_use_id: "call_qwen38_001".to_string(),
4811 content: "widget.rs: struct Widget { .. }".to_string(),
4812 is_error: None,
4813 content_blocks: None,
4814 }],
4815 },
4816 ];
4817
4818 let messages = build_chat_messages_for_request_and_provider_and_route(
4819 &request,
4820 ApiProvider::ModelstudioTokenPlan,
4821 DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL,
4822 );
4823
4824 // The handoff occurs exactly once, as a plain user text message.
4825 let handoff_carriers: Vec<&serde_json::Value> = messages
4826 .iter()
4827 .filter(|message| {
4828 message.get("role").and_then(serde_json::Value::as_str) == Some("user")
4829 && message
4830 .get("content")
4831 .and_then(serde_json::Value::as_str)
4832 .is_some_and(|content| content.contains("HANDOFF-SENTINEL"))
4833 })
4834 .collect();
4835 assert_eq!(
4836 handoff_carriers.len(),
4837 1,
4838 "the handoff must appear in exactly one user message: {messages:?}"
4839 );
4840
4841 // The assistant turn keeps its text and tool call, but no historical
4842 // reasoning_content for qwen3.8.
4843 let assistant = messages
4844 .iter()
4845 .find(|message| {
4846 message.get("role").and_then(serde_json::Value::as_str) == Some("assistant")
4847 })
4848 .expect("assistant message");
4849 assert_eq!(
4850 assistant.get("content").and_then(serde_json::Value::as_str),
4851 Some("I'll read the widget first.")
4852 );
4853 assert!(
4854 assistant.get("reasoning_content").is_none(),
4855 "qwen3.8 must not receive historical reasoning_content: {assistant:?}"
4856 );
4857 let tool_calls = assistant
4858 .get("tool_calls")
4859 .and_then(serde_json::Value::as_array)
4860 .expect("tool_calls array");
4861 assert_eq!(tool_calls.len(), 1);
4862 assert_eq!(tool_calls[0]["id"], serde_json::json!("call_qwen38_001"));
4863 assert_eq!(
4864 tool_calls[0].pointer("/function/name"),
4865 Some(&serde_json::json!("read"))
4866 );
4867 assert_eq!(
4868 tool_calls[0].pointer("/function/arguments"),
4869 Some(&serde_json::json!(r#"{"path":"widget.rs"}"#))
4870 );
4871
4872 // The matching tool result rides along under its original id.
4873 let tool_result = messages
4874 .iter()
4875 .find(|message| message.get("role").and_then(serde_json::Value::as_str) == Some("tool"))
4876 .expect("tool result message");
4877 assert_eq!(
4878 tool_result.get("tool_call_id"),
4879 Some(&serde_json::json!("call_qwen38_001"))
4880 );
4881 assert_eq!(
4882 tool_result
4883 .get("content")
4884 .and_then(serde_json::Value::as_str),
4885 Some("widget.rs: struct Widget { .. }")
4886 );
4887
4888 // Control: the same handoff/tool loop with a documented
4889 // preserve-thinking model replays its historical reasoning, proving
4890 // the strip above is model-gated, not route-gated.
4891 let mut preserve_request = request;
4892 preserve_request.model = "qwen3.7-plus".to_string();
4893 let preserve_messages = build_chat_messages_for_request_and_provider_and_route(
4894 &preserve_request,
4895 ApiProvider::ModelstudioTokenPlan,
4896 DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL,
4897 );
4898 let preserve_assistant = preserve_messages
4899 .iter()
4900 .find(|message| {
4901 message.get("role").and_then(serde_json::Value::as_str) == Some("assistant")
4902 })
4903 .expect("assistant message");
4904 assert_eq!(
4905 preserve_assistant
4906 .get("reasoning_content")
4907 .and_then(serde_json::Value::as_str),
4908 Some("stale thinking from the prior turn"),
4909 "documented preserve-thinking models keep replaying history"
4910 );
4911 }
4912 }
4913
4914 // === #103 Phase 4: SSE decoder behavior on canned chunk sequences ============
4915
4916 #[cfg(test)]
4917 #[path = "chat/tests/stream_decoder.rs"]
4918 mod stream_decoder_tests;
4919
4920 #[cfg(test)]
4921 mod alias_thinking_detection_tests {
4922 //! Regression coverage for the DeepSeek public model aliases.
4923 //!
4924 //! `deepseek-chat` and `deepseek-reasoner` are the canonical alias names
4925 //! published in DeepSeek's API docs. Server-side they resolve to V4-flash
4926 //! and V4-pro respectively, both of which have thinking mode enabled by
4927 //! default. If the TUI does not classify those aliases as reasoning
4928 //! models, the sanitizer skips replaying `reasoning_content` on tool-call
4929 //! assistant messages and DeepSeek returns a 400 ("the `reasoning_content`
4930 //! in the thinking mode must be passed back to the API") on the second
4931 //! turn. See upstream API docs:
4932 //! <https://api-docs.deepseek.com/guides/thinking_mode>
4933 use super::{
4934 ReasoningStreamStyle, apply_direct_moonshot_k3_fixed_sampling,
4935 apply_inkling_reasoning_effort, apply_kimi_code_fixed_sampling,
4936 apply_kimi_code_k3_reasoning_effort, apply_openai_reasoning_effort,
4937 apply_provider_token_limit, apply_route_reasoning_controls, is_reasoning_model_for_stream,
4938 is_reasoning_model_for_stream_on_route, provider_accepts_reasoning_content,
4939 reasoning_stream_style_for_route, requires_reasoning_content,
4940 should_replay_reasoning_content, should_replay_reasoning_content_for_provider,
4941 should_replay_reasoning_content_for_provider_on_route,
4942 };
4943 use crate::config::ApiProvider;
4944 use serde_json::json;
4945
4946 #[test]
4947 fn aliases_routed_to_v4_require_reasoning_content() {
4948 // Documented public aliases.
4949 assert!(requires_reasoning_content("deepseek-chat"));
4950 assert!(requires_reasoning_content("deepseek-reasoner"));
4951 // Case-insensitive: users sometimes copy/paste with capitalisation.
4952 assert!(requires_reasoning_content("DeepSeek-Chat"));
4953 assert!(requires_reasoning_content("DEEPSEEK-REASONER"));
4954 }
4955
4956 #[test]
4957 fn explicit_v4_ids_still_require_reasoning_content() {
4958 // Direct V4 IDs continue to match (regression guard for the existing
4959 // `lower.contains("deepseek-v4")` branch).
4960 assert!(requires_reasoning_content("deepseek-v4-flash"));
4961 assert!(requires_reasoning_content("deepseek-v4-pro"));
4962 }
4963
4964 #[test]
4965 fn non_thinking_aliases_remain_excluded() {
4966 // Legacy non-thinking IDs and unrelated provider models must not be
4967 // misclassified, otherwise we would force a placeholder
4968 // `reasoning_content` on providers that reject the field.
4969 assert!(!requires_reasoning_content("deepseek-v3"));
4970 assert!(!requires_reasoning_content("deepseek-coder"));
4971 assert!(!requires_reasoning_content("qwen3-coder"));
4972 assert!(!requires_reasoning_content("claude-sonnet-4-6"));
4973 }
4974
4975 #[test]
4976 fn alias_prefix_handles_suffixed_variants() {
4977 // OpenRouter / proxy deployments occasionally suffix the canonical
4978 // alias (e.g. `deepseek-chat:free`). Those routes still hit V4
4979 // server-side, so they must continue to require reasoning_content.
4980 assert!(requires_reasoning_content("deepseek-chat:free"));
4981 assert!(requires_reasoning_content("deepseek-reasoner-2025-05"));
4982 }
4983
4984 #[test]
4985 fn explicit_reasoning_off_overrides_alias_detection() {
4986 // `reasoning_effort = "off"` is the documented escape hatch: even when
4987 // the model is in the thinking family, the user can opt out and the
4988 // sanitizer must respect that choice.
4989 assert!(!should_replay_reasoning_content(
4990 "deepseek-chat",
4991 Some("off")
4992 ));
4993 assert!(!should_replay_reasoning_content(
4994 "deepseek-reasoner",
4995 Some("disabled")
4996 ));
4997 // Without an explicit override, alias models still trigger replay.
4998 assert!(should_replay_reasoning_content("deepseek-chat", None));
4999 assert!(should_replay_reasoning_content(
5000 "deepseek-reasoner",
5001 Some("medium")
5002 ));
5003 }
5004
5005 #[test]
5006 fn generic_openai_provider_does_not_accept_reasoning_content_semantics() {
5007 assert!(!provider_accepts_reasoning_content(ApiProvider::Openai));
5008 assert!(provider_accepts_reasoning_content(ApiProvider::Deepseek));
5009 assert!(provider_accepts_reasoning_content(ApiProvider::NvidiaNim));
5010 assert!(provider_accepts_reasoning_content(ApiProvider::XiaomiMimo));
5011 assert!(provider_accepts_reasoning_content(ApiProvider::Arcee));
5012 assert!(provider_accepts_reasoning_content(ApiProvider::Minimax));
5013 assert!(provider_accepts_reasoning_content(ApiProvider::Zai));
5014 // #3016: Moonshot's native endpoint streams Kimi thinking as
5015 // reasoning_content.
5016 assert!(provider_accepts_reasoning_content(ApiProvider::Moonshot));
5017 }
5018
5019 /// Alibaba's classic pay-as-you-go DashScope endpoints are genuine
5020 /// Alibaba Chat Completions hosts serving the same models; before
5021 /// 2026-08-04 they were missing from the verifier allowlist, so every
5022 /// reasoning control was silently stripped there (fail-closed feature
5023 /// loss, not a leak). The intl spelling matches provider_defaults.
5024 #[test]
5025 fn classic_dashscope_hosts_are_verified_modelstudio_chat_routes() {
5026 for base_url in [
5027 "https://dashscope.aliyuncs.com/compatible-mode/v1",
5028 "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
5029 "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/",
5030 ] {
5031 assert!(
5032 super::is_exact_modelstudio_chat_route(ApiProvider::ModelstudioTokenPlan, base_url),
5033 "{base_url}"
5034 );
5035 let mut body = json!({});
5036 apply_route_reasoning_controls(
5037 &mut body,
5038 ApiProvider::ModelstudioTokenPlan,
5039 base_url,
5040 "qwen3.7-plus",
5041 Some("off"),
5042 );
5043 assert_eq!(body["enable_thinking"], json!(false), "{base_url}: {body}");
5044 }
5045 // Lookalike hosts stay unverified — fail closed.
5046 for base_url in [
5047 "https://dashscope.aliyuncs.com.evil.example/compatible-mode/v1",
5048 "https://notdashscope.aliyuncs.com/compatible-mode/v1",
5049 "https://dashscope.aliyuncs.com/other-path/v1",
5050 ] {
5051 assert!(
5052 !super::is_exact_modelstudio_chat_route(
5053 ApiProvider::ModelstudioTokenPlan,
5054 base_url
5055 ),
5056 "{base_url}"
5057 );
5058 }
5059 }
5060
5061 #[test]
5062 fn modelstudio_hybrid_routes_send_documented_thinking_controls() {
5063 let base_url = crate::config::DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL;
5064 for (effort, enabled) in [
5065 (None, true),
5066 (Some("low"), true),
5067 (Some("high"), true),
5068 (Some("xhigh"), true),
5069 (Some("off"), false),
5070 ] {
5071 let mut body = json!({});
5072 apply_route_reasoning_controls(
5073 &mut body,
5074 ApiProvider::ModelstudioTokenPlan,
5075 base_url,
5076 "qwen3.7-plus",
5077 effort,
5078 );
5079
5080 assert_eq!(body["enable_thinking"], json!(enabled), "{effort:?}");
5081 assert_eq!(body["preserve_thinking"], json!(enabled), "{effort:?}");
5082 assert!(body.get("thinking").is_none(), "{effort:?}: {body}");
5083 assert!(body.get("reasoning_effort").is_none(), "{effort:?}: {body}");
5084 }
5085 }
5086
5087 #[test]
5088 fn modelstudio_deepseek_v4_maps_effort_to_documented_values() {
5089 let base_url = crate::config::DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL;
5090 for (requested, expected) in [("low", "high"), ("high", "high"), ("xhigh", "max")] {
5091 let mut body = json!({});
5092 apply_route_reasoning_controls(
5093 &mut body,
5094 ApiProvider::ModelstudioTokenPlan,
5095 base_url,
5096 "deepseek-v4-pro",
5097 Some(requested),
5098 );
5099
5100 assert_eq!(body["enable_thinking"], json!(true), "{requested}");
5101 assert_eq!(body["reasoning_effort"], json!(expected), "{requested}");
5102 }
5103 }
5104
5105 #[test]
5106 fn modelstudio_reasoning_controls_fail_closed_on_custom_gateways() {
5107 let mut body = json!({
5108 "enable_thinking": true,
5109 "preserve_thinking": true,
5110 "reasoning_effort": "high",
5111 });
5112 apply_route_reasoning_controls(
5113 &mut body,
5114 ApiProvider::ModelstudioTokenPlan,
5115 "https://proxy.example/v1",
5116 "qwen3.7-plus",
5117 Some("high"),
5118 );
5119
5120 assert!(body.get("enable_thinking").is_none());
5121 assert!(body.get("preserve_thinking").is_none());
5122 assert!(body.get("reasoning_effort").is_none());
5123 }
5124
5125 #[test]
5126 fn modelstudio_anthropic_identities_write_nothing_on_the_chat_path() {
5127 // The Messages adapter owns these two. If `wire = "openai"` ever routes
5128 // them through Chat Completions, the shaper must strip rather than
5129 // inherit the OpenAI-dialect fields — there is no provider-enum writer
5130 // left to re-add them.
5131 for provider in [
5132 ApiProvider::ModelstudioTokenPlanAnthropic,
5133 ApiProvider::ModelstudioCodingPlanAnthropic,
5134 ] {
5135 for base_url in [
5136 crate::config::DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL,
5137 crate::config::MODELSTUDIO_TOKEN_PLAN_ANTHROPIC_BASE_URL,
5138 ] {
5139 let mut body = json!({ "enable_thinking": true });
5140 apply_route_reasoning_controls(
5141 &mut body,
5142 provider,
5143 base_url,
5144 "qwen3.7-plus",
5145 Some("high"),
5146 );
5147 assert_eq!(body, json!({}), "{provider:?} {base_url}");
5148 }
5149 }
5150 }
5151
5152 #[test]
5153 fn modelstudio_qwen38_route_streams_reasoning_without_replaying_history() {
5154 let base_url = crate::config::DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL;
5155 for model in ["qwen3.8-max", "qwen3.8-max-preview"] {
5156 // qwen3.8 is thinking-only. Effort selection must never hide its
5157 // separate reasoning stream, including the stale `off` state
5158 // that can arrive before route normalization.
5159 assert_eq!(
5160 reasoning_stream_style_for_route(
5161 ApiProvider::ModelstudioTokenPlan,
5162 base_url,
5163 model,
5164 None,
5165 ),
5166 ReasoningStreamStyle::SeparateField,
5167 "{model}"
5168 );
5169 // ...but Alibaba does not document `preserve_thinking` for the
5170 // qwen3.8 family, so no historical `reasoning_content` may be
5171 // replayed — even when a stale effort claims thinking is off.
5172 // Replaying it feeds the model its own past Thinking blocks and
5173 // re-triggers them every turn (observed handoff loop).
5174 for effort in [None, Some("off"), Some("high"), Some("xhigh")] {
5175 assert!(
5176 !should_replay_reasoning_content_for_provider_on_route(
5177 ApiProvider::ModelstudioTokenPlan,
5178 base_url,
5179 model,
5180 effort,
5181 ),
5182 "{model} {effort:?}"
5183 );
5184 }
5185 // ...and no enable/disable or preserve switch is ever sent for
5186 // them.
5187 for effort in [None, Some("off"), Some("high")] {
5188 let mut body = json!({});
5189 apply_route_reasoning_controls(
5190 &mut body,
5191 ApiProvider::ModelstudioTokenPlan,
5192 base_url,
5193 model,
5194 effort,
5195 );
5196 assert!(body.get("enable_thinking").is_none(), "{model}: {body}");
5197 assert!(
5198 body.get("preserve_thinking").is_none(),
5199 "{model} {effort:?}: {body}"
5200 );
5201 assert!(body.get("reasoning_effort").is_none(), "{model}: {body}");
5202 }
5203 }
5204 }
5205
5206 #[test]
5207 fn modelstudio_hybrid_route_classifies_reasoning_and_replays_history() {
5208 let base_url = crate::config::DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL;
5209 assert_eq!(
5210 reasoning_stream_style_for_route(
5211 ApiProvider::ModelstudioTokenPlan,
5212 base_url,
5213 "qwen3.7-plus",
5214 None,
5215 ),
5216 ReasoningStreamStyle::SeparateField,
5217 );
5218 assert!(should_replay_reasoning_content_for_provider_on_route(
5219 ApiProvider::ModelstudioTokenPlan,
5220 base_url,
5221 "qwen3.7-plus",
5222 None,
5223 ));
5224 assert!(!should_replay_reasoning_content_for_provider_on_route(
5225 ApiProvider::ModelstudioTokenPlan,
5226 base_url,
5227 "qwen3.7-plus",
5228 Some("off"),
5229 ));
5230 }
5231
5232 #[test]
5233 fn modelstudio_replay_stays_narrow_until_a_live_key_confirms_it() {
5234 // Deliberately narrower than PR #5233: only `preserve_thinking` models
5235 // replay. GLM and DeepSeek-V3.x on Model Studio stay stripped until
5236 // someone with a key confirms DashScope accepts `reasoning_content` in
5237 // input messages. deepseek-v4* is unaffected — it replays through
5238 // `requires_reasoning_content` on every provider.
5239 let base_url = crate::config::DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL;
5240 for model in ["glm-5.2", "deepseek-v3.2", "deepseek-v3.1"] {
5241 assert!(
5242 !should_replay_reasoning_content_for_provider_on_route(
5243 ApiProvider::ModelstudioTokenPlan,
5244 base_url,
5245 model,
5246 None,
5247 ),
5248 "{model}"
5249 );
5250 }
5251 assert!(should_replay_reasoning_content_for_provider_on_route(
5252 ApiProvider::ModelstudioTokenPlan,
5253 base_url,
5254 "deepseek-v4-pro",
5255 None,
5256 ));
5257 }
5258
5259 #[test]
5260 fn modelstudio_coding_plan_chat_route_is_classified_for_all_supported_identities() {
5261 // The picker represents Coding Plan as mode = "coding-plan" under
5262 // the primary provider id, so the chat client receives
5263 // ModelstudioTokenPlan with the Coding Plan URL. Direct configuration
5264 // also retains the legacy ModelstudioCodingPlan identity.
5265 let base_url = crate::config::DEFAULT_MODELSTUDIO_CODING_PLAN_BASE_URL;
5266 for provider in [
5267 ApiProvider::ModelstudioTokenPlan,
5268 ApiProvider::ModelstudioCodingPlan,
5269 ] {
5270 let mut body = json!({});
5271 apply_route_reasoning_controls(
5272 &mut body,
5273 provider,
5274 base_url,
5275 "qwen3.7-plus",
5276 Some("high"),
5277 );
5278
5279 assert_eq!(body["enable_thinking"], json!(true), "{provider:?}");
5280 assert_eq!(body["preserve_thinking"], json!(true), "{provider:?}");
5281 assert_eq!(
5282 reasoning_stream_style_for_route(provider, base_url, "qwen3.7-plus", None),
5283 ReasoningStreamStyle::SeparateField,
5284 "{provider:?}",
5285 );
5286 assert!(should_replay_reasoning_content_for_provider_on_route(
5287 provider,
5288 base_url,
5289 "qwen3.7-plus",
5290 None,
5291 ));
5292 }
5293 }
5294
5295 #[test]
5296 fn modelstudio_workspace_scoped_token_plan_route_is_recognized() {
5297 let workspace_url =
5298 "https://workspace-123.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1";
5299 // Stream classification works on the workspace-scoped host...
5300 assert_eq!(
5301 reasoning_stream_style_for_route(
5302 ApiProvider::ModelstudioTokenPlan,
5303 workspace_url,
5304 "qwen3.8-max",
5305 None,
5306 ),
5307 ReasoningStreamStyle::SeparateField,
5308 );
5309 assert_eq!(
5310 reasoning_stream_style_for_route(
5311 ApiProvider::ModelstudioTokenPlan,
5312 workspace_url,
5313 "qwen3.7-plus",
5314 None,
5315 ),
5316 ReasoningStreamStyle::SeparateField,
5317 );
5318 // ...and the route gate decides replay the same way it does on the
5319 // default host: qwen3.8 never replays, documented preserve models do.
5320 assert!(!should_replay_reasoning_content_for_provider_on_route(
5321 ApiProvider::ModelstudioTokenPlan,
5322 workspace_url,
5323 "qwen3.8-max",
5324 None,
5325 ));
5326 assert!(should_replay_reasoning_content_for_provider_on_route(
5327 ApiProvider::ModelstudioTokenPlan,
5328 workspace_url,
5329 "qwen3.7-plus",
5330 None,
5331 ));
5332 let mut body = json!({});
5333 apply_route_reasoning_controls(
5334 &mut body,
5335 ApiProvider::ModelstudioTokenPlan,
5336 workspace_url,
5337 "qwen3.7-plus",
5338 Some("high"),
5339 );
5340 assert_eq!(body["preserve_thinking"], json!(true), "{body}");
5341 }
5342
5343 #[test]
5344 fn modelstudio_thinking_named_unknown_model_cannot_bypass_the_route_gate() {
5345 // An unknown Model Studio model whose *name* suggests reasoning must
5346 // not gain replay: only the documented `preserve_thinking` list (plus
5347 // the concrete DeepSeek V4 family ids) authorizes historical
5348 // `reasoning_content` on exact Model Studio routes. The generic
5349 // `-thinking`/`reasoner` heuristics prove nothing about DashScope's
5350 // request dialect.
5351 let base_url = crate::config::DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL;
5352 for model in [
5353 "foo-thinking",
5354 "foo-reasoner",
5355 "new-model-reasoning",
5356 "acme-reasoner-v2",
5357 ] {
5358 assert!(
5359 !should_replay_reasoning_content_for_provider_on_route(
5360 ApiProvider::ModelstudioTokenPlan,
5361 base_url,
5362 model,
5363 None,
5364 ),
5365 "{model}"
5366 );
5367 // The shaper writes no reasoning controls for it either — fail
5368 // closed on the wire.
5369 let mut body = json!({ "enable_thinking": true, "preserve_thinking": true });
5370 apply_route_reasoning_controls(
5371 &mut body,
5372 ApiProvider::ModelstudioTokenPlan,
5373 base_url,
5374 model,
5375 Some("high"),
5376 );
5377 assert!(body.get("enable_thinking").is_none(), "{model}: {body}");
5378 assert!(body.get("preserve_thinking").is_none(), "{model}: {body}");
5379 }
5380 // A suggestive name is not a replay contract on any route.
5381 assert!(!requires_reasoning_content("foo-thinking"));
5382 assert!(!requires_reasoning_content("foo-reasoner"));
5383 }
5384
5385 #[test]
5386 fn modelstudio_qwen36_flash_preserves_thinking_per_current_documentation() {
5387 // Current Alibaba documentation explicitly includes qwen3.6-flash
5388 // (and its documented snapshots) among the `preserve_thinking`
5389 // models. Keep positive coverage so any future narrowing of the
5390 // preserve list has to remove it deliberately.
5391 let base_url = crate::config::DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL;
5392 for model in ["qwen3.6-flash", "qwen3.6-flash-2026-04-16"] {
5393 assert!(should_replay_reasoning_content_for_provider_on_route(
5394 ApiProvider::ModelstudioTokenPlan,
5395 base_url,
5396 model,
5397 None,
5398 ));
5399 let mut body = json!({});
5400 apply_route_reasoning_controls(
5401 &mut body,
5402 ApiProvider::ModelstudioTokenPlan,
5403 base_url,
5404 model,
5405 Some("high"),
5406 );
5407 assert_eq!(body["enable_thinking"], json!(true), "{model}");
5408 assert_eq!(body["preserve_thinking"], json!(true), "{model}: {body}");
5409 // Hybrid semantics: an explicit off disables both, and replay
5410 // follows the effort gate.
5411 assert!(!should_replay_reasoning_content_for_provider_on_route(
5412 ApiProvider::ModelstudioTokenPlan,
5413 base_url,
5414 model,
5415 Some("off"),
5416 ));
5417 let mut off_body = json!({});
5418 apply_route_reasoning_controls(
5419 &mut off_body,
5420 ApiProvider::ModelstudioTokenPlan,
5421 base_url,
5422 model,
5423 Some("off"),
5424 );
5425 assert_eq!(off_body["enable_thinking"], json!(false), "{model}");
5426 assert_eq!(off_body["preserve_thinking"], json!(false), "{model}");
5427 }
5428
5429 for invented in [
5430 "qwen3.6-flash-future",
5431 "qwen3.7-plus-proxy",
5432 "kimi-k2.7-code-unverified",
5433 ] {
5434 assert!(
5435 !should_replay_reasoning_content_for_provider_on_route(
5436 ApiProvider::ModelstudioTokenPlan,
5437 base_url,
5438 invented,
5439 None,
5440 ),
5441 "prefix lookalikes must fail closed: {invented}"
5442 );
5443 }
5444 }
5445
5446 #[test]
5447 fn modelstudio_kimi_k27_code_is_thinking_only_and_preserves_trace() {
5448 // NOTE: unlike the qwen3.8 pair, this classification is asserted by
5449 // PR #5233 rather than corroborated by models_dev.bundled.json, which
5450 // lists kimi-k2.7-code with `reasoning: true` and no `always_on`.
5451 let base_url = crate::config::DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL;
5452 for model in [
5453 "kimi-k2.7-code",
5454 "kimi/kimi-k2.7-code",
5455 "kimi/kimi-k2.7-code-highspeed",
5456 ] {
5457 let mut body = json!({});
5458 apply_route_reasoning_controls(
5459 &mut body,
5460 ApiProvider::ModelstudioTokenPlan,
5461 base_url,
5462 model,
5463 Some("off"),
5464 );
5465
5466 assert!(body.get("enable_thinking").is_none(), "{model}: {body}");
5467 assert_eq!(body["preserve_thinking"], json!(true), "{model}");
5468 assert_eq!(
5469 reasoning_stream_style_for_route(
5470 ApiProvider::ModelstudioTokenPlan,
5471 base_url,
5472 model,
5473 None,
5474 ),
5475 ReasoningStreamStyle::SeparateField,
5476 "{model}",
5477 );
5478 assert!(should_replay_reasoning_content_for_provider_on_route(
5479 ApiProvider::ModelstudioTokenPlan,
5480 base_url,
5481 model,
5482 Some("off"),
5483 ));
5484 }
5485 }
5486
5487 #[test]
5488 fn stream_classifies_moonshot_kimi_as_reasoning() {
5489 // #3016: without this, Kimi thinking leaked into answer text.
5490 assert!(is_reasoning_model_for_stream(
5491 ApiProvider::Moonshot,
5492 "kimi-k2.6"
5493 ));
5494 assert!(
5495 is_reasoning_model_for_stream(ApiProvider::Moonshot, "kimi-for-coding"),
5496 "Kimi Code's stable model id now maps to K2.7 Code and streams reasoning_content"
5497 );
5498 }
5499
5500 #[test]
5501 fn moonshot_and_minimax_replay_reasoning_content_for_supported_models() {
5502 assert!(should_replay_reasoning_content_for_provider(
5503 ApiProvider::Moonshot,
5504 "kimi-k2.7-code",
5505 None,
5506 ));
5507 assert!(should_replay_reasoning_content_for_provider(
5508 ApiProvider::Moonshot,
5509 "kimi-for-coding",
5510 None,
5511 ));
5512 assert!(should_replay_reasoning_content_for_provider(
5513 ApiProvider::Minimax,
5514 "MiniMax-M3",
5515 None,
5516 ));
5517 assert!(should_replay_reasoning_content_for_provider(
5518 ApiProvider::Zai,
5519 "GLM-5.2",
5520 None,
5521 ));
5522 assert!(should_replay_reasoning_content_for_provider(
5523 ApiProvider::Zai,
5524 "GLM-5.3",
5525 None,
5526 ));
5527 assert!(!should_replay_reasoning_content_for_provider(
5528 ApiProvider::Moonshot,
5529 "kimi-for-coding",
5530 Some("off"),
5531 ));
5532 }
5533
5534 #[test]
5535 fn bare_k3_reasoning_semantics_are_scoped_to_exact_kimi_code_route() {
5536 let kimi_code = crate::config::DEFAULT_KIMI_CODE_BASE_URL;
5537 let direct_moonshot = crate::config::DEFAULT_MOONSHOT_BASE_URL;
5538
5539 assert!(should_replay_reasoning_content_for_provider_on_route(
5540 ApiProvider::Moonshot,
5541 kimi_code,
5542 crate::config::KIMI_CODE_K3_MODEL,
5543 Some("high"),
5544 ));
5545 assert!(is_reasoning_model_for_stream_on_route(
5546 ApiProvider::Moonshot,
5547 kimi_code,
5548 crate::config::KIMI_CODE_K3_MODEL,
5549 ));
5550 assert_eq!(
5551 reasoning_stream_style_for_route(
5552 ApiProvider::Moonshot,
5553 kimi_code,
5554 crate::config::KIMI_CODE_K3_MODEL,
5555 None,
5556 ),
5557 ReasoningStreamStyle::SeparateField
5558 );
5559
5560 assert!(!should_replay_reasoning_content_for_provider_on_route(
5561 ApiProvider::Moonshot,
5562 direct_moonshot,
5563 crate::config::KIMI_CODE_K3_MODEL,
5564 Some("high"),
5565 ));
5566 assert!(!is_reasoning_model_for_stream_on_route(
5567 ApiProvider::Moonshot,
5568 direct_moonshot,
5569 crate::config::KIMI_CODE_K3_MODEL,
5570 ));
5571 assert_eq!(
5572 reasoning_stream_style_for_route(
5573 ApiProvider::Moonshot,
5574 direct_moonshot,
5575 crate::config::KIMI_CODE_K3_MODEL,
5576 None,
5577 ),
5578 ReasoningStreamStyle::None
5579 );
5580 assert!(
5581 should_replay_reasoning_content_for_provider_on_route(
5582 ApiProvider::Moonshot,
5583 kimi_code,
5584 crate::config::KIMI_CODE_K3_MODEL,
5585 Some("off"),
5586 ),
5587 "exact membership K3 stays always-thinking even for a stale raw Off caller"
5588 );
5589 }
5590
5591 #[test]
5592 fn direct_moonshot_k3_is_always_thinking_and_replays_reasoning() {
5593 let direct = crate::config::DEFAULT_MOONSHOT_BASE_URL;
5594 let model = crate::config::MOONSHOT_KIMI_K3_MODEL;
5595
5596 for effort in [Some("off"), Some("low"), Some("high"), Some("max"), None] {
5597 assert!(should_replay_reasoning_content_for_provider_on_route(
5598 ApiProvider::Moonshot,
5599 direct,
5600 model,
5601 effort,
5602 ));
5603 }
5604 assert_eq!(
5605 reasoning_stream_style_for_route(ApiProvider::Moonshot, direct, model, None),
5606 ReasoningStreamStyle::SeparateField
5607 );
5608 }
5609
5610 #[test]
5611 fn xiaomi_mimo_uses_max_completion_tokens_payload_key() {
5612 let mut body = json!({
5613 "model": "mimo-v2.5-pro",
5614 "messages": [],
5615 "max_tokens": 8192,
5616 });
5617
5618 apply_provider_token_limit(
5619 &mut body,
5620 ApiProvider::XiaomiMimo,
5621 "https://api.xiaomimimo.com/v1",
5622 "mimo-v2.5-pro",
5623 8192,
5624 );
5625
5626 assert!(body.get("max_tokens").is_none());
5627 assert_eq!(
5628 body.get("max_completion_tokens")
5629 .and_then(serde_json::Value::as_u64),
5630 Some(8192)
5631 );
5632 }
5633
5634 #[test]
5635 fn openai_reasoning_model_uses_completion_token_limit_and_effort_field() {
5636 let mut body = json!({
5637 "model": "gpt-5.5",
5638 "messages": [],
5639 "max_tokens": 4096,
5640 });
5641
5642 apply_provider_token_limit(
5643 &mut body,
5644 ApiProvider::Openai,
5645 "https://api.openai.com/v1",
5646 "gpt-5.5",
5647 4096,
5648 );
5649 apply_openai_reasoning_effort(&mut body, ApiProvider::Openai, "gpt-5.5", Some("high"));
5650
5651 assert!(body.get("max_tokens").is_none());
5652 assert_eq!(
5653 body.get("max_completion_tokens")
5654 .and_then(serde_json::Value::as_u64),
5655 Some(4096)
5656 );
5657 assert_eq!(
5658 body.get("reasoning_effort")
5659 .and_then(serde_json::Value::as_str),
5660 Some("high")
5661 );
5662 }
5663
5664 #[test]
5665 fn gpt_56_uses_documented_max_reasoning_effort() {
5666 let mut body = json!({
5667 "model": "gpt-5.6-sol",
5668 "messages": [],
5669 "max_tokens": 8192,
5670 });
5671
5672 apply_provider_token_limit(
5673 &mut body,
5674 ApiProvider::Openai,
5675 "https://api.openai.com/v1",
5676 "gpt-5.6-sol",
5677 8192,
5678 );
5679 apply_openai_reasoning_effort(&mut body, ApiProvider::Openai, "gpt-5.6-sol", Some("max"));
5680
5681 assert!(body.get("max_tokens").is_none());
5682 assert_eq!(body["max_completion_tokens"], json!(8192));
5683 assert_eq!(body["reasoning_effort"], json!("max"));
5684 }
5685
5686 #[test]
5687 fn grok_46_uses_exact_first_party_reasoning_effort_ladder() {
5688 for (requested, expected) in [
5689 ("off", "high"),
5690 ("low", "low"),
5691 ("medium", "medium"),
5692 ("high", "high"),
5693 ("xhigh", "xhigh"),
5694 ("max", "xhigh"),
5695 ] {
5696 let mut body = json!({});
5697 apply_route_reasoning_controls(
5698 &mut body,
5699 ApiProvider::Xai,
5700 crate::config::DEFAULT_XAI_BASE_URL,
5701 crate::config::XAI_GROK_4_6_MODEL,
5702 Some(requested),
5703 );
5704 assert_eq!(body, json!({ "reasoning_effort": expected }), "{requested}");
5705 }
5706
5707 let mut provider_default = json!({});
5708 apply_route_reasoning_controls(
5709 &mut provider_default,
5710 ApiProvider::Xai,
5711 crate::config::DEFAULT_XAI_BASE_URL,
5712 crate::config::XAI_GROK_4_6_MODEL,
5713 Some("auto"),
5714 );
5715 assert_eq!(provider_default, json!({}));
5716
5717 let mut custom = json!({});
5718 apply_route_reasoning_controls(
5719 &mut custom,
5720 ApiProvider::Xai,
5721 "https://gateway.example/v1",
5722 crate::config::XAI_GROK_4_6_MODEL,
5723 Some("medium"),
5724 );
5725 assert_eq!(custom, json!({}));
5726 }
5727
5728 #[test]
5729 fn grok_45_uses_first_party_ladder_and_maps_xhigh_to_high() {
5730 for (requested, expected) in [
5731 ("off", "high"),
5732 ("low", "low"),
5733 ("medium", "medium"),
5734 ("high", "high"),
5735 ("xhigh", "high"),
5736 ("max", "high"),
5737 ] {
5738 let mut body = json!({});
5739 apply_route_reasoning_controls(
5740 &mut body,
5741 ApiProvider::Xai,
5742 crate::config::DEFAULT_XAI_BASE_URL,
5743 crate::config::XAI_GROK_4_5_MODEL,
5744 Some(requested),
5745 );
5746 assert_eq!(body, json!({ "reasoning_effort": expected }), "{requested}");
5747 }
5748 }
5749
5750 #[test]
5751 fn inkling_uses_its_exact_reasoning_vocabulary_without_thinking_extension() {
5752 for (requested, expected) in [
5753 ("off", "none"),
5754 ("minimal", "minimal"),
5755 ("low", "low"),
5756 ("medium", "medium"),
5757 ("high", "high"),
5758 ("max", "max"),
5759 ("xhigh", "max"),
5760 ] {
5761 let mut body = json!({
5762 "thinking": { "type": "enabled" },
5763 "reasoning_effort": "xhigh",
5764 });
5765
5766 apply_inkling_reasoning_effort(
5767 &mut body,
5768 ApiProvider::Together,
5769 "thinkingmachines/inkling",
5770 Some(requested),
5771 );
5772
5773 assert_eq!(body["reasoning_effort"], json!(expected));
5774 assert!(body.get("thinking").is_none());
5775 }
5776 }
5777
5778 #[test]
5779 fn inkling_reasoning_override_is_scoped_to_the_exact_together_route() {
5780 let mut other_model = json!({ "thinking": { "type": "enabled" } });
5781 apply_inkling_reasoning_effort(
5782 &mut other_model,
5783 ApiProvider::Together,
5784 "deepseek-ai/DeepSeek-V4-Pro",
5785 Some("max"),
5786 );
5787 assert_eq!(other_model["thinking"]["type"], json!("enabled"));
5788 assert!(other_model.get("reasoning_effort").is_none());
5789
5790 let mut other_provider = json!({ "thinking": { "type": "enabled" } });
5791 apply_inkling_reasoning_effort(
5792 &mut other_provider,
5793 ApiProvider::Openrouter,
5794 "thinkingmachines/inkling",
5795 Some("max"),
5796 );
5797 assert_eq!(other_provider["thinking"]["type"], json!("enabled"));
5798 assert!(other_provider.get("reasoning_effort").is_none());
5799 }
5800
5801 #[test]
5802 fn kimi_code_k3_uses_documented_nested_thinking_effort() {
5803 for (requested, expected) in [
5804 ("low", json!({ "type": "enabled", "effort": "low" })),
5805 ("minimum", json!({ "type": "enabled", "effort": "low" })),
5806 ("light", json!({ "type": "enabled", "effort": "low" })),
5807 ("medium", json!({ "type": "enabled", "effort": "high" })),
5808 ("high", json!({ "type": "enabled", "effort": "high" })),
5809 ("xhigh", json!({ "type": "enabled", "effort": "max" })),
5810 ("ultra", json!({ "type": "enabled", "effort": "max" })),
5811 ("max", json!({ "type": "enabled", "effort": "max" })),
5812 ("none", json!({ "type": "enabled", "effort": "low" })),
5813 ("off", json!({ "type": "enabled", "effort": "low" })),
5814 ] {
5815 let mut body = json!({ "reasoning_effort": "stale" });
5816 apply_kimi_code_k3_reasoning_effort(
5817 &mut body,
5818 ApiProvider::Moonshot,
5819 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
5820 crate::config::KIMI_CODE_K3_MODEL,
5821 Some(requested),
5822 );
5823
5824 assert_eq!(body["thinking"], expected, "requested {requested}");
5825 assert!(body.get("reasoning_effort").is_none());
5826 }
5827 }
5828
5829 #[test]
5830 fn kimi_code_k3_256k_uses_k3_reasoning_and_membership_sampling_contracts() {
5831 let mut body = json!({
5832 "reasoning_effort": "stale",
5833 "temperature": 0.3,
5834 "top_p": 0.8,
5835 });
5836 apply_kimi_code_k3_reasoning_effort(
5837 &mut body,
5838 ApiProvider::Moonshot,
5839 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
5840 crate::config::KIMI_CODE_K3_256K_MODEL,
5841 Some("max"),
5842 );
5843 apply_kimi_code_fixed_sampling(
5844 &mut body,
5845 ApiProvider::Moonshot,
5846 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
5847 crate::config::KIMI_CODE_K3_256K_MODEL,
5848 );
5849
5850 assert_eq!(
5851 body["thinking"],
5852 json!({ "type": "enabled", "effort": "max" })
5853 );
5854 assert!(body.get("reasoning_effort").is_none());
5855 assert!(body.get("temperature").is_none());
5856 assert!(body.get("top_p").is_none());
5857 }
5858
5859 #[test]
5860 fn kimi_code_fixed_sampling_does_not_leak_to_neighbor_routes() {
5861 for (provider, base_url, model) in [
5862 (
5863 ApiProvider::Moonshot,
5864 crate::config::DEFAULT_MOONSHOT_BASE_URL,
5865 crate::config::KIMI_CODE_K3_256K_MODEL,
5866 ),
5867 (
5868 ApiProvider::Openrouter,
5869 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
5870 crate::config::KIMI_CODE_K3_256K_MODEL,
5871 ),
5872 (
5873 ApiProvider::Moonshot,
5874 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
5875 "k3-256k-preview",
5876 ),
5877 ] {
5878 let mut body = json!({ "temperature": 0.3, "top_p": 0.8 });
5879 apply_kimi_code_fixed_sampling(&mut body, provider, base_url, model);
5880 assert_eq!(body["temperature"], json!(0.3));
5881 assert_eq!(body["top_p"], json!(0.8));
5882 }
5883 }
5884
5885 #[test]
5886 fn direct_moonshot_k3_uses_top_level_effort_and_never_disables_thinking() {
5887 for (requested, expected) in [
5888 ("off", "low"),
5889 ("none", "low"),
5890 ("low", "low"),
5891 ("medium", "high"),
5892 ("high", "high"),
5893 ("xhigh", "max"),
5894 ("max", "max"),
5895 ] {
5896 let mut body = json!({
5897 "model": crate::config::MOONSHOT_KIMI_K3_MODEL,
5898 "thinking": { "type": "disabled" },
5899 });
5900 apply_route_reasoning_controls(
5901 &mut body,
5902 ApiProvider::Moonshot,
5903 crate::config::DEFAULT_MOONSHOT_BASE_URL,
5904 crate::config::MOONSHOT_KIMI_K3_MODEL,
5905 Some(requested),
5906 );
5907
5908 assert_eq!(body["reasoning_effort"], json!(expected), "{requested}");
5909 assert!(body.get("thinking").is_none(), "{requested}: {body}");
5910 }
5911
5912 let mut provider_default = json!({ "thinking": { "type": "enabled" } });
5913 apply_route_reasoning_controls(
5914 &mut provider_default,
5915 ApiProvider::Moonshot,
5916 crate::config::DEFAULT_MOONSHOT_BASE_URL,
5917 crate::config::MOONSHOT_KIMI_K3_MODEL,
5918 Some("auto"),
5919 );
5920 assert!(provider_default.get("thinking").is_none());
5921 assert!(provider_default.get("reasoning_effort").is_none());
5922 }
5923
5924 #[test]
5925 fn direct_moonshot_k3_uses_modern_token_field_and_fixed_sampling_only_on_exact_route() {
5926 let mut direct = json!({
5927 "max_tokens": 64,
5928 "temperature": 0.2,
5929 "top_p": 0.9,
5930 });
5931 apply_provider_token_limit(
5932 &mut direct,
5933 ApiProvider::Moonshot,
5934 crate::config::DEFAULT_MOONSHOT_BASE_URL,
5935 crate::config::MOONSHOT_KIMI_K3_MODEL,
5936 64,
5937 );
5938 apply_direct_moonshot_k3_fixed_sampling(
5939 &mut direct,
5940 ApiProvider::Moonshot,
5941 crate::config::DEFAULT_MOONSHOT_BASE_URL,
5942 crate::config::MOONSHOT_KIMI_K3_MODEL,
5943 );
5944 assert_eq!(direct["max_completion_tokens"], json!(64));
5945 assert!(direct.get("max_tokens").is_none());
5946 assert!(direct.get("temperature").is_none());
5947 assert!(direct.get("top_p").is_none());
5948
5949 let mut neighbor = json!({
5950 "max_tokens": 64,
5951 "temperature": 0.2,
5952 "top_p": 0.9,
5953 });
5954 apply_provider_token_limit(
5955 &mut neighbor,
5956 ApiProvider::Moonshot,
5957 "https://proxy.example/v1",
5958 crate::config::MOONSHOT_KIMI_K3_MODEL,
5959 64,
5960 );
5961 apply_direct_moonshot_k3_fixed_sampling(
5962 &mut neighbor,
5963 ApiProvider::Moonshot,
5964 "https://proxy.example/v1",
5965 crate::config::MOONSHOT_KIMI_K3_MODEL,
5966 );
5967 assert_eq!(neighbor["max_tokens"], json!(64));
5968 assert!(neighbor.get("max_completion_tokens").is_none());
5969 assert_eq!(neighbor["temperature"], json!(0.2));
5970 assert_eq!(neighbor["top_p"], json!(0.9));
5971 }
5972
5973 #[test]
5974 fn direct_and_membership_k3_reasoning_dialects_do_not_cross_routes() {
5975 let mut membership = json!({});
5976 apply_route_reasoning_controls(
5977 &mut membership,
5978 ApiProvider::Moonshot,
5979 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
5980 crate::config::KIMI_CODE_K3_MODEL,
5981 Some("max"),
5982 );
5983 assert_eq!(
5984 membership["thinking"],
5985 json!({ "type": "enabled", "effort": "max" })
5986 );
5987 assert!(membership.get("reasoning_effort").is_none());
5988
5989 for (base_url, model) in [
5990 (
5991 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
5992 crate::config::MOONSHOT_KIMI_K3_MODEL,
5993 ),
5994 (
5995 crate::config::DEFAULT_MOONSHOT_BASE_URL,
5996 crate::config::KIMI_CODE_K3_MODEL,
5997 ),
5998 (
5999 "https://proxy.example/v1",
6000 crate::config::MOONSHOT_KIMI_K3_MODEL,
6001 ),
6002 ] {
6003 let mut neighbor = json!({});
6004 apply_route_reasoning_controls(
6005 &mut neighbor,
6006 ApiProvider::Moonshot,
6007 base_url,
6008 model,
6009 Some("max"),
6010 );
6011 assert_eq!(
6012 neighbor["thinking"],
6013 json!({ "type": "enabled" }),
6014 "{base_url} / {model}"
6015 );
6016 assert!(neighbor.get("reasoning_effort").is_none());
6017 assert!(neighbor.pointer("/thinking/effort").is_none());
6018 }
6019 }
6020
6021 #[test]
6022 fn kimi_code_k3_effort_override_never_leaks_to_neighbor_routes() {
6023 for (base_url, model) in [
6024 (crate::config::DEFAULT_KIMI_CODE_BASE_URL, "kimi-k3"),
6025 (
6026 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
6027 crate::config::DEFAULT_KIMI_CODE_MODEL,
6028 ),
6029 (crate::config::DEFAULT_MOONSHOT_BASE_URL, "k3"),
6030 ] {
6031 let mut body = json!({ "thinking": { "type": "enabled" } });
6032 apply_kimi_code_k3_reasoning_effort(
6033 &mut body,
6034 ApiProvider::Moonshot,
6035 base_url,
6036 model,
6037 Some("max"),
6038 );
6039
6040 assert_eq!(body["thinking"], json!({ "type": "enabled" }));
6041 assert!(
6042 body.pointer("/thinking/effort").is_none(),
6043 "{base_url} / {model}"
6044 );
6045 assert!(body.get("reasoning_effort").is_none());
6046 }
6047 }
6048
6049 #[test]
6050 fn muse_spark_uses_meta_reasoning_effort_without_openai_token_rewrite() {
6051 let mut body = json!({
6052 "model": "muse-spark-1.1",
6053 "messages": [],
6054 "max_tokens": 8192,
6055 });
6056
6057 apply_provider_token_limit(
6058 &mut body,
6059 ApiProvider::Meta,
6060 "https://api.meta.ai/v1",
6061 "muse-spark-1.1",
6062 8192,
6063 );
6064 apply_openai_reasoning_effort(&mut body, ApiProvider::Meta, "muse-spark-1.1", Some("max"));
6065
6066 assert_eq!(body["max_tokens"], json!(8192));
6067 assert!(body.get("max_completion_tokens").is_none());
6068 assert_eq!(body["reasoning_effort"], json!("xhigh"));
6069 }
6070
6071 #[test]
6072 fn provider_regression_5853_muse_family_preserves_reasoning_effort() {
6073 for model in [
6074 "muse-spark-1.2",
6075 "muse-spark-1.3",
6076 "muse-spark-1.3-contributor",
6077 " MUSE-SPARK-1.3 ",
6078 ] {
6079 for (effort, wire) in [
6080 ("low", "low"),
6081 ("medium", "medium"),
6082 ("high", "high"),
6083 ("xhigh", "xhigh"),
6084 ("max", "xhigh"),
6085 ("ultra", "xhigh"),
6086 ] {
6087 let mut body = json!({"model": model});
6088 apply_openai_reasoning_effort(&mut body, ApiProvider::Meta, model, Some(effort));
6089 assert_eq!(body["reasoning_effort"], wire, "{model}, effort={effort}");
6090 }
6091 }
6092 for (provider, model, effort) in [
6093 (ApiProvider::Openai, "muse-spark-1.3", Some("high")),
6094 (ApiProvider::Meta, "muse-glimmer-fixture", Some("high")),
6095 (ApiProvider::Meta, "muse-sparks-fixture", Some("high")),
6096 (ApiProvider::Meta, "muse-spark-1.3", None),
6097 ] {
6098 let mut body = json!({"model": model});
6099 apply_openai_reasoning_effort(&mut body, provider, model, effort);
6100 assert!(
6101 body.get("reasoning_effort").is_none(),
6102 "{provider:?}, {model}"
6103 );
6104 }
6105 }
6106
6107 #[test]
6108 fn openai_non_reasoning_model_omits_reasoning_only_fields() {
6109 let mut body = json!({
6110 "model": "gpt-4o",
6111 "messages": [],
6112 "max_tokens": 4096,
6113 });
6114
6115 apply_provider_token_limit(
6116 &mut body,
6117 ApiProvider::Openai,
6118 "https://api.openai.com/v1",
6119 "gpt-4o",
6120 4096,
6121 );
6122 apply_openai_reasoning_effort(&mut body, ApiProvider::Openai, "gpt-4o", Some("high"));
6123
6124 assert_eq!(
6125 body.get("max_tokens").and_then(serde_json::Value::as_u64),
6126 Some(4096)
6127 );
6128 assert!(body.get("max_completion_tokens").is_none());
6129 assert!(body.get("reasoning_effort").is_none());
6130 }
6131
6132 #[test]
6133 fn openai_provider_deepseek_compatible_model_keeps_chat_token_field() {
6134 let mut body = json!({
6135 "model": "deepseek-v4-pro",
6136 "messages": [],
6137 "max_tokens": 4096,
6138 });
6139
6140 apply_provider_token_limit(
6141 &mut body,
6142 ApiProvider::Openai,
6143 "https://api.openai.com/v1",
6144 "deepseek-v4-pro",
6145 4096,
6146 );
6147 apply_openai_reasoning_effort(
6148 &mut body,
6149 ApiProvider::Openai,
6150 "deepseek-v4-pro",
6151 Some("high"),
6152 );
6153
6154 assert_eq!(
6155 body.get("max_tokens").and_then(serde_json::Value::as_u64),
6156 Some(4096)
6157 );
6158 assert!(body.get("max_completion_tokens").is_none());
6159 assert!(body.get("reasoning_effort").is_none());
6160 }
6161
6162 #[test]
6163 fn deepseek_model_on_openai_provider_still_replays_reasoning_content() {
6164 // #1739 / #1694: a DeepSeek thinking model pointed at a
6165 // DeepSeek-compatible endpoint via the generic `openai` provider must
6166 // still replay reasoning_content, even though the provider itself does
6167 // not accept the field. Otherwise the thinking-mode API returns 400.
6168 assert!(should_replay_reasoning_content_for_provider(
6169 ApiProvider::Openai,
6170 "deepseek-v4-flash",
6171 None,
6172 ));
6173 assert!(should_replay_reasoning_content_for_provider(
6174 ApiProvider::Openai,
6175 "deepseek-v4-pro",
6176 None,
6177 ));
6178 assert!(should_replay_reasoning_content_for_provider(
6179 ApiProvider::Openai,
6180 "deepseek-reasoner",
6181 Some("medium"),
6182 ));
6183 // The documented escape hatch still wins over model detection.
6184 assert!(!should_replay_reasoning_content_for_provider(
6185 ApiProvider::Openai,
6186 "deepseek-v4-flash",
6187 Some("off"),
6188 ));
6189 }
6190
6191 #[test]
6192 fn generic_model_on_openai_provider_still_strips_reasoning_content() {
6193 // #1542 no-regression guard: a genuine non-DeepSeek model on the
6194 // openai provider must continue to have reasoning_content stripped.
6195 assert!(!should_replay_reasoning_content_for_provider(
6196 ApiProvider::Openai,
6197 "qwen3-coder",
6198 None,
6199 ));
6200 assert!(!should_replay_reasoning_content_for_provider(
6201 ApiProvider::Openai,
6202 "claude-sonnet-4-6",
6203 None,
6204 ));
6205 }
6206
6207 #[test]
6208 fn suggestive_unknown_model_names_never_authorize_reasoning_replay() {
6209 for provider in [
6210 ApiProvider::Openai,
6211 ApiProvider::Deepseek,
6212 ApiProvider::Openrouter,
6213 ApiProvider::Moonshot,
6214 ApiProvider::Zai,
6215 ] {
6216 for model in [
6217 "foo-thinking",
6218 "foo-reasoner",
6219 "acme-reasoning",
6220 "future-reasoner-v9",
6221 ] {
6222 assert!(
6223 !should_replay_reasoning_content_for_provider(provider, model, None),
6224 "{provider:?} {model}"
6225 );
6226 assert!(
6227 !is_reasoning_model_for_stream(provider, model),
6228 "stream classification must fail closed too: {provider:?} {model}"
6229 );
6230 }
6231 }
6232 }
6233
6234 #[test]
6235 fn stream_classifies_deepseek_model_on_openai_provider_as_reasoning() {
6236 // #1739: the SSE parser must treat a DeepSeek thinking model on the
6237 // generic `openai` provider (DeepSeek-compatible endpoint) as a
6238 // reasoning model, or incoming `reasoning_content` tokens are stored
6239 // as answer text and the subsequent replay still 400s.
6240 assert!(is_reasoning_model_for_stream(
6241 ApiProvider::Openai,
6242 "deepseek-v4-flash"
6243 ));
6244 assert!(is_reasoning_model_for_stream(
6245 ApiProvider::Openai,
6246 "deepseek-v4-pro"
6247 ));
6248 assert!(is_reasoning_model_for_stream(
6249 ApiProvider::Openai,
6250 "deepseek-reasoner"
6251 ));
6252 // Native DeepSeek provider was already correct; stays correct.
6253 assert!(is_reasoning_model_for_stream(
6254 ApiProvider::Deepseek,
6255 "deepseek-v4-pro"
6256 ));
6257 }
6258
6259 #[test]
6260 fn zai_tiered_effort_applies_to_glm_5_2_and_glm_5_3_but_not_5_1() {
6261 let zai = crate::config::DEFAULT_ZAI_BASE_URL;
6262 // GLM-5.3 and GLM-5.3-Flash inherit GLM-5.2's reasoning_options
6263 // (effort high/max), so they must take the same tiered wire path.
6264 for model in [
6265 crate::config::ZAI_GLM_5_2_MODEL,
6266 crate::config::ZAI_GLM_5_3_MODEL,
6267 crate::config::ZAI_GLM_5_3_FLASH_MODEL,
6268 ] {
6269 let mut body = json!({});
6270 apply_route_reasoning_controls(&mut body, ApiProvider::Zai, zai, model, Some("max"));
6271 assert_eq!(body["reasoning_effort"], json!("max"), "{model} at max");
6272
6273 let mut body = json!({});
6274 apply_route_reasoning_controls(&mut body, ApiProvider::Zai, zai, model, Some("high"));
6275 assert_eq!(body["reasoning_effort"], json!("high"), "{model} at high");
6276 }
6277
6278 // GLM-5.1 and GLM-5-Turbo keep only the generic thinking control.
6279 for model in [
6280 crate::config::ZAI_GLM_5_1_MODEL,
6281 crate::config::ZAI_GLM_5_TURBO_MODEL,
6282 ] {
6283 let mut body = json!({});
6284 apply_route_reasoning_controls(&mut body, ApiProvider::Zai, zai, model, Some("max"));
6285 assert!(
6286 body.get("reasoning_effort").is_none(),
6287 "{model} must not receive tiered effort"
6288 );
6289 }
6290
6291 // A compatible gateway is not evidence of the Z.ai dialect, for 5.3
6292 // exactly as for 5.2.
6293 let mut body = json!({"thinking": {"type": "enabled"}});
6294 apply_route_reasoning_controls(
6295 &mut body,
6296 ApiProvider::Zai,
6297 "https://gateway.example.com/v1",
6298 crate::config::ZAI_GLM_5_3_MODEL,
6299 Some("max"),
6300 );
6301 assert!(body.get("reasoning_effort").is_none());
6302 assert!(body.get("thinking").is_none());
6303 }
6304
6305 #[test]
6306 fn zai_forced_thinking_models_never_send_thinking_disabled() {
6307 // BigModel and Z.ai document GLM-5.3 / GLM-5.3-Flash as forced-thinking:
6308 // `thinking.type: "disabled"` errors, effort accepts only low/high/max,
6309 // and the migration note for a former `disabled` payload is
6310 // `enabled` + `reasoning_effort: "low"`. Both hosts of the first-party
6311 // open platform — api.z.ai and open.bigmodel.cn — get the rewrite.
6312 for zai in [
6313 crate::config::DEFAULT_ZAI_BASE_URL,
6314 "https://open.bigmodel.cn/api/paas/v4",
6315 ] {
6316 for model in [
6317 crate::config::ZAI_GLM_5_3_MODEL,
6318 crate::config::ZAI_GLM_5_3_FLASH_MODEL,
6319 ] {
6320 let mut body = json!({});
6321 apply_route_reasoning_controls(
6322 &mut body,
6323 ApiProvider::Zai,
6324 zai,
6325 model,
6326 Some("off"),
6327 );
6328 assert_eq!(
6329 body["thinking"]["type"],
6330 json!("enabled"),
6331 "{model} must not send the rejected disabled toggle"
6332 );
6333 assert_eq!(
6334 body["reasoning_effort"],
6335 json!("low"),
6336 "{model} off becomes low"
6337 );
6338
6339 let mut body = json!({});
6340 apply_route_reasoning_controls(
6341 &mut body,
6342 ApiProvider::Zai,
6343 zai,
6344 model,
6345 Some("low"),
6346 );
6347 assert_eq!(
6348 body["reasoning_effort"],
6349 json!("low"),
6350 "{model} low is native"
6351 );
6352
6353 let mut body = json!({});
6354 apply_route_reasoning_controls(
6355 &mut body,
6356 ApiProvider::Zai,
6357 zai,
6358 model,
6359 Some("medium"),
6360 );
6361 assert_eq!(
6362 body["reasoning_effort"],
6363 json!("high"),
6364 "{model} medium maps to high"
6365 );
6366
6367 let mut body = json!({});
6368 apply_route_reasoning_controls(
6369 &mut body,
6370 ApiProvider::Zai,
6371 zai,
6372 model,
6373 Some("max"),
6374 );
6375 assert_eq!(
6376 body["reasoning_effort"],
6377 json!("max"),
6378 "{model} max stays max"
6379 );
6380
6381 // Unknown legacy values leave the field omitted so the API keeps
6382 // its documented default; nothing may reintroduce `disabled`.
6383 let mut body = json!({});
6384 apply_route_reasoning_controls(
6385 &mut body,
6386 ApiProvider::Zai,
6387 zai,
6388 model,
6389 Some("auto"),
6390 );
6391 assert!(
6392 body.get("reasoning_effort").is_none(),
6393 "{model} auto stays omitted"
6394 );
6395 assert_ne!(body["thinking"]["type"], json!("disabled"));
6396 }
6397
6398 // GLM-5.2 honours the generic disabled toggle on both hosts. On
6399 // BigModel that toggle now reaches the API (its docs still list
6400 // `disabled` for GLM-5.2) instead of being stripped by the
6401 // fail-closed gateway path.
6402 let mut body = json!({});
6403 apply_route_reasoning_controls(
6404 &mut body,
6405 ApiProvider::Zai,
6406 zai,
6407 crate::config::ZAI_GLM_5_2_MODEL,
6408 Some("off"),
6409 );
6410 assert_eq!(body["thinking"]["type"], json!("disabled"));
6411 assert!(body.get("reasoning_effort").is_none());
6412 }
6413 }
6414
6415 #[test]
6416 fn zai_bigmodel_adjacent_routes_stay_fail_closed() {
6417 // BigModel's `/preview` product and a plain-http neighbor are not the
6418 // documented Chat dialect, so they keep the gateway treatment: no
6419 // Z.ai reasoning fields, including on a forced-thinking model.
6420 for neighboring_route in [
6421 "https://open.bigmodel.cn/api/paas/v4/preview",
6422 "http://open.bigmodel.cn/api/paas/v4",
6423 ] {
6424 let mut body = json!({"thinking": {"type": "enabled"}});
6425 apply_route_reasoning_controls(
6426 &mut body,
6427 ApiProvider::Zai,
6428 neighboring_route,
6429 crate::config::ZAI_GLM_5_3_MODEL,
6430 Some("max"),
6431 );
6432 assert!(
6433 body.get("reasoning_effort").is_none(),
6434 "{neighboring_route} must not gain tiered effort"
6435 );
6436 assert!(
6437 body.get("thinking").is_none(),
6438 "{neighboring_route} must not keep the Z.ai thinking object"
6439 );
6440 }
6441 }
6442
6443 #[test]
6444 fn stream_classifies_known_large_reasoning_models_as_reasoning() {
6445 // Xiaomi MiMo and OpenRouter/Qwen/Trinity can stream private reasoning through a
6446 // `reasoning` delta without using a DeepSeek-looking model name. The
6447 // renderer must still route that field into Thinking cells instead
6448 // of plain assistant prose.
6449 assert!(
6450 is_reasoning_model_for_stream(ApiProvider::XiaomiMimo, "mimo-v2.5-pro"),
6451 "mimo-v2.5-pro should stream reasoning as thinking on Xiaomi MiMo"
6452 );
6453 assert!(
6454 is_reasoning_model_for_stream(ApiProvider::Arcee, "trinity-large-thinking"),
6455 "trinity-large-thinking should stream reasoning as thinking on direct Arcee"
6456 );
6457 assert!(
6458 is_reasoning_model_for_stream(ApiProvider::Zai, "GLM-5.2"),
6459 "GLM-5.2 should stream reasoning_content as thinking on direct Z.ai"
6460 );
6461 assert!(
6462 is_reasoning_model_for_stream(ApiProvider::Zai, "GLM-5.3"),
6463 "GLM-5.3 inherits GLM-5.2's reasoning capability on direct Z.ai"
6464 );
6465 for model in [
6466 "arcee-ai/trinity-large-thinking",
6467 "minimax/minimax-m3",
6468 "xiaomi/mimo-v2.5-pro",
6469 ] {
6470 assert!(
6471 is_reasoning_model_for_stream(ApiProvider::Openrouter, model),
6472 "{model} should stream reasoning as thinking on OpenRouter"
6473 );
6474 }
6475 }
6476
6477 #[test]
6478 fn stream_does_not_classify_generic_model_as_reasoning() {
6479 // #1542 no-regression guard: a genuine non-DeepSeek model on the
6480 // openai provider must NOT be treated as a reasoning model, so the
6481 // parser keeps inlining any `reasoning_content` it emits as text.
6482 assert!(!is_reasoning_model_for_stream(
6483 ApiProvider::Openai,
6484 "qwen3-coder"
6485 ));
6486 assert!(!is_reasoning_model_for_stream(
6487 ApiProvider::Openai,
6488 "claude-sonnet-4-6"
6489 ));
6490 // Non-DeepSeek model on a reasoning-aware provider is also unchanged.
6491 assert!(!is_reasoning_model_for_stream(
6492 ApiProvider::Deepseek,
6493 "qwen3-coder"
6494 ));
6495 }
6496
6497 #[test]
6498 fn stream_classification_matches_replay_predicate() {
6499 // The streaming classifier and the replay predicate must agree on
6500 // model identity, or stream parsing and message sanitisation disagree
6501 // about where reasoning tokens live. Effort=None isolates the
6502 // model/provider dimension shared by both.
6503 for model in ["deepseek-v4-pro", "deepseek-reasoner", "qwen3-coder"] {
6504 for provider in [ApiProvider::Openai, ApiProvider::Deepseek] {
6505 assert_eq!(
6506 is_reasoning_model_for_stream(provider, model),
6507 should_replay_reasoning_content_for_provider(provider, model, None),
6508 "stream vs replay disagree for {model} on {provider:?}"
6509 );
6510 }
6511 }
6512 }
6513 }
6514
6515 #[cfg(test)]
6516 mod image_block_wire_tests {
6517 //! The OpenAI-compatible projection of [`ContentBlock::ImageUrl`].
6518 //!
6519 //! Chat Completions is the wire format behind the large majority of
6520 //! CodeWhale's provider routes, so a regression here is a regression for
6521 //! most of them at once. The shape is fixed by OpenAI's spec: a `user`
6522 //! message whose `content` is an array of parts, with the image as
6523 //! `{"type":"image_url","image_url":{"url":…}}`.
6524 use super::{ApiProvider, build_chat_messages, build_chat_wire_body};
6525 use codewhale_models::Role;
6526 use codewhale_models::{ContentBlock, ImageUrlContent, Message, MessageRequest};
6527
6528 const DATA_URL: &str = "data:image/png;base64,QUJD";
6529
6530 #[test]
6531 fn compaction_checkpoint_keeps_complete_tool_round_on_wire() {
6532 let mut messages: Vec<Message> = serde_json::from_value(serde_json::json!([
6533 {"role":"user","content":[{"type":"text","text":"Analyze the data"}]},
6534 {"role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"read","input":{"path":"a.txt"}}]},
6535 {"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"ready"}]},
6536 {"role":"assistant","content":[{"type":"tool_use","id":"call_2","name":"read","input":{"path":"b.txt"}}]},
6537 {"role":"user","content":[{"type":"tool_result","tool_use_id":"call_2","content":"done"}]}
6538 ])).unwrap();
6539 let summary = codewhale_models::SystemPrompt::Text(
6540 crate::compaction::build_compaction_summary_block_text("Compacted summary", ""),
6541 );
6542 messages.push(crate::compaction::compaction_checkpoint_message(&summary));
6543 crate::runtime_handoff::replace_agent_topology_checkpoint(&mut messages, &[]);
6544 let stored = messages.clone();
6545 let wire = build_chat_messages(None, &messages, "gpt-4o");
6546 assert_eq!(
6547 messages, stored,
6548 "request construction must not alter saved history"
6549 );
6550 let roles: Vec<&str> = wire
6551 .iter()
6552 .map(|message| message["role"].as_str().unwrap())
6553 .collect();
6554 assert_eq!(roles, ["user", "assistant", "tool", "assistant", "tool"]);
6555 let prompt = wire[0]["content"].as_str().unwrap();
6556 assert!(prompt.contains("Compacted summary"));
6557 assert!(prompt.contains("Analyze the data"));
6558 assert!(prompt.contains("codewhale.agent_topology.v1"));
6559 assert_eq!(wire[1]["tool_calls"][0]["id"], "call_1");
6560 assert_eq!(wire[2]["tool_call_id"], "call_1");
6561 assert_eq!(wire[3]["tool_calls"][0]["id"], "call_2");
6562 assert_eq!(wire[4]["tool_call_id"], "call_2");
6563
6564 messages.push(
6565 serde_json::from_value(serde_json::json!({
6566 "role":"assistant","content":[{"type":"text","text":"Analysis complete"}]
6567 }))
6568 .unwrap(),
6569 );
6570 messages.push(
6571 serde_json::from_value(serde_json::json!({
6572 "role":"user","content":[{"type":"text","text":"What happened next?"}]
6573 }))
6574 .unwrap(),
6575 );
6576 let later_wire = build_chat_messages(None, &messages, "gpt-4o");
6577 let later_roles: Vec<&str> = later_wire
6578 .iter()
6579 .map(|message| message["role"].as_str().unwrap())
6580 .collect();
6581 assert_eq!(
6582 later_roles,
6583 [
6584 "user",
6585 "assistant",
6586 "tool",
6587 "assistant",
6588 "tool",
6589 "assistant",
6590 "user"
6591 ]
6592 );
6593 assert_eq!(later_wire[6]["content"], "What happened next?");
6594
6595 let restored = crate::compaction::restore_compaction_checkpoint(
6596 crate::runtime_handoff::project_messages_for_restore(&messages),
6597 Some(&summary),
6598 );
6599 let restored_wire = build_chat_messages(None, &restored, "gpt-4o");
6600 let restored_roles: Vec<&str> = restored_wire
6601 .iter()
6602 .map(|message| message["role"].as_str().unwrap())
6603 .collect();
6604 assert_eq!(restored_roles, later_roles);
6605 assert!(
6606 restored_wire[0]["content"]
6607 .as_str()
6608 .unwrap()
6609 .contains("restored Agent topology checkpoint")
6610 );
6611 assert_eq!(restored_wire[6]["content"], "What happened next?");
6612 }
6613
6614 #[test]
6615 fn quoted_compaction_marker_does_not_reorder_user_wire_messages() {
6616 let messages: Vec<Message> = serde_json::from_value(serde_json::json!([
6617 {"role":"user","content":[{"type":"text","text":"First question"}]},
6618 {"role":"assistant","content":[{"type":"text","text":"First answer"}]},
6619 {"role":"user","content":[{"type":"text","text":"Please explain: Another language model started to solve this problem"}]},
6620 {"role":"assistant","content":[{"type":"text","text":"It introduces a summary."}]},
6621 {"role":"user","content":[{"type":"text","text":"Follow-up question"}]}
6622 ])).unwrap();
6623 let wire = build_chat_messages(None, &messages, "gpt-4o");
6624 let roles: Vec<&str> = wire
6625 .iter()
6626 .map(|message| message["role"].as_str().unwrap())
6627 .collect();
6628 assert_eq!(roles, ["user", "assistant", "user", "assistant", "user"]);
6629 assert_eq!(wire[0]["content"], "First question");
6630 assert_eq!(
6631 wire[2]["content"],
6632 "Please explain: Another language model started to solve this problem"
6633 );
6634 assert_eq!(wire[4]["content"], "Follow-up question");
6635
6636 let quoted_exact_header = crate::compaction::build_compaction_summary_block_text(
6637 "This text was pasted by a user",
6638 "",
6639 );
6640 let mut with_exact_quote = messages.clone();
6641 with_exact_quote[2] = Message {
6642 role: Role::User,
6643 content: vec![ContentBlock::Text {
6644 text: quoted_exact_header.clone(),
6645 cache_control: None,
6646 }],
6647 };
6648 let exact_wire = build_chat_messages(None, &with_exact_quote, "gpt-4o");
6649 let exact_roles: Vec<&str> = exact_wire
6650 .iter()
6651 .map(|message| message["role"].as_str().unwrap())
6652 .collect();
6653 assert_eq!(exact_roles, roles);
6654 assert_eq!(exact_wire[2]["content"], quoted_exact_header);
6655
6656 let mut after_tool: Vec<Message> = serde_json::from_value(serde_json::json!([
6657 {"role":"user","content":[{"type":"text","text":"Read first"}]},
6658 {"role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"read","input":{"path":"a.txt"}}]},
6659 {"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"contents"}]},
6660 {"role":"user","content":[{"type":"text","text":"placeholder"}]},
6661 {"role":"assistant","content":[{"type":"text","text":"Answer"}]}
6662 ])).unwrap();
6663 after_tool[3] = Message {
6664 role: Role::User,
6665 content: vec![ContentBlock::Text {
6666 text: quoted_exact_header.clone(),
6667 cache_control: None,
6668 }],
6669 };
6670 let after_tool_wire = build_chat_messages(None, &after_tool, "gpt-4o");
6671 let after_tool_roles: Vec<&str> = after_tool_wire
6672 .iter()
6673 .map(|message| message["role"].as_str().unwrap())
6674 .collect();
6675 assert_eq!(
6676 after_tool_roles,
6677 ["user", "assistant", "tool", "user", "assistant"]
6678 );
6679 assert_eq!(after_tool_wire[3]["content"], quoted_exact_header);
6680 }
6681
6682 #[test]
6683 fn topology_checkpoint_after_tool_result_keeps_wire_tool_pair() {
6684 let mut messages: Vec<Message> = serde_json::from_value(serde_json::json!([
6685 {"role":"user","content":[{"type":"text","text":"Read the file"}]},
6686 {"role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"read","input":{"path":"a.txt"}}]},
6687 {"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"contents"}]}
6688 ])).unwrap();
6689 crate::runtime_handoff::replace_agent_topology_checkpoint(&mut messages, &[]);
6690 let wire = build_chat_messages(None, &messages, "gpt-4o");
6691 let roles: Vec<&str> = wire
6692 .iter()
6693 .map(|message| message["role"].as_str().unwrap())
6694 .collect();
6695 assert_eq!(roles, ["user", "assistant", "tool"]);
6696 assert!(
6697 wire[0]["content"]
6698 .as_str()
6699 .unwrap()
6700 .contains("agent_topology_v1")
6701 );
6702 assert_eq!(wire[1]["tool_calls"][0]["id"], "call_1");
6703 assert_eq!(wire[2]["tool_call_id"], "call_1");
6704 }
6705
6706 #[test]
6707 fn compaction_without_retained_user_has_one_wire_user_before_tools() {
6708 let mut messages: Vec<Message> = serde_json::from_value(serde_json::json!([
6709 {"role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"read","input":{"path":"a.txt"}}]},
6710 {"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"contents"}]}
6711 ])).unwrap();
6712 let summary = codewhale_models::SystemPrompt::Text(
6713 crate::compaction::build_compaction_summary_block_text("Summary", ""),
6714 );
6715 messages.push(crate::compaction::compaction_checkpoint_message(&summary));
6716 crate::runtime_handoff::replace_agent_topology_checkpoint(&mut messages, &[]);
6717 let wire = build_chat_messages(None, &messages, "gpt-4o");
6718 let roles: Vec<&str> = wire
6719 .iter()
6720 .map(|message| message["role"].as_str().unwrap())
6721 .collect();
6722 assert_eq!(roles, ["user", "assistant", "tool"]);
6723 let prompt = wire[0]["content"].as_str().unwrap();
6724 assert!(prompt.contains("Summary"));
6725 assert!(prompt.contains("agent_topology_v1"));
6726 assert_eq!(wire[2]["tool_call_id"], "call_1");
6727 }
6728
6729 #[test]
6730 fn compaction_after_unanswered_user_prompt_has_one_wire_user() {
6731 let mut messages: Vec<Message> = serde_json::from_value(serde_json::json!([
6732 {"role":"user","content":[{"type":"text","text":"Please continue"}]}
6733 ]))
6734 .unwrap();
6735 let summary = codewhale_models::SystemPrompt::Text(
6736 crate::compaction::build_compaction_summary_block_text("Earlier work", ""),
6737 );
6738 messages.push(crate::compaction::compaction_checkpoint_message(&summary));
6739 crate::runtime_handoff::replace_agent_topology_checkpoint(&mut messages, &[]);
6740 let wire = build_chat_messages(None, &messages, "gpt-4o");
6741 assert_eq!(wire.len(), 1);
6742 assert_eq!(wire[0]["role"], "user");
6743 let prompt = wire[0]["content"].as_str().unwrap();
6744 assert!(prompt.contains("Please continue"));
6745 assert!(prompt.contains("Earlier work"));
6746 assert!(prompt.contains("agent_topology_v1"));
6747 }
6748
6749 fn fixture_tool_use(id: &str) -> ContentBlock {
6750 ContentBlock::ToolUse {
6751 id: id.to_string(),
6752 name: "read".to_string(),
6753 input: serde_json::json!({"path": format!("{id}.txt")}),
6754 caller: None,
6755 thought_signature: None,
6756 }
6757 }
6758
6759 fn fixture_tool_result(id: &str) -> Message {
6760 Message {
6761 role: Role::User,
6762 content: vec![ContentBlock::ToolResult {
6763 tool_use_id: id.to_string(),
6764 content: format!("result for {id}"),
6765 is_error: Some(false),
6766 content_blocks: None,
6767 }],
6768 }
6769 }
6770
6771 fn request_with_image() -> MessageRequest {
6772 MessageRequest {
6773 model: "gpt-4o".to_string(),
6774 messages: vec![Message {
6775 role: Role::User,
6776 content: vec![
6777 ContentBlock::Text {
6778 text: "what is in this screenshot?".to_string(),
6779 cache_control: None,
6780 },
6781 ContentBlock::ImageUrl {
6782 image_url: ImageUrlContent {
6783 url: DATA_URL.to_string(),
6784 },
6785 },
6786 ],
6787 }],
6788 max_tokens: 128,
6789 system: None,
6790 tools: None,
6791 tool_choice: None,
6792 metadata: None,
6793 thinking: None,
6794 reasoning_effort: None,
6795 stream: None,
6796 temperature: None,
6797 top_p: None,
6798 }
6799 }
6800
6801 #[test]
6802 fn user_image_becomes_a_multimodal_parts_array() {
6803 let body = build_chat_wire_body(
6804 &request_with_image(),
6805 ApiProvider::Openai,
6806 "https://api.openai.com/v1",
6807 false,
6808 )
6809 .expect("wire body");
6810
6811 let messages = body.body["messages"].as_array().expect("messages");
6812 let user = messages
6813 .iter()
6814 .find(|message| message["role"] == "user")
6815 .expect("a user message");
6816 let parts = user["content"]
6817 .as_array()
6818 .expect("content must be a parts array once an image is present, not a bare string");
6819
6820 let image = parts
6821 .iter()
6822 .find(|part| part["type"] == "image_url")
6823 .expect("an image_url part");
6824 assert_eq!(image["image_url"]["url"], DATA_URL);
6825
6826 let text = parts
6827 .iter()
6828 .find(|part| part["type"] == "text")
6829 .expect("the accompanying text part");
6830 assert!(
6831 text["text"]
6832 .as_str()
6833 .expect("text")
6834 .contains("what is in this screenshot?"),
6835 "the question must survive alongside the image: {user}"
6836 );
6837 }
6838
6839 #[test]
6840 fn deepseek_vision_exp_uses_chat_image_url_request_shape() {
6841 let mut request = request_with_image();
6842 request.model = "deepseek-v4-flash-vision-exp".to_string();
6843
6844 let body = build_chat_wire_body(
6845 &request,
6846 ApiProvider::Deepseek,
6847 "https://api.deepseek.com/beta",
6848 false,
6849 )
6850 .expect("DeepSeek vision wire body");
6851
6852 assert_eq!(body.body["model"], "deepseek-v4-flash-vision-exp");
6853 let messages = body.body["messages"].as_array().expect("messages");
6854 let user = messages
6855 .iter()
6856 .find(|message| message["role"] == "user")
6857 .expect("a user message");
6858 let parts = user["content"]
6859 .as_array()
6860 .expect("DeepSeek vision content must use multimodal parts");
6861
6862 assert!(parts.iter().any(|part| {
6863 part["type"] == "text" && part["text"] == "what is in this screenshot?"
6864 }));
6865 assert!(
6866 parts.iter().any(|part| {
6867 part["type"] == "image_url" && part["image_url"]["url"] == DATA_URL
6868 })
6869 );
6870 }
6871
6872 #[test]
6873 fn a_message_with_no_image_keeps_its_plain_string_content() {
6874 // Promoting every user turn to a parts array would change the request
6875 // bytes for every text-only route, and with them the prompt-cache
6876 // prefix. Images must be the only thing that triggers the array form.
6877 let mut request = request_with_image();
6878 request.messages[0]
6879 .content
6880 .retain(|block| !matches!(block, ContentBlock::ImageUrl { .. }));
6881
6882 let body = build_chat_wire_body(
6883 &request,
6884 ApiProvider::Openai,
6885 "https://api.openai.com/v1",
6886 false,
6887 )
6888 .expect("wire body");
6889
6890 let messages = body.body["messages"].as_array().expect("messages");
6891 let user = messages
6892 .iter()
6893 .find(|message| message["role"] == "user")
6894 .expect("a user message");
6895 assert!(
6896 user["content"].is_string(),
6897 "text-only turns must stay a plain string: {user}"
6898 );
6899 }
6900
6901 #[test]
6902 fn tool_result_image_follows_its_tool_message_as_multimodal_user_content() {
6903 let mut request = request_with_image();
6904 request.messages = vec![
6905 Message {
6906 role: Role::Assistant,
6907 content: vec![ContentBlock::ToolUse {
6908 id: "call_image_1".to_string(),
6909 name: "read".to_string(),
6910 input: serde_json::json!({"path": "shot.png"}),
6911 caller: None,
6912 thought_signature: None,
6913 }],
6914 },
6915 Message {
6916 role: Role::User,
6917 content: vec![ContentBlock::ToolResult {
6918 tool_use_id: "call_image_1".to_string(),
6919 content: "screenshot captured".to_string(),
6920 is_error: Some(false),
6921 content_blocks: Some(vec![serde_json::json!({
6922 "type": "image",
6923 "mime_type": "image/png",
6924 "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==",
6925 })]),
6926 }],
6927 },
6928 ];
6929
6930 let body = build_chat_wire_body(
6931 &request,
6932 ApiProvider::Openai,
6933 "https://api.openai.com/v1",
6934 false,
6935 )
6936 .expect("wire body");
6937 let messages = body.body["messages"].as_array().expect("messages");
6938 let tool_index = messages
6939 .iter()
6940 .position(|message| message["role"] == "tool")
6941 .expect("tool result");
6942 assert_eq!(messages[tool_index]["tool_call_id"], "call_image_1");
6943 assert_eq!(messages[tool_index]["content"], "screenshot captured");
6944
6945 let image_message = &messages[tool_index + 1];
6946 assert_eq!(image_message["role"], "user");
6947 let parts = image_message["content"].as_array().expect("image parts");
6948 assert_eq!(parts[1]["type"], "image_url");
6949 assert_eq!(
6950 parts[1]["image_url"]["url"],
6951 "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="
6952 );
6953 assert!(
6954 parts[0]["text"]
6955 .as_str()
6956 .is_some_and(|text| text.contains("read") && text.contains("call_image_1"))
6957 );
6958 }
6959
6960 #[test]
6961 fn tool_result_images_follow_the_entire_tool_call_batch() {
6962 let mut request = request_with_image();
6963 request.messages = vec![
6964 Message {
6965 role: Role::Assistant,
6966 content: vec![
6967 ContentBlock::ToolUse {
6968 id: "call_image_1".to_string(),
6969 name: "read".to_string(),
6970 input: serde_json::json!({"path": "first.png"}),
6971 caller: None,
6972 thought_signature: None,
6973 },
6974 ContentBlock::ToolUse {
6975 id: "call_image_2".to_string(),
6976 name: "read".to_string(),
6977 input: serde_json::json!({"path": "second.png"}),
6978 caller: None,
6979 thought_signature: None,
6980 },
6981 ],
6982 },
6983 Message {
6984 role: Role::User,
6985 content: vec![ContentBlock::ToolResult {
6986 tool_use_id: "call_image_1".to_string(),
6987 content: "first screenshot captured".to_string(),
6988 is_error: Some(false),
6989 content_blocks: Some(vec![serde_json::json!({
6990 "type": "image",
6991 "mime_type": "image/png",
6992 "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==",
6993 })]),
6994 }],
6995 },
6996 Message {
6997 role: Role::User,
6998 content: vec![ContentBlock::ToolResult {
6999 tool_use_id: "call_image_2".to_string(),
7000 content: "second screenshot captured".to_string(),
7001 is_error: Some(false),
7002 content_blocks: Some(vec![serde_json::json!({
7003 "type": "image",
7004 "mime_type": "image/png",
7005 "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYPj/HwADAgH/5ncLrgAAAABJRU5ErkJggg==",
7006 })]),
7007 }],
7008 },
7009 ];
7010
7011 let body = build_chat_wire_body(
7012 &request,
7013 ApiProvider::Openai,
7014 "https://api.openai.com/v1",
7015 false,
7016 )
7017 .expect("wire body");
7018 let messages = body.body["messages"].as_array().expect("messages");
7019 let roles: Vec<_> = messages
7020 .iter()
7021 .map(|message| message["role"].as_str().expect("role"))
7022 .collect();
7023 assert_eq!(roles, ["assistant", "tool", "tool", "user"]);
7024 assert_eq!(messages[1]["tool_call_id"], "call_image_1");
7025 assert_eq!(messages[2]["tool_call_id"], "call_image_2");
7026
7027 let image_parts = messages[3]["content"].as_array().expect("image parts");
7028 assert_eq!(image_parts.len(), 4);
7029 assert_eq!(
7030 image_parts[1]["image_url"]["url"],
7031 "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="
7032 );
7033 assert_eq!(
7034 image_parts[3]["image_url"]["url"],
7035 "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYPj/HwADAgH/5ncLrgAAAABJRU5ErkJggg=="
7036 );
7037 }
7038
7039 #[test]
7040 fn out_of_order_tool_results_remain_a_contiguous_complete_batch() {
7041 let messages = vec![
7042 Message {
7043 role: Role::Assistant,
7044 content: vec![fixture_tool_use("call_one"), fixture_tool_use("call_two")],
7045 },
7046 fixture_tool_result("call_two"),
7047 fixture_tool_result("call_one"),
7048 ];
7049
7050 let wire = build_chat_messages(None, &messages, "gpt-4o");
7051 let roles: Vec<_> = wire
7052 .iter()
7053 .map(|message| message["role"].as_str().expect("role"))
7054 .collect();
7055 assert_eq!(roles, ["assistant", "tool", "tool"]);
7056 assert_eq!(wire[1]["tool_call_id"], "call_two");
7057 assert_eq!(wire[2]["tool_call_id"], "call_one");
7058 }
7059
7060 #[test]
7061 fn incomplete_tool_result_batch_is_downgraded_before_serialization() {
7062 let messages = vec![
7063 Message {
7064 role: Role::Assistant,
7065 content: vec![fixture_tool_use("call_one"), fixture_tool_use("call_two")],
7066 },
7067 fixture_tool_result("call_one"),
7068 ];
7069
7070 let wire = build_chat_messages(None, &messages, "gpt-4o");
7071 assert!(
7072 !wire
7073 .iter()
7074 .any(|message| message.get("tool_calls").is_some()),
7075 "an incomplete tool batch must not reach the provider: {wire:?}"
7076 );
7077 assert!(
7078 !wire
7079 .iter()
7080 .any(|message| message["role"].as_str() == Some("tool")),
7081 "the partial result must be removed with its incomplete call batch: {wire:?}"
7082 );
7083 }
7084
7085 #[test]
7086 fn interleaved_tool_result_batch_is_downgraded_before_serialization() {
7087 let messages = vec![
7088 Message {
7089 role: Role::Assistant,
7090 content: vec![
7091 ContentBlock::ToolUse {
7092 id: "call_one".to_string(),
7093 name: "read".to_string(),
7094 input: serde_json::json!({"path": "first.png"}),
7095 caller: None,
7096 thought_signature: None,
7097 },
7098 ContentBlock::ToolUse {
7099 id: "call_two".to_string(),
7100 name: "read".to_string(),
7101 input: serde_json::json!({"path": "second.png"}),
7102 caller: None,
7103 thought_signature: None,
7104 },
7105 ],
7106 },
7107 Message {
7108 role: Role::User,
7109 content: vec![ContentBlock::ToolResult {
7110 tool_use_id: "call_one".to_string(),
7111 content: "first screenshot captured".to_string(),
7112 is_error: Some(false),
7113 content_blocks: Some(vec![serde_json::json!({
7114 "type": "image",
7115 "mime_type": "image/png",
7116 "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==",
7117 })]),
7118 }],
7119 },
7120 Message {
7121 role: Role::User,
7122 content: vec![
7123 ContentBlock::Text {
7124 text: "interloper".to_string(),
7125 cache_control: None,
7126 },
7127 ContentBlock::ToolResult {
7128 tool_use_id: "call_two".to_string(),
7129 content: "second screenshot captured".to_string(),
7130 is_error: Some(false),
7131 content_blocks: Some(vec![serde_json::json!({
7132 "type": "image",
7133 "mime_type": "image/png",
7134 "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==",
7135 })]),
7136 },
7137 ],
7138 },
7139 ];
7140
7141 let wire = build_chat_messages(None, &messages, "gpt-4o");
7142 assert!(
7143 !wire
7144 .iter()
7145 .any(|message| message.get("tool_calls").is_some()),
7146 "a non-contiguous tool batch must not reach the provider: {wire:?}"
7147 );
7148 assert!(
7149 !wire
7150 .iter()
7151 .any(|message| message["role"].as_str() == Some("tool")),
7152 "orphaned tool replies must be removed with their stripped call batch: {wire:?}"
7153 );
7154 assert!(
7155 !wire.iter().any(|message| {
7156 message["content"].as_array().is_some_and(|parts| {
7157 parts
7158 .iter()
7159 .any(|part| part["type"].as_str() == Some("image_url"))
7160 })
7161 }),
7162 "images from a stripped tool batch must not survive as user input: {wire:?}"
7163 );
7164 }
7165
7166 #[test]
7167 fn duplicate_tool_call_ids_are_downgraded_before_serialization() {
7168 let messages = vec![
7169 Message {
7170 role: Role::Assistant,
7171 content: vec![
7172 ContentBlock::ToolUse {
7173 id: "duplicate".to_string(),
7174 name: "read".to_string(),
7175 input: serde_json::json!({"path": "first.png"}),
7176 caller: None,
7177 thought_signature: None,
7178 },
7179 ContentBlock::ToolUse {
7180 id: "duplicate".to_string(),
7181 name: "read".to_string(),
7182 input: serde_json::json!({"path": "second.png"}),
7183 caller: None,
7184 thought_signature: None,
7185 },
7186 ],
7187 },
7188 Message {
7189 role: Role::User,
7190 content: vec![ContentBlock::ToolResult {
7191 tool_use_id: "duplicate".to_string(),
7192 content: "one result for two calls".to_string(),
7193 is_error: Some(false),
7194 content_blocks: Some(vec![serde_json::json!({
7195 "type": "image",
7196 "mime_type": "image/png",
7197 "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==",
7198 })]),
7199 }],
7200 },
7201 ];
7202
7203 let wire = build_chat_messages(None, &messages, "gpt-4o");
7204 assert!(
7205 !wire
7206 .iter()
7207 .any(|message| message.get("tool_calls").is_some()),
7208 "duplicate call IDs cannot satisfy two tool calls: {wire:?}"
7209 );
7210 assert!(
7211 !wire
7212 .iter()
7213 .any(|message| message["role"].as_str() == Some("tool")),
7214 "the ambiguous tool result must be removed with the stripped batch: {wire:?}"
7215 );
7216 assert!(
7217 !wire.iter().any(|message| {
7218 message["content"].as_array().is_some_and(|parts| {
7219 parts
7220 .iter()
7221 .any(|part| part["type"].as_str() == Some("image_url"))
7222 })
7223 }),
7224 "media from an ambiguous duplicate-ID batch must not survive: {wire:?}"
7225 );
7226 }
7227 }
7228
7229 #[cfg(test)]
7230 mod mistral_reasoning_tests {
7231 use super::*;
7232
7233 fn request_with_assistant_thinking_and_tool() -> MessageRequest {
7234 MessageRequest {
7235 model: "mistral-medium-latest".to_string(),
7236 messages: vec![
7237 Message {
7238 role: Role::Assistant,
7239 content: vec![
7240 ContentBlock::Thinking {
7241 thinking: "Inspect the current state before calling the tool."
7242 .to_string(),
7243 signature: None,
7244 state: None,
7245 },
7246 ContentBlock::Text {
7247 text: "I will inspect it now.".to_string(),
7248 cache_control: None,
7249 },
7250 ContentBlock::ToolUse {
7251 id: "call-1".to_string(),
7252 name: "read_file".to_string(),
7253 input: json!({"path": "README.md"}),
7254 caller: None,
7255 thought_signature: None,
7256 },
7257 ],
7258 },
7259 Message {
7260 role: Role::User,
7261 content: vec![ContentBlock::ToolResult {
7262 tool_use_id: "call-1".to_string(),
7263 content: "contents".to_string(),
7264 is_error: None,
7265 content_blocks: None,
7266 }],
7267 },
7268 ],
7269 max_tokens: 64,
7270 system: None,
7271 tools: None,
7272 tool_choice: None,
7273 metadata: None,
7274 thinking: None,
7275 reasoning_effort: Some("high".to_string()),
7276 stream: None,
7277 temperature: None,
7278 top_p: None,
7279 }
7280 }
7281
7282 #[test]
7283 fn mistral_effort_wire_value_covers_codewhale_tiers() {
7284 assert_eq!(mistral_reasoning_effort_wire_value("off"), Some("none"));
7285 assert_eq!(
7286 mistral_reasoning_effort_wire_value("disabled"),
7287 Some("none")
7288 );
7289 assert_eq!(mistral_reasoning_effort_wire_value("none"), Some("none"));
7290 assert_eq!(mistral_reasoning_effort_wire_value("false"), Some("none"));
7291 assert_eq!(mistral_reasoning_effort_wire_value("high"), Some("high"));
7292 assert_eq!(mistral_reasoning_effort_wire_value("xhigh"), Some("high"));
7293 assert_eq!(mistral_reasoning_effort_wire_value("max"), Some("high"));
7294 assert_eq!(mistral_reasoning_effort_wire_value("ultra"), Some("high"));
7295 assert_eq!(
7296 mistral_reasoning_effort_wire_value("ultracode"),
7297 Some("high")
7298 );
7299 // Intermediate tiers must be omitted so the request falls back to
7300 // Mistral's own default rather than 400 code 3051 on unsupported
7301 // values like "low"/"medium" that the server does not accept today.
7302 assert_eq!(mistral_reasoning_effort_wire_value("low"), None);
7303 assert_eq!(mistral_reasoning_effort_wire_value("medium"), None);
7304 assert_eq!(mistral_reasoning_effort_wire_value("mid"), None);
7305 assert_eq!(mistral_reasoning_effort_wire_value("minimal"), None);
7306 }
7307
7308 #[test]
7309 fn mistral_model_gate_only_matches_reasoning_capable_families() {
7310 assert!(mistral_model_supports_reasoning("mistral-medium-latest"));
7311 assert!(mistral_model_supports_reasoning("mistral-medium-3-5"));
7312 assert!(mistral_model_supports_reasoning("mistral-small-latest"));
7313 assert!(mistral_model_supports_reasoning("mistral-small-2603"));
7314 assert!(mistral_model_supports_reasoning("magistral-small-latest"));
7315 assert!(mistral_model_supports_reasoning("MISTRAL-MEDIUM-LATEST"));
7316 assert!(!mistral_model_supports_reasoning("mistral-code-latest"));
7317 assert!(!mistral_model_supports_reasoning("codestral-latest"));
7318 assert!(!mistral_model_supports_reasoning("mistral-large-latest"));
7319 assert!(!mistral_model_supports_reasoning("mistral-nemo-2407"));
7320 }
7321
7322 #[test]
7323 fn mistral_route_shaper_writes_reasoning_only_for_supported_models() {
7324 let mut body = json!({"model": "mistral-medium-latest"});
7325 apply_mistral_route_reasoning_controls(
7326 &mut body,
7327 ApiProvider::Mistral,
7328 crate::config::DEFAULT_MISTRAL_BASE_URL,
7329 "mistral-medium-latest",
7330 Some("high"),
7331 );
7332 assert_eq!(body["reasoning_effort"], json!("high"));
7333
7334 let mut body = json!({"model": "mistral-code-latest"});
7335 apply_mistral_route_reasoning_controls(
7336 &mut body,
7337 ApiProvider::Mistral,
7338 crate::config::DEFAULT_MISTRAL_BASE_URL,
7339 "mistral-code-latest",
7340 Some("high"),
7341 );
7342 assert!(
7343 body.get("reasoning_effort").is_none(),
7344 "non-reasoning models must never see reasoning_effort (Mistral 400s on 3051): {body}"
7345 );
7346
7347 let mut body = json!({"model": "mistral-medium-latest", "reasoning_effort": "stale"});
7348 apply_mistral_route_reasoning_controls(
7349 &mut body,
7350 ApiProvider::Mistral,
7351 crate::config::DEFAULT_MISTRAL_BASE_URL,
7352 "mistral-medium-latest",
7353 Some("low"),
7354 );
7355 assert!(
7356 body.get("reasoning_effort").is_none(),
7357 "intermediate tiers must be stripped rather than sent unsupported: {body}"
7358 );
7359
7360 // Non-Mistral providers must not be touched by this shaper.
7361 let mut body = json!({"model": "deepseek-v4-pro", "reasoning_effort": "high"});
7362 apply_mistral_route_reasoning_controls(
7363 &mut body,
7364 ApiProvider::Deepseek,
7365 crate::config::DEFAULT_MISTRAL_BASE_URL,
7366 "deepseek-v4-pro",
7367 Some("high"),
7368 );
7369 assert_eq!(body["reasoning_effort"], json!("high"));
7370
7371 let mut body = json!({
7372 "model": "mistral-medium-latest",
7373 "thinking": {"type": "enabled"},
7374 "reasoning_effort": "stale",
7375 });
7376 apply_mistral_route_reasoning_controls(
7377 &mut body,
7378 ApiProvider::Mistral,
7379 "https://gateway.example.test/v1",
7380 "mistral-medium-latest",
7381 Some("high"),
7382 );
7383 assert!(body.get("thinking").is_none());
7384 assert!(body.get("reasoning_effort").is_none());
7385
7386 let mut native = json!({"model": "magistral-small-latest"});
7387 apply_mistral_route_reasoning_controls(
7388 &mut native,
7389 ApiProvider::Mistral,
7390 crate::config::DEFAULT_MISTRAL_BASE_URL,
7391 "magistral-small-latest",
7392 Some("off"),
7393 );
7394 assert!(
7395 native.get("reasoning_effort").is_none(),
7396 "legacy native Magistral is always-reasoning and does not use the adjustable effort field"
7397 );
7398 }
7399
7400 #[test]
7401 fn mistral_wire_dialect_is_limited_to_exact_first_party_routes() {
7402 for official in [
7403 "https://api.mistral.ai/v1",
7404 "https://api.eu.mistral.ai/v1/",
7405 "https://api.us.mistral.ai/v1",
7406 ] {
7407 assert!(is_exact_mistral_chat_route(ApiProvider::Mistral, official));
7408 }
7409 for neighbor in [
7410 "http://api.mistral.ai/v1",
7411 "https://api.mistral.ai/v2",
7412 "https://proxy.example.test/v1",
7413 "https://api.mistral.ai.evil.test/v1",
7414 ] {
7415 assert!(!is_exact_mistral_chat_route(ApiProvider::Mistral, neighbor));
7416 }
7417 assert!(!is_exact_mistral_chat_route(
7418 ApiProvider::Openai,
7419 crate::config::DEFAULT_MISTRAL_BASE_URL,
7420 ));
7421 assert_eq!(
7422 reasoning_stream_style_for_route(
7423 ApiProvider::Mistral,
7424 crate::config::DEFAULT_MISTRAL_BASE_URL,
7425 "mistral-medium-latest",
7426 None,
7427 ),
7428 ReasoningStreamStyle::MistralBlocks
7429 );
7430 assert_eq!(
7431 reasoning_stream_style_for_route(
7432 ApiProvider::Mistral,
7433 "https://gateway.example.test/v1",
7434 "mistral-medium-latest",
7435 None,
7436 ),
7437 ReasoningStreamStyle::None
7438 );
7439 }
7440
7441 #[test]
7442 fn extract_mistral_polymorphic_content_flattens_thinking_and_text() {
7443 // Non-reasoning response: plain string content. Extractor returns
7444 // (None, None) so the shared string fallback still runs.
7445 let plain = json!({"content": "Hello world"});
7446 assert_eq!(extract_mistral_polymorphic_content(&plain), (None, None));
7447
7448 // Missing content: no panic, returns (None, None).
7449 let empty = json!({});
7450 assert_eq!(extract_mistral_polymorphic_content(&empty), (None, None));
7451
7452 // Reasoning response: nested thinking array + text block.
7453 let reasoning = json!({"content": [
7454 {"type": "thinking", "thinking": [
7455 {"type": "text", "text": "First "},
7456 {"type": "text", "text": "second."},
7457 ], "closed": true},
7458 {"type": "text", "text": "Final answer."},
7459 ]});
7460 let (thinking, text) = extract_mistral_polymorphic_content(&reasoning);
7461 assert_eq!(thinking.as_deref(), Some("First second."));
7462 assert_eq!(text.as_deref(), Some("Final answer."));
7463
7464 // Thinking-only chunk (mid-stream) with no closing text yet.
7465 let thinking_only = json!({"content": [
7466 {"type": "thinking", "thinking": [{"type": "text", "text": "still thinking"}]},
7467 ]});
7468 let (thinking, text) = extract_mistral_polymorphic_content(&thinking_only);
7469 assert_eq!(thinking.as_deref(), Some("still thinking"));
7470 assert_eq!(text, None);
7471 }
7472
7473 #[test]
7474 fn reshape_mistral_messages_reconstructs_polymorphic_shape_for_assistant_replay() {
7475 // Assistant message with stored reasoning_content is reshaped into
7476 // Mistral's polymorphic content-as-array shape.
7477 let mut messages = vec![
7478 json!({"role": "user", "content": "compute 3+4"}),
7479 json!({
7480 "role": "assistant",
7481 "content": "The answer is 7.",
7482 "reasoning_content": "Let me add 3 and 4 to get 7.",
7483 }),
7484 json!({"role": "user", "content": "now multiply by 2"}),
7485 ];
7486 reshape_mistral_messages_for_reasoning_replay(&mut messages);
7487
7488 assert_eq!(messages[0]["role"], "user");
7489 assert!(
7490 messages[0]["content"].is_string(),
7491 "user turns are left untouched: {}",
7492 messages[0]
7493 );
7494
7495 assert_eq!(messages[1]["role"], "assistant");
7496 assert!(
7497 messages[1].get("reasoning_content").is_none(),
7498 "reasoning_content field must be removed after reshape: {}",
7499 messages[1]
7500 );
7501 let content = messages[1]["content"]
7502 .as_array()
7503 .expect("assistant content is now an array");
7504 assert_eq!(content.len(), 2);
7505 assert_eq!(content[0]["type"], "thinking");
7506 assert_eq!(content[0]["closed"], true);
7507 assert_eq!(content[0]["thinking"][0]["type"], "text");
7508 assert_eq!(
7509 content[0]["thinking"][0]["text"],
7510 "Let me add 3 and 4 to get 7."
7511 );
7512 assert_eq!(content[1]["type"], "text");
7513 assert_eq!(content[1]["text"], "The answer is 7.");
7514
7515 // Assistant with no reasoning stays as-is (plain string content).
7516 let mut plain = vec![json!({"role": "assistant", "content": "hi"})];
7517 reshape_mistral_messages_for_reasoning_replay(&mut plain);
7518 assert_eq!(plain[0]["content"], "hi");
7519
7520 // Empty reasoning is treated as absent — no reshape.
7521 let mut empty = vec![json!({
7522 "role": "assistant",
7523 "content": "hi",
7524 "reasoning_content": " ",
7525 })];
7526 reshape_mistral_messages_for_reasoning_replay(&mut empty);
7527 assert_eq!(empty[0]["content"], "hi");
7528 }
7529
7530 #[test]
7531 fn mistral_prompt_builder_replays_stored_thinking_as_polymorphic_content() {
7532 let request = request_with_assistant_thinking_and_tool();
7533 let exact = build_chat_messages_for_request_and_provider_and_route(
7534 &request,
7535 ApiProvider::Mistral,
7536 crate::config::DEFAULT_MISTRAL_BASE_URL,
7537 );
7538 let assistant = &exact[0];
7539 assert!(assistant.get("reasoning_content").is_none());
7540 assert!(assistant.get("tool_calls").is_some());
7541 let content = assistant["content"]
7542 .as_array()
7543 .expect("exact Mistral history uses polymorphic content");
7544 assert_eq!(content[0]["type"], "thinking");
7545 assert_eq!(
7546 content[0]["thinking"][0]["text"],
7547 "Inspect the current state before calling the tool."
7548 );
7549 assert_eq!(content[1]["type"], "text");
7550 assert_eq!(content[1]["text"], "I will inspect it now.");
7551
7552 for (provider, base_url) in [
7553 (ApiProvider::Mistral, "https://gateway.example.test/v1"),
7554 (ApiProvider::Openai, crate::config::DEFAULT_MISTRAL_BASE_URL),
7555 ] {
7556 let neighbor = build_chat_messages_for_request_and_provider_and_route(
7557 &request, provider, base_url,
7558 );
7559 assert!(neighbor[0].get("reasoning_content").is_none());
7560 assert!(
7561 neighbor[0]["content"].is_string(),
7562 "unproven routes must not inherit Mistral's polymorphic dialect: {}",
7563 neighbor[0]
7564 );
7565 }
7566 }
7567
7568 #[test]
7569 fn mistral_stream_tool_call_replay_does_not_gain_reasoning_content() {
7570 let request = request_with_assistant_thinking_and_tool();
7571 let wire = build_chat_wire_body(
7572 &request,
7573 ApiProvider::Mistral,
7574 crate::config::DEFAULT_MISTRAL_BASE_URL,
7575 true,
7576 )
7577 .expect("Mistral stream wire body");
7578 let assistant = &wire.body["messages"][0];
7579 assert!(assistant["content"].is_array());
7580 assert!(assistant.get("reasoning_content").is_none());
7581 assert!(assistant.get("tool_calls").is_some());
7582 assert_eq!(wire.body["reasoning_effort"], "high");
7583 assert_eq!(wire.replay_input_tokens, None);
7584 }
7585
7586 #[test]
7587 fn mistral_nonstream_parser_is_route_isolated() {
7588 let payload = json!({
7589 "id": "chatcmpl-mistral",
7590 "model": "mistral-medium-latest",
7591 "choices": [{
7592 "finish_reason": "stop",
7593 "message": {"role": "assistant", "content": [
7594 {"type": "thinking", "thinking": [
7595 {"type": "text", "text": "private trace"}
7596 ], "closed": true},
7597 {"type": "text", "text": "public answer"}
7598 ]}
7599 }],
7600 "usage": {"prompt_tokens": 5, "completion_tokens": 2}
7601 });
7602 let mistral = parse_chat_message_for_route(
7603 &payload,
7604 ApiProvider::Mistral,
7605 crate::config::DEFAULT_MISTRAL_BASE_URL,
7606 )
7607 .expect("Mistral payload parses");
7608 assert!(matches!(
7609 &mistral.content[0],
7610 ContentBlock::Thinking { thinking, .. } if thinking == "private trace"
7611 ));
7612 assert!(matches!(
7613 &mistral.content[1],
7614 ContentBlock::Text { text, .. } if text == "public answer"
7615 ));
7616
7617 let generic = parse_chat_message(&payload).expect("generic payload parses");
7618 assert!(
7619 !generic
7620 .content
7621 .iter()
7622 .any(|block| matches!(block, ContentBlock::Thinking { .. })),
7623 "typed arrays from another provider must not be reinterpreted as Mistral thinking"
7624 );
7625 }
7626
7627 #[test]
7628 fn mistral_shared_capability_matches_wire_contract() {
7629 for model in [
7630 "mistral-medium-latest",
7631 "mistral-small-latest",
7632 "magistral-small-latest",
7633 ] {
7634 assert!(codewhale_models::model_supports_reasoning(model), "{model}");
7635 }
7636 for model in ["mistral-code-latest", "mistral-large-latest"] {
7637 assert!(
7638 !codewhale_models::model_supports_reasoning(model),
7639 "{model}"
7640 );
7641 }
7642 }
7643 }
7644
7645 #[cfg(test)]
7646 mod google_thought_signature_tests {
7647 use super::*;
7648
7649 // ── Google thought signatures (#v0.9.8 Google backend) ──────────────
7650 use crate::config::{DEFAULT_GOOGLE_BASE_URL, DEFAULT_OPENAI_BASE_URL};
7651
7652 /// One signed tool turn. `paired` false is the restart shape: the process
7653 /// died between the tool call and its result, so the terminal
7654 /// `tool_result` never reached durable history.
7655 fn signed_history(signature: Option<&str>, paired: bool) -> Vec<Message> {
7656 let mut messages = vec![
7657 Message {
7658 role: Role::User,
7659 content: vec![ContentBlock::Text {
7660 text: "Read the config.".to_string(),
7661 cache_control: None,
7662 }],
7663 },
7664 Message {
7665 role: Role::Assistant,
7666 content: vec![
7667 ContentBlock::Text {
7668 text: "Reading now.".to_string(),
7669 cache_control: None,
7670 },
7671 ContentBlock::ToolUse {
7672 id: "call-g-1".to_string(),
7673 name: "read".to_string(),
7674 input: json!({"path": "config.toml"}),
7675 caller: None,
7676 thought_signature: signature.map(str::to_string),
7677 },
7678 ],
7679 },
7680 ];
7681 if paired {
7682 messages.push(Message {
7683 role: Role::User,
7684 content: vec![ContentBlock::ToolResult {
7685 tool_use_id: "call-g-1".to_string(),
7686 content: "key = \"value\"".to_string(),
7687 is_error: None,
7688 content_blocks: None,
7689 }],
7690 });
7691 }
7692 messages
7693 }
7694
7695 fn request_from(messages: Vec<Message>) -> MessageRequest {
7696 MessageRequest {
7697 model: "gemini-3.1-pro-preview".to_string(),
7698 messages,
7699 max_tokens: 64,
7700 system: None,
7701 tools: None,
7702 tool_choice: None,
7703 metadata: None,
7704 thinking: None,
7705 reasoning_effort: Some("high".to_string()),
7706 stream: None,
7707 temperature: None,
7708 top_p: None,
7709 }
7710 }
7711
7712 fn google_request_with_signed_tool(signature: Option<&str>) -> MessageRequest {
7713 request_from(signed_history(signature, true))
7714 }
7715
7716 #[tokio::test]
7717 async fn gateway_thought_signature_rejection_explains_recovery_after_transport() {
7718 use crate::llm_client::LlmClient;
7719 use wiremock::matchers::{method, path};
7720 use wiremock::{Mock, MockServer, ResponseTemplate};
7721
7722 let _ = rustls::crypto::ring::default_provider().install_default();
7723 // An unsigned replay must reach the gateway: it may manage Google's
7724 // signatures itself. Only an actual rejection warrants recovery advice.
7725 for streaming in [false, true] {
7726 for status in [200, 400] {
7727 let server = MockServer::start().await;
7728 let response = if status == 400 {
7729 ResponseTemplate::new(status).set_body_json(json!({
7730 "error": {
7731 "code": 400,
7732 "message": "Function call is missing a thought_signature in functionCall parts."
7733 }
7734 }))
7735 } else if streaming {
7736 ResponseTemplate::new(status)
7737 .insert_header("content-type", "text/event-stream")
7738 .set_body_string("data: [DONE]\n\n")
7739 } else {
7740 ResponseTemplate::new(status).set_body_json(json!({
7741 "id": "gateway-replay",
7742 "model": "gemini-3.1-pro-preview",
7743 "choices": [{
7744 "message": {"role": "assistant", "content": "Done."},
7745 "finish_reason": "stop"
7746 }]
7747 }))
7748 };
7749 Mock::given(method("POST"))
7750 .and(path("/v1/chat/completions"))
7751 .respond_with(response)
7752 .expect(1)
7753 .mount(&server)
7754 .await;
7755
7756 let mut client = CodewhaleClient::new(&crate::config::Config {
7757 provider: Some("openai".to_string()),
7758 providers: Some(crate::config::ProvidersConfig {
7759 openai: crate::config::ProviderConfig {
7760 api_key: Some("gateway-test-key".to_string()),
7761 base_url: Some(format!("{}/v1", server.uri())),
7762 model: Some("gemini-3.1-pro-preview".to_string()),
7763 ..crate::config::ProviderConfig::default()
7764 },
7765 ..crate::config::ProvidersConfig::default()
7766 }),
7767 ..crate::config::Config::default()
7768 })
7769 .expect("gateway client");
7770 client.isolated_request_state = true;
7771 let request = google_request_with_signed_tool(None);
7772 let result = if streaming {
7773 client.create_message_stream(request).await.map(|_| ())
7774 } else {
7775 client
7776 .create_message_without_response_cache(request)
7777 .await
7778 .map(|_| ())
7779 };
7780 if status == 400 {
7781 let error = result.expect_err("gateway rejects unsigned replay");
7782 let message = error.to_string();
7783 assert!(message.contains("built-in `google` provider"), "{message}");
7784 assert!(message.contains("start a new session"), "{message}");
7785 assert!(matches!(
7786 error.downcast_ref::<crate::llm_client::LlmError>(),
7787 Some(crate::llm_client::LlmError::InvalidRequest { status: 400, .. })
7788 ));
7789 } else {
7790 result.expect("gateway-managed signatures must still work");
7791 }
7792 server.verify().await;
7793 }
7794 }
7795 }
7796
7797 #[test]
7798 fn google_route_round_trips_thought_signatures_on_replayed_tool_calls() {
7799 let request = google_request_with_signed_tool(Some("SIG-abc123"));
7800 let messages = build_chat_messages_for_request_and_provider_and_route(
7801 &request,
7802 ApiProvider::Google,
7803 DEFAULT_GOOGLE_BASE_URL,
7804 );
7805 let assistant = messages
7806 .iter()
7807 .find(|m| m.get("role") == Some(&json!("assistant")))
7808 .expect("assistant replay message");
7809 let signature = assistant
7810 .pointer("/tool_calls/0/extra_content/google/thought_signature")
7811 .and_then(serde_json::Value::as_str);
7812 assert_eq!(signature, Some("SIG-abc123"));
7813 }
7814
7815 #[test]
7816 fn google_route_fails_closed_when_replayed_signature_is_missing() {
7817 let request = google_request_with_signed_tool(None);
7818 let error = build_chat_wire_body(
7819 &request,
7820 ApiProvider::Google,
7821 DEFAULT_GOOGLE_BASE_URL,
7822 false,
7823 )
7824 .err()
7825 .expect("missing signature must fail closed before transport");
7826 assert!(
7827 error.to_string().contains("thought signature"),
7828 "error must name the missing signature: {error}"
7829 );
7830 }
7831
7832 /// Google names the same model `gemini-3-pro` and `models/gemini-3-pro` on
7833 /// this endpoint. The prefixed spelling used to match none of the thinking
7834 /// families, so the model that most needs a signature was treated as one
7835 /// that needs none and the replay reached Google unsigned (#6018).
7836 #[test]
7837 fn google_route_fails_closed_for_a_models_prefixed_thinking_id() {
7838 let mut request = google_request_with_signed_tool(None);
7839 request.model = "models/gemini-3-pro-preview".to_string();
7840 let error = build_chat_wire_body(
7841 &request,
7842 ApiProvider::Google,
7843 DEFAULT_GOOGLE_BASE_URL,
7844 false,
7845 )
7846 .err()
7847 .expect("a models/-prefixed thinking id must fail closed like its bare spelling");
7848 assert!(
7849 error.to_string().contains("thought signature"),
7850 "error must name the missing signature: {error}"
7851 );
7852 }
7853
7854 #[test]
7855 fn google_missing_signature_is_a_warning_not_an_error_for_flash_lite() {
7856 // 2.5 Flash-Lite ships thinking off; Google may legitimately omit
7857 // signatures there, so replay proceeds.
7858 let mut request = google_request_with_signed_tool(None);
7859 request.model = "gemini-2.5-flash-lite".to_string();
7860 build_chat_wire_body(
7861 &request,
7862 ApiProvider::Google,
7863 DEFAULT_GOOGLE_BASE_URL,
7864 false,
7865 )
7866 .expect("flash-lite replay must not require a signature");
7867 }
7868
7869 #[test]
7870 fn non_google_routes_never_see_google_extra_content() {
7871 let request = google_request_with_signed_tool(Some("SIG-abc123"));
7872 let messages = build_chat_messages_for_request_and_provider_and_route(
7873 &request,
7874 ApiProvider::Openai,
7875 DEFAULT_OPENAI_BASE_URL,
7876 );
7877 for message in &messages {
7878 if let Some(tool_calls) = message.get("tool_calls").and_then(|v| v.as_array()) {
7879 for call in tool_calls {
7880 assert!(
7881 call.get("extra_content").is_none(),
7882 "Google-only fields must not leak to other providers"
7883 );
7884 }
7885 }
7886 }
7887 }
7888
7889 #[test]
7890 fn google_neighbor_base_url_does_not_get_google_dialect() {
7891 // A Google provider row pointed at some other gateway must not
7892 // carry signatures or fail closed: the dialect binds to the exact
7893 // official route, not to provider identity alone.
7894 let request = google_request_with_signed_tool(None);
7895 build_chat_wire_body(
7896 &request,
7897 ApiProvider::Google,
7898 "https://gateway.example.com/v1",
7899 false,
7900 )
7901 .expect("non-official Google base URL must not require signatures");
7902 let messages = build_chat_messages_for_request_and_provider_and_route(
7903 &google_request_with_signed_tool(Some("SIG")),
7904 ApiProvider::Google,
7905 "https://gateway.example.com/v1",
7906 );
7907 assert!(
7908 messages
7909 .iter()
7910 .all(|m| m.pointer("/tool_calls/0/extra_content").is_none()),
7911 "signatures must not be sent to a non-Google endpoint"
7912 );
7913 }
7914
7915 /// The manually configured OpenAI-compatible row (#1519,
7916 /// `ApiProvider::Custom`) pointed at Google's OpenAI-compat endpoint is
7917 /// byte-for-byte the same endpoint as the built-in `google` row. C22: it
7918 /// used to fail the `provider == Google` half of the route gate, so its
7919 /// signatures were stripped on replay with no warning and later signed
7920 /// tool turns failed. The gate now binds to the endpoint.
7921 #[test]
7922 fn manually_configured_openai_compatible_google_endpoint_replays_signatures() {
7923 let request = google_request_with_signed_tool(Some("SIG-abc123"));
7924 for base_url in [
7925 DEFAULT_GOOGLE_BASE_URL,
7926 "https://generativelanguage.googleapis.com/v1beta/openai",
7927 "https://GenerativeLanguage.googleapis.com/v1beta/openai/",
7928 ] {
7929 let messages = build_chat_messages_for_request_and_provider_and_route(
7930 &request,
7931 ApiProvider::Custom,
7932 base_url,
7933 );
7934 let assistant = messages
7935 .iter()
7936 .find(|m| m.get("role") == Some(&json!("assistant")))
7937 .expect("assistant replay message");
7938 assert_eq!(
7939 assistant
7940 .pointer("/tool_calls/0/extra_content/google/thought_signature")
7941 .and_then(serde_json::Value::as_str),
7942 Some("SIG-abc123"),
7943 "custom row at Google's endpoint must replay the signature ({base_url})"
7944 );
7945 }
7946 }
7947
7948 /// Signature preservation is a property of the endpoint, never of the
7949 /// reasoning setting: an operator who turns reasoning off (or whose
7950 /// effort is simply absent) still has to replay signed tool history.
7951 #[test]
7952 fn signatures_survive_replay_regardless_of_the_reasoning_setting() {
7953 for effort in [None, Some("off"), Some("low"), Some("high")] {
7954 for (provider, base_url) in [
7955 (ApiProvider::Google, DEFAULT_GOOGLE_BASE_URL),
7956 (
7957 ApiProvider::Custom,
7958 "https://generativelanguage.googleapis.com/v1beta/openai",
7959 ),
7960 ] {
7961 let mut request = google_request_with_signed_tool(Some("SIG-abc123"));
7962 request.reasoning_effort = effort.map(str::to_string);
7963 let body = build_chat_wire_body(&request, provider, base_url, true)
7964 .expect("signed replay builds on a signature-bearing route");
7965 let assistant = body.body["messages"]
7966 .as_array()
7967 .expect("messages")
7968 .iter()
7969 .find(|m| m.get("role") == Some(&json!("assistant")))
7970 .expect("assistant replay message");
7971 assert_eq!(
7972 assistant
7973 .pointer("/tool_calls/0/extra_content/google/thought_signature")
7974 .and_then(serde_json::Value::as_str),
7975 Some("SIG-abc123"),
7976 "reasoning={effort:?} must not govern signature replay ({base_url})"
7977 );
7978 }
7979 }
7980 }
7981
7982 /// Fail closed on the manually configured row too — the missing-signature
7983 /// error is the useful feedback that replaces a silent strip.
7984 #[test]
7985 fn custom_row_at_google_endpoint_fails_closed_without_a_signature() {
7986 let request = google_request_with_signed_tool(None);
7987 let error = build_chat_wire_body(
7988 &request,
7989 ApiProvider::Custom,
7990 "https://generativelanguage.googleapis.com/v1beta/openai",
7991 false,
7992 )
7993 .err()
7994 .expect("missing signature must fail closed before transport");
7995 let rendered = error.to_string();
7996 assert!(
7997 rendered.contains("thought signature") && rendered.contains("call-g-1"),
7998 "error must name the missing signature and the tool call: {rendered}"
7999 );
8000 }
8001
8002 /// A custom row pointed somewhere else is still a foreign gateway: the
8003 /// signature is stripped, and the strip reports how much it removed so
8004 /// the drop is never silent.
8005 #[test]
8006 fn custom_row_off_google_endpoint_strips_and_reports_signatures() {
8007 let request = google_request_with_signed_tool(Some("SIG-abc123"));
8008 let mut messages = build_chat_messages_for_request_and_provider_and_route(
8009 &request,
8010 ApiProvider::Google,
8011 DEFAULT_GOOGLE_BASE_URL,
8012 );
8013 assert_eq!(
8014 strip_google_tool_call_extra_content(&mut messages),
8015 1,
8016 "the strip must report the signatures it dropped"
8017 );
8018 assert!(
8019 messages
8020 .iter()
8021 .all(|m| m.pointer("/tool_calls/0/extra_content").is_none()),
8022 "stripped history must carry no Google-only fields"
8023 );
8024 let via_route = build_chat_messages_for_request_and_provider_and_route(
8025 &request,
8026 ApiProvider::Custom,
8027 "https://gateway.example.com/v1",
8028 );
8029 assert!(
8030 via_route
8031 .iter()
8032 .all(|m| m.pointer("/tool_calls/0/extra_content").is_none()),
8033 "signatures must not reach a non-Google endpoint"
8034 );
8035 }
8036
8037 #[test]
8038 fn google_reasoning_uses_compatible_effort_without_rejected_native_fields() {
8039 // Wire examples and model limits from Google's compatibility docs.
8040 // Cover the actual request builder, both transport modes and both
8041 // ways a fresh install can configure the official endpoint (#6018).
8042 for (model, effort, expected) in [
8043 ("gemini-3.1-pro-preview", Some("low"), Some("low")),
8044 ("gemini-3.1-pro-preview", Some("medium"), Some("medium")),
8045 ("gemini-3.1-pro-preview", Some("high"), Some("high")),
8046 ("gemini-3.1-pro-preview", Some("max"), Some("high")),
8047 ("gemini-3.5-flash-lite", Some("off"), Some("minimal")),
8048 ("gemini-2.5-flash", Some("off"), Some("none")),
8049 ("models/gemini-2.5-flash-lite", Some("off"), Some("none")),
8050 ("gemini-2.5-pro", Some("off"), Some("minimal")),
8051 ("gemini-3.1-pro-preview", None, None),
8052 ] {
8053 for provider in [ApiProvider::Google, ApiProvider::Custom] {
8054 for streaming in [false, true] {
8055 let mut request = google_request_with_signed_tool(Some("SIG"));
8056 request.model = model.to_string();
8057 request.reasoning_effort = effort.map(str::to_string);
8058 let wire = build_chat_wire_body(
8059 &request,
8060 provider,
8061 DEFAULT_GOOGLE_BASE_URL,
8062 streaming,
8063 )
8064 .expect("valid signed Google request");
8065 assert_eq!(
8066 wire.body.get("reasoning_effort").and_then(Value::as_str),
8067 expected,
8068 "{model}: {effort:?}, {provider:?}, streaming={streaming}"
8069 );
8070 assert!(wire.body.get("google").is_none());
8071 assert!(wire.body.get("extra_body").is_none());
8072 assert!(wire.body.get("thinking").is_none());
8073 }
8074 }
8075 }
8076 }
8077
8078 #[test]
8079 fn google_reasoning_control_does_not_rewrite_other_endpoints() {
8080 for provider in [ApiProvider::Google, ApiProvider::Custom] {
8081 let request = google_request_with_signed_tool(Some("SIG"));
8082 let wire =
8083 build_chat_wire_body(&request, provider, "https://gateway.example.com/v1", false)
8084 .expect("valid gateway request");
8085 assert!(wire.body.get("reasoning_effort").is_none());
8086 assert!(wire.body.get("google").is_none());
8087 assert!(wire.body.get("extra_body").is_none());
8088 }
8089 }
8090
8091 #[test]
8092 fn google_signature_captured_from_non_streaming_tool_call() {
8093 let payload = json!({
8094 "id": "resp-1",
8095 "model": "gemini-3.1-pro-preview",
8096 "choices": [{
8097 "index": 0,
8098 "finish_reason": "tool_calls",
8099 "message": {
8100 "role": "assistant",
8101 "content": null,
8102 "tool_calls": [{
8103 "id": "call-g-9",
8104 "type": "function",
8105 "function": {
8106 "name": "read",
8107 "arguments": "{\"path\":\"x\"}"
8108 },
8109 "extra_content": {
8110 "google": { "thought_signature": "SIG-stream" }
8111 }
8112 }]
8113 }
8114 }],
8115 "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
8116 });
8117 let response = parse_chat_message(&payload).expect("parses");
8118 let signature = response.content.iter().find_map(|block| match block {
8119 ContentBlock::ToolUse {
8120 thought_signature, ..
8121 } => thought_signature.clone(),
8122 _ => None,
8123 });
8124 assert_eq!(signature.as_deref(), Some("SIG-stream"));
8125 }
8126
8127 #[test]
8128 fn google_signature_captured_from_streaming_first_chunk() {
8129 let chunk = json!({
8130 "choices": [{
8131 "index": 0,
8132 "delta": {
8133 "tool_calls": [{
8134 "index": 0,
8135 "id": "call-g-7",
8136 "type": "function",
8137 "function": { "name": "read", "arguments": "{}" },
8138 "extra_content": {
8139 "google": { "thought_signature": "SIG-delta" }
8140 }
8141 }]
8142 }
8143 }]
8144 });
8145 let mut content_index = 0u32;
8146 let mut text_started = false;
8147 let mut thinking_started = false;
8148 let mut tool_indices = std::collections::HashMap::new();
8149 let mut reasoning_buffers = std::collections::HashMap::new();
8150 let mut inline_tags = InlineReasoningTagState::default();
8151 let events = parse_sse_chunk_with_reasoning_style(
8152 &chunk,
8153 &mut content_index,
8154 &mut text_started,
8155 &mut thinking_started,
8156 &mut tool_indices,
8157 &mut reasoning_buffers,
8158 &mut inline_tags,
8159 ReasoningStreamStyle::None,
8160 );
8161 let signature = events.iter().find_map(|event| match event {
8162 StreamEvent::ContentBlockStart {
8163 content_block:
8164 ContentBlockStart::ToolUse {
8165 thought_signature, ..
8166 },
8167 ..
8168 } => thought_signature.clone(),
8169 _ => None,
8170 });
8171 assert_eq!(signature.as_deref(), Some("SIG-delta"));
8172 }
8173
8174 /// Run the production restart/resume chain over a message history:
8175 /// persist to disk, reload, repair crashed tool pairs, then project for
8176 /// restore exactly as `apply.rs` does before assigning `api_messages`.
8177 /// Returns the recovery receipt, the restored messages, and the raw
8178 /// session JSON as it actually sits on disk.
8179 fn resumed(
8180 messages: &[Message],
8181 ) -> (
8182 crate::session_manager::SessionRecovery,
8183 Vec<Message>,
8184 String,
8185 ) {
8186 let dir = tempfile::tempdir().expect("tempdir");
8187 let manager = crate::session_manager::SessionManager::new(dir.path().join("sessions"))
8188 .expect("session manager");
8189 let session = crate::session_manager::create_saved_session(
8190 messages,
8191 "gemini-3.1-pro-preview",
8192 dir.path(),
8193 0,
8194 None,
8195 );
8196 let id = session.metadata.id.clone();
8197 let path = manager.save_session(&session).expect("save session");
8198 let on_disk = std::fs::read_to_string(&path).expect("read persisted session");
8199 let recovery = manager
8200 .recover_session_for_resume(&id)
8201 .expect("recover session for resume");
8202 let restored =
8203 crate::runtime_handoff::project_messages_for_restore(&recovery.session.messages);
8204 (recovery, restored, on_disk)
8205 }
8206
8207 fn replayed_signature(body: &serde_json::Value) -> Option<String> {
8208 body["messages"]
8209 .as_array()
8210 .expect("wire messages")
8211 .iter()
8212 // The crash repair appends a trailing assistant text receipt, so
8213 // find the tool-call message by shape, never by index.
8214 .find(|message| message.get("tool_calls").is_some())
8215 .expect("assistant tool-call message")
8216 .pointer("/tool_calls/0/extra_content/google/thought_signature")
8217 .and_then(serde_json::Value::as_str)
8218 .map(str::to_string)
8219 }
8220
8221 /// C22 done-evidence item 3: restarting and resuming must continue the
8222 /// signed history. This walks the whole persistence chain — durable JSON,
8223 /// reload, restore projection, wire body — because the signature can be
8224 /// lost at any of them, and a serde round-trip alone would prove none of
8225 /// it. Local unit evidence only: it does not exercise a real Gemini call.
8226 #[test]
8227 fn resumed_google_session_replays_signed_tool_calls() {
8228 let original = signed_history(Some("SIG-abc123"), true);
8229 let (recovery, restored, on_disk) = resumed(&original);
8230
8231 assert!(
8232 !recovery.changed,
8233 "a fully paired history needs no repair on resume"
8234 );
8235 assert_eq!(
8236 recovery.session.messages, original,
8237 "reload must return the signed history unchanged"
8238 );
8239 assert_eq!(
8240 restored, original,
8241 "the restore projection must not touch signed tool history"
8242 );
8243 assert!(
8244 on_disk.contains("\"thought_signature\"") && on_disk.contains("SIG-abc123"),
8245 "the signature must reach durable storage, not just live memory"
8246 );
8247
8248 for (provider, base_url) in [
8249 (ApiProvider::Google, DEFAULT_GOOGLE_BASE_URL),
8250 (
8251 ApiProvider::Custom,
8252 "https://generativelanguage.googleapis.com/v1beta/openai",
8253 ),
8254 ] {
8255 let body =
8256 build_chat_wire_body(&request_from(restored.clone()), provider, base_url, true)
8257 .expect("a resumed signed history must build, not fail closed");
8258 assert_eq!(
8259 replayed_signature(&body.body).as_deref(),
8260 Some("SIG-abc123"),
8261 "resumed history must still replay the signature ({base_url})"
8262 );
8263 }
8264 }
8265
8266 /// The real restart shape: the process died between the tool call and its
8267 /// result, so resume runs `repair_tool_call_pairs`, which rebuilds every
8268 /// message. That rebuild must not strip `ToolUse` fields — if it did, the
8269 /// repaired history would fail closed on Gemini 3 forever after.
8270 #[test]
8271 fn crash_repaired_resume_keeps_the_signature_on_the_repaired_tool_call() {
8272 let (recovery, restored, on_disk) = resumed(&signed_history(Some("SIG-abc123"), false));
8273
8274 assert!(recovery.changed, "a dangling tool call must be repaired");
8275 assert_eq!(recovery.repaired_call_count, 1);
8276 assert_eq!(recovery.duplicate_result_count, 0);
8277 assert_eq!(recovery.orphan_result_count, 0);
8278 assert!(
8279 on_disk.contains("\"thought_signature\"") && on_disk.contains("SIG-abc123"),
8280 "the signature must reach durable storage, not just live memory"
8281 );
8282 assert!(
8283 restored
8284 .iter()
8285 .any(|message| message.content.iter().any(|block| matches!(
8286 block,
8287 ContentBlock::ToolResult { tool_use_id, content, .. }
8288 if tool_use_id == "call-g-1" && content.contains("crashed_and_repaired")
8289 ))),
8290 "the repair must pair the dangling call with a terminal result"
8291 );
8292
8293 let body = build_chat_wire_body(
8294 &request_from(restored),
8295 ApiProvider::Google,
8296 DEFAULT_GOOGLE_BASE_URL,
8297 true,
8298 )
8299 .expect("a crash-repaired signed history must build, not fail closed");
8300 assert_eq!(
8301 replayed_signature(&body.body).as_deref(),
8302 Some("SIG-abc123"),
8303 "crash repair must not strip the thought signature"
8304 );
8305 }
8306 }
8307
8307 lines RUST