返回 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, HashSet};
8 use std::io::Write;
9 use std::pin::Pin;
10 use std::time::Duration;
11
12 use anyhow::{Context, Result};
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_zai_chat_route, is_exact_zai_tiered_effort_route,
20 minimax_m3_route_uses_max_completion_tokens, wire_model_for_provider_route,
21 };
22
23 // The bounded response-header wait (`stream_open_timeout`) and its env
24 // override live in the shared stream-entry seam; every streaming adapter
25 // (Chat Completions / Anthropic Messages / Responses) uses the same policy.
26 use super::stream_entry::stream_open_timeout;
27
28 fn stream_idle_timeout_message(
29 idle: Duration,
30 bytes_received: usize,
31 stream_age: Duration,
32 since_last_chunk: Duration,
33 ) -> String {
34 // Shared seam: Chat Completions / Anthropic / Responses keep one message shape.
35 super::stream_entry::idle_timeout_message(idle, bytes_received, stream_age, since_last_chunk)
36 }
37
38 use crate::config::ApiProvider;
39 use crate::llm_client::StreamEventBox;
40 use crate::llm_client::sanitize_http_error_body;
41 use crate::logging;
42 use crate::models::{
43 ContentBlock, ContentBlockStart, Delta, Message, MessageDelta, MessageRequest, MessageResponse,
44 StreamEvent, SystemPrompt, Tool, ToolCaller, Usage, is_openai_gpt_56_api_model,
45 model_is_openai_reasoning_family, model_supports_reasoning,
46 };
47
48 use super::{
49 DeepSeekClient, ERROR_BODY_MAX_BYTES, SSE_BACKPRESSURE_HIGH_WATERMARK,
50 SSE_BACKPRESSURE_SLEEP_MS, SSE_MAX_LINES_PER_CHUNK, acquire_stream_buffer,
51 apply_reasoning_effort, bounded_error_text, from_api_tool_name, parse_usage,
52 release_stream_buffer, system_to_instructions, to_api_tool_name,
53 };
54
55 fn apply_provider_token_limit(
56 body: &mut Value,
57 provider: ApiProvider,
58 base_url: &str,
59 model: &str,
60 max_tokens: u32,
61 ) {
62 let use_max_completion_tokens = provider == ApiProvider::XiaomiMimo
63 || (provider == ApiProvider::Openai && model_is_openai_reasoning_family(model))
64 || minimax_m3_route_uses_max_completion_tokens(provider, base_url, model)
65 || is_exact_direct_moonshot_k3_route(provider, base_url, model);
66 if !use_max_completion_tokens {
67 return;
68 }
69
70 if let Some(object) = body.as_object_mut() {
71 object.remove("max_tokens");
72 }
73 body["max_completion_tokens"] = json!(max_tokens);
74 }
75
76 fn apply_openai_reasoning_effort(
77 body: &mut Value,
78 provider: ApiProvider,
79 model: &str,
80 effort: Option<&str>,
81 ) {
82 let model_lower = model.trim().to_ascii_lowercase();
83 let is_gpt_56 =
84 provider == ApiProvider::Openai && is_openai_gpt_56_api_model(model_lower.as_str());
85 let is_openai_reasoning =
86 provider == ApiProvider::Openai && model_is_openai_reasoning_family(model);
87 let is_muse_spark = provider == ApiProvider::Meta
88 && matches!(
89 model_lower.as_str(),
90 "muse-spark-1.1" | "muse-spark-1.2" | "muse-spark-1.2-contributor"
91 );
92 if !is_openai_reasoning && !is_muse_spark {
93 return;
94 }
95 let Some(effort) =
96 effort.and_then(|value| openai_compatible_reasoning_effort(value, is_gpt_56, !is_gpt_56))
97 else {
98 return;
99 };
100 body["reasoning_effort"] = json!(effort);
101 }
102
103 fn apply_inkling_reasoning_effort(
104 body: &mut Value,
105 provider: ApiProvider,
106 model: &str,
107 effort: Option<&str>,
108 ) {
109 if provider != ApiProvider::Together
110 || !model.trim().eq_ignore_ascii_case(TOGETHER_INKLING_MODEL)
111 {
112 return;
113 }
114
115 // Inkling's official chat template accepts OpenAI's top-level
116 // `reasoning_effort` field with this exact vocabulary. It does not use
117 // Together's generic `thinking` extension or the `xhigh` wire value.
118 if let Some(object) = body.as_object_mut() {
119 object.remove("thinking");
120 }
121 let Some(effort) = effort else {
122 return;
123 };
124 let wire_effort = match effort.trim().to_ascii_lowercase().as_str() {
125 "off" | "disabled" | "none" | "false" => "none",
126 "minimal" => "minimal",
127 "low" => "low",
128 "medium" | "mid" | "" => "medium",
129 "high" => "high",
130 "max" | "xhigh" | "highest" | "ultracode" => "max",
131 _ => return,
132 };
133 body["reasoning_effort"] = json!(wire_effort);
134 }
135
136 /// Apply Kimi Code K3's route-specific nested thinking effort after the
137 /// generic Moonshot shaping. Other Moonshot and Kimi-compatible routes accept
138 /// only the generic enabled/disabled form, so the exact endpoint and bare
139 /// model identifier are both part of this guard.
140 fn apply_kimi_code_k3_reasoning_effort(
141 body: &mut Value,
142 provider: ApiProvider,
143 base_url: &str,
144 model: &str,
145 effort: Option<&str>,
146 ) {
147 if !is_exact_kimi_code_k3_route(provider, base_url, model) {
148 return;
149 }
150 let Some(effort) = effort else {
151 return;
152 };
153
154 let thinking = match effort.trim().to_ascii_lowercase().as_str() {
155 "off" | "none" | "disabled" | "false" | "low" | "minimum" | "minimal" | "light" => {
156 json!({ "type": "enabled", "effort": "low" })
157 }
158 "medium" | "high" => json!({ "type": "enabled", "effort": "high" }),
159 "xhigh" | "ultra" | "max" => json!({ "type": "enabled", "effort": "max" }),
160 _ => return,
161 };
162
163 // K3 uses the nested `thinking.effort` dialect. Do not leave an
164 // OpenAI-style effort value behind if another shaping layer was added
165 // before this route-specific override.
166 if let Some(object) = body.as_object_mut() {
167 object.remove("reasoning_effort");
168 }
169 body["thinking"] = thinking;
170 }
171
172 /// Apply Moonshot's direct K3 reasoning dialect.
173 ///
174 /// The pay-as-you-go K3 endpoint is always-thinking and accepts only the
175 /// top-level `reasoning_effort` values low/high/max. In particular, a generic
176 /// Moonshot `thinking: {type: disabled}` payload is not truthful for this
177 /// route. Treat a legacy raw `off` as the lowest supported tier defensively;
178 /// route-aware callers normalize it before it reaches this layer.
179 fn apply_direct_moonshot_k3_reasoning_effort(
180 body: &mut Value,
181 provider: ApiProvider,
182 base_url: &str,
183 model: &str,
184 effort: Option<&str>,
185 ) {
186 if !is_exact_direct_moonshot_k3_route(provider, base_url, model) {
187 return;
188 }
189
190 if let Some(object) = body.as_object_mut() {
191 object.remove("thinking");
192 object.remove("reasoning_effort");
193 }
194 let Some(effort) = effort else {
195 return;
196 };
197 let wire_effort = match effort.trim().to_ascii_lowercase().as_str() {
198 "off" | "none" | "disabled" | "false" | "low" | "minimum" | "minimal" | "light" => "low",
199 "medium" | "mid" | "high" | "" => "high",
200 "xhigh" | "ultra" | "max" | "highest" | "ultracode" => "max",
201 // `auto` and unknown legacy values leave the field omitted so the
202 // direct API owns its documented default (`max`).
203 _ => return,
204 };
205 body["reasoning_effort"] = json!(wire_effort);
206 }
207
208 /// Keep Z.ai controls on exact first-party routes only. The tiered-effort GLM
209 /// models (5.2, and 5.3 which inherits its reasoning options) receive the
210 /// documented top-level effort, GLM-5.1 and GLM-5-Turbo keep only the generic
211 /// thinking toggle, and compatible gateways receive neither field because their
212 /// request dialect is not known from provider/model selection alone.
213 fn apply_zai_route_reasoning_controls(
214 body: &mut Value,
215 provider: ApiProvider,
216 base_url: &str,
217 model: &str,
218 effort: Option<&str>,
219 ) {
220 if provider != ApiProvider::Zai {
221 return;
222 }
223
224 if let Some(object) = body.as_object_mut() {
225 object.remove("reasoning_effort");
226 if !is_exact_zai_chat_route(provider, base_url) {
227 // A compatible gateway owns its own request dialect. Provider/model
228 // selection alone is not evidence that Z.ai's `thinking` object is
229 // supported there, so fail closed instead of leaking it.
230 object.remove("thinking");
231 return;
232 }
233 }
234 if !crate::config::is_exact_known_zai_reasoning_route(provider, base_url, model) {
235 if let Some(object) = body.as_object_mut() {
236 object.remove("thinking");
237 }
238 return;
239 }
240 if !is_exact_zai_tiered_effort_route(provider, base_url, model) {
241 // Exact first-party GLM-5-Turbo and GLM-5.1 keep only the generic
242 // enabled/disabled thinking control.
243 return;
244 }
245 match effort
246 .map(|value| value.trim().to_ascii_lowercase())
247 .as_deref()
248 {
249 Some("high") => body["reasoning_effort"] = json!("high"),
250 Some("xhigh") | Some("max") | Some("highest") | Some("ultracode") => {
251 body["reasoning_effort"] = json!("max");
252 }
253 // Off, lower tiers, omitted effort, and unknown legacy values retain
254 // only the generic Z.ai thinking control.
255 _ => {}
256 }
257 }
258
259 /// Add MiniMax's Chat-only reasoning controls only when endpoint and model
260 /// prove the exact first-party M3 route. A provider label alone is not enough
261 /// to send MiniMax-specific fields to a compatible gateway or unknown model.
262 fn apply_minimax_route_reasoning_controls(
263 body: &mut Value,
264 provider: ApiProvider,
265 base_url: &str,
266 model: &str,
267 effort: Option<&str>,
268 ) {
269 if provider != ApiProvider::Minimax {
270 return;
271 }
272 if let Some(object) = body.as_object_mut() {
273 object.remove("reasoning_split");
274 object.remove("thinking");
275 }
276 if !crate::config::is_exact_minimax_m3_route(provider, base_url, model) {
277 return;
278 }
279
280 body["reasoning_split"] = json!(true);
281 match effort
282 .map(|value| value.trim().to_ascii_lowercase())
283 .as_deref()
284 {
285 Some("off" | "disabled" | "none" | "false") => {
286 body["thinking"] = json!({ "type": "disabled" });
287 }
288 Some(
289 "low" | "minimal" | "medium" | "mid" | "high" | "xhigh" | "max" | "highest"
290 | "ultracode" | "",
291 ) => {
292 body["thinking"] = json!({ "type": "adaptive" });
293 }
294 _ => {}
295 }
296 }
297
298 /// Model Studio's OpenAI-compatible API uses its own top-level reasoning
299 /// controls. Keep them on verified Alibaba Chat Completions routes: a custom
300 /// `base_url` points the same provider identity at an arbitrary gateway, and
301 /// that gateway must not be handed Alibaba's dialect.
302 ///
303 /// This is the *sole* writer of Model Studio reasoning fields —
304 /// `apply_reasoning_effort` deliberately writes nothing for the `Modelstudio*`
305 /// identities — so the strip below runs for all four variants, including the
306 /// two Anthropic-dialect ones. Those normally reach the Messages adapter
307 /// instead, but `wire = "openai"` can route them here, and an unmatched
308 /// `enable_thinking` left in the body would then go out unguarded.
309 fn apply_modelstudio_route_reasoning_controls(
310 body: &mut Value,
311 provider: ApiProvider,
312 base_url: &str,
313 model: &str,
314 effort: Option<&str>,
315 ) {
316 if !matches!(
317 provider,
318 ApiProvider::ModelstudioTokenPlan
319 | ApiProvider::ModelstudioTokenPlanAnthropic
320 | ApiProvider::ModelstudioCodingPlan
321 | ApiProvider::ModelstudioCodingPlanAnthropic
322 ) {
323 return;
324 }
325
326 if let Some(object) = body.as_object_mut() {
327 object.remove("thinking");
328 object.remove("enable_thinking");
329 object.remove("preserve_thinking");
330 object.remove("reasoning_effort");
331 }
332 if !is_exact_modelstudio_chat_route(provider, base_url) {
333 return;
334 }
335
336 let thinking_only = modelstudio_model_is_thinking_only(model);
337 if !thinking_only && !modelstudio_model_is_hybrid(model) {
338 return;
339 }
340
341 let thinking_enabled = !modelstudio_effort_disables_thinking(effort);
342 // Thinking-only models emit `reasoning_content` but reject an
343 // enable/disable control. Hybrid models use `enable_thinking`.
344 if !thinking_only {
345 body["enable_thinking"] = json!(thinking_enabled);
346 }
347 if modelstudio_model_supports_preserve_thinking(model) {
348 // Model Studio otherwise drops assistant `reasoning_content` from the
349 // next turn's context. This applies even when the provider default
350 // leaves thinking enabled and no explicit UI effort was selected.
351 body["preserve_thinking"] = json!(thinking_only || thinking_enabled);
352 }
353 if !thinking_only
354 && thinking_enabled
355 && let Some(effort) = effort.and_then(modelstudio_reasoning_effort_for_model)
356 && modelstudio_model_supports_reasoning_effort(model)
357 {
358 body["reasoning_effort"] = json!(effort);
359 }
360 }
361
362 /// Fail-closed host guard: only Alibaba's own OpenAI-compatible Chat
363 /// Completions URL shapes count. Anything else (a proxy, a self-hosted
364 /// gateway, a typo) gets the Model Studio fields stripped and nothing added.
365 fn is_exact_modelstudio_chat_route(provider: ApiProvider, base_url: &str) -> bool {
366 let trimmed = base_url.trim().trim_end_matches('/').to_ascii_lowercase();
367 let Some((host, path)) = trimmed
368 .strip_prefix("https://")
369 .and_then(|rest| rest.split_once('/'))
370 else {
371 return false;
372 };
373
374 // Includes Token Plan's default and workspace-scoped
375 // `{workspace}.<region>.maas.aliyuncs.com/compatible-mode/v1` hosts.
376 let token_plan_chat = host.ends_with(".maas.aliyuncs.com") && path == "compatible-mode/v1";
377 let coding_plan_chat = host == "coding-intl.dashscope.aliyuncs.com" && path == "v1";
378 // Alibaba's classic pay-as-you-go DashScope endpoints serve the same
379 // models and the same dialect; leaving them off the allowlist silently
380 // stripped every reasoning control on a genuine Alibaba host
381 // (2026-08-04 review). The intl spelling matches the repo's own
382 // provider defaults.
383 let classic_dashscope_chat = matches!(
384 host,
385 "dashscope.aliyuncs.com" | "dashscope-intl.aliyuncs.com"
386 ) && path == "compatible-mode/v1";
387
388 match provider {
389 // The primary Model Studio provider selects Coding Plan through
390 // `mode = "coding-plan"`, which resolves this base URL without
391 // changing the provider enum. Legacy Coding Plan identities remain
392 // supported as well, so recognize either official Chat route for the
393 // complete Model Studio OpenAI family. The `*Anthropic` identities
394 // speak the Messages dialect and are never verified here.
395 ApiProvider::ModelstudioTokenPlan | ApiProvider::ModelstudioCodingPlan => {
396 token_plan_chat || coding_plan_chat || classic_dashscope_chat
397 }
398 _ => false,
399 }
400 }
401
402 fn is_exact_modelstudio_thinking_only_route(
403 provider: ApiProvider,
404 base_url: &str,
405 model: &str,
406 ) -> bool {
407 is_exact_modelstudio_chat_route(provider, base_url) && modelstudio_model_is_thinking_only(model)
408 }
409
410 fn modelstudio_effort_disables_thinking(effort: Option<&str>) -> bool {
411 effort.is_some_and(|value| {
412 matches!(
413 value.trim().to_ascii_lowercase().as_str(),
414 "off" | "disabled" | "none" | "false"
415 )
416 })
417 }
418
419 /// Models with no enable/disable control at all. `models_dev.bundled.json`
420 /// lists `qwen3.8-max` as `thinking: always_on` and gives `qwen3.8-max-preview`
421 /// effort/budget options with no `toggle`, so sending `enable_thinking` to
422 /// either is at best ignored and at worst a 400.
423 fn modelstudio_model_is_thinking_only(model: &str) -> bool {
424 let model = model.trim().to_ascii_lowercase();
425 matches!(model.as_str(), "qwen3.8-max" | "qwen3.8-max-preview")
426 // Kimi K2.7 Code on Model Studio is reported always-thinking and
427 // supporting preserve_thinking. Keep it separate from hybrid Kimi
428 // variants so we do not send the unsupported enable_thinking switch.
429 || model.starts_with("kimi-k2.7-code")
430 }
431
432 fn modelstudio_model_is_hybrid(model: &str) -> bool {
433 let model = model.trim().to_ascii_lowercase();
434 model.starts_with("qwen3.7-")
435 || model.starts_with("qwen3.6-")
436 || model.starts_with("qwen3.5-")
437 || model.starts_with("qwen3-")
438 || model.starts_with("deepseek-v4")
439 || model.starts_with("deepseek-v3.2")
440 || model.starts_with("deepseek-v3.1")
441 || model.starts_with("kimi-k2.6")
442 || model.starts_with("kimi-k2.5")
443 || model.starts_with("glm-")
444 }
445
446 fn modelstudio_model_supports_preserve_thinking(model: &str) -> bool {
447 let model = model.trim().to_ascii_lowercase();
448 model.starts_with("qwen3.7-max")
449 || model.starts_with("qwen3.7-plus")
450 || model.starts_with("qwen3.6-max-preview")
451 || model.starts_with("qwen3.6-plus")
452 || model.starts_with("qwen3.6-flash")
453 || model.starts_with("kimi-k2.6")
454 || model.starts_with("kimi-k2.7-code")
455 }
456
457 fn modelstudio_model_supports_reasoning_effort(model: &str) -> bool {
458 let model = model.trim().to_ascii_lowercase();
459 model.starts_with("deepseek-v4") || matches!(model.as_str(), "glm-5.2" | "glm-5.1" | "glm-5")
460 }
461
462 fn modelstudio_reasoning_effort_for_model(effort: &str) -> Option<&'static str> {
463 match effort.trim().to_ascii_lowercase().as_str() {
464 // Model Studio documents low and medium as aliases for high.
465 "minimal" | "low" | "medium" | "mid" | "high" | "" => Some("high"),
466 "xhigh" | "max" | "highest" | "ultracode" => Some("max"),
467 _ => None,
468 }
469 }
470
471 /// Final reasoning-control pass shared by streaming and non-streaming Chat
472 /// Completions requests. Route-specific shapers run after the generic provider
473 /// layer so they can remove fields that are invalid for their exact endpoint.
474 pub(super) fn apply_route_reasoning_controls(
475 body: &mut Value,
476 provider: ApiProvider,
477 base_url: &str,
478 model: &str,
479 effort: Option<&str>,
480 ) {
481 apply_reasoning_effort(body, effort, provider);
482 apply_modelstudio_route_reasoning_controls(body, provider, base_url, model, effort);
483 apply_minimax_route_reasoning_controls(body, provider, base_url, model, effort);
484 apply_inkling_reasoning_effort(body, provider, model, effort);
485 apply_openai_reasoning_effort(body, provider, model, effort);
486 apply_direct_moonshot_k3_reasoning_effort(body, provider, base_url, model, effort);
487 apply_kimi_code_k3_reasoning_effort(body, provider, base_url, model, effort);
488 apply_zai_route_reasoning_controls(body, provider, base_url, model, effort);
489 }
490
491 /// The direct K3 Chat Completions schema exposes fixed sampling behavior and
492 /// omits `temperature` and `top_p`. Strip legacy/generic values only from the
493 /// exact first-party route so compatible gateways keep their own contract.
494 /// Source: <https://platform.kimi.ai/docs/guide/kimi-k3-quickstart> (verified 2026-07-20).
495 fn apply_direct_moonshot_k3_fixed_sampling(
496 body: &mut Value,
497 provider: ApiProvider,
498 base_url: &str,
499 model: &str,
500 ) {
501 if !is_exact_direct_moonshot_k3_route(provider, base_url, model) {
502 return;
503 }
504 if let Some(object) = body.as_object_mut() {
505 object.remove("temperature");
506 object.remove("top_p");
507 }
508 }
509
510 fn openai_compatible_reasoning_effort(
511 effort: &str,
512 supports_max: bool,
513 supports_minimal: bool,
514 ) -> Option<&'static str> {
515 match effort.trim().to_ascii_lowercase().as_str() {
516 "off" | "disabled" | "none" | "false" => Some("none"),
517 "minimal" if supports_minimal => Some("minimal"),
518 "minimal" => Some("low"),
519 "low" => Some("low"),
520 "medium" | "mid" | "" => Some("medium"),
521 "high" => Some("high"),
522 "xhigh" => Some("xhigh"),
523 "max" | "highest" | "ultracode" if supports_max => Some("max"),
524 "max" | "highest" | "ultracode" => Some("xhigh"),
525 _ => None,
526 }
527 }
528
529 fn mirror_minimax_reasoning_details_for_messages(messages: &mut [Value]) {
530 for message in messages {
531 if message.get("role").and_then(Value::as_str) != Some("assistant") {
532 continue;
533 }
534 if message.get("reasoning_details").is_some() {
535 continue;
536 }
537 let Some(reasoning) = message
538 .get("reasoning_content")
539 .and_then(Value::as_str)
540 .filter(|reasoning| !reasoning.trim().is_empty())
541 .map(str::to_string)
542 else {
543 continue;
544 };
545 message["reasoning_details"] = json!([
546 {
547 "type": "text",
548 "text": reasoning,
549 }
550 ]);
551 }
552 }
553
554 fn mirror_minimax_reasoning_details_for_body(body: &mut Value, provider: ApiProvider) {
555 if provider != ApiProvider::Minimax {
556 return;
557 }
558 let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else {
559 return;
560 };
561 mirror_minimax_reasoning_details_for_messages(messages);
562 }
563
564 fn sanitize_moonshot_chat_tools(chat_tools: &mut [Value]) -> Result<()> {
565 for tool in chat_tools {
566 let Some(function) = tool
567 .as_object_mut()
568 .and_then(|tool| tool.get_mut("function"))
569 .and_then(Value::as_object_mut)
570 else {
571 continue;
572 };
573 let Some(parameters) = function.get_mut("parameters") else {
574 continue;
575 };
576 let note = crate::tools::schema_sanitize::sanitize_for_kimi_parameters(parameters)
577 .map_err(|error| {
578 anyhow::anyhow!(
579 "Moonshot function parameters failed safe compatibility validation: {error}"
580 )
581 })?;
582 if let Some(note) = note {
583 let description = function
584 .get("description")
585 .and_then(Value::as_str)
586 .unwrap_or_default();
587 let description = if description.is_empty() {
588 note
589 } else {
590 format!("{description} {note}")
591 };
592 function.insert("description".to_string(), json!(description));
593 }
594 }
595 Ok(())
596 }
597
598 /// The final Chat Completions wire payload for one request.
599 ///
600 /// Produced by [`build_chat_wire_body`], the single place where a
601 /// `MessageRequest` becomes Chat-shaped JSON. It is reached only through
602 /// [`super::DeepSeekClient::prepare_outbound_request`], the shared outbound
603 /// seam that the blocking transport, the streaming transport, and
604 /// `/preview-request` all consume — so a preview cannot drift from what would
605 /// be sent, and no other dialect is projected through this builder.
606 ///
607 /// Seam concept harvested from PR #1099 (`build_sanitized_chat_completion_body`)
608 /// by TaoMu (GTC2080); re-implemented against the current client shape.
609 pub(crate) struct ChatWireBody {
610 /// Provider-shaped JSON body, post-sanitizers.
611 pub(crate) body: Value,
612 /// The model id actually placed on the wire (may differ from the
613 /// configured/display model for routed providers).
614 pub(crate) model: String,
615 /// Tokens re-sent because thinking-mode replay substituted
616 /// `reasoning_content`. Only computed on the streaming path, which is the
617 /// only path that runs the replay sanitizer today.
618 pub(crate) replay_input_tokens: Option<u32>,
619 }
620
621 /// Build the Chat Completions wire body for `request`.
622 ///
623 /// `stream` selects the streaming shape (`stream` + `stream_options`) and, to
624 /// preserve historical behavior exactly, also gates the thinking-mode replay
625 /// sanitizer — the blocking path has never run it.
626 pub(crate) fn build_chat_wire_body(
627 request: &MessageRequest,
628 provider: ApiProvider,
629 base_url: &str,
630 stream: bool,
631 ) -> Result<ChatWireBody> {
632 let messages =
633 build_chat_messages_for_request_and_provider_and_route(request, provider, base_url);
634 let model = {
635 let wire = wire_model_for_provider_route(provider, base_url, &request.model);
636 crate::models::effective_muse_wire_id(&wire).to_string()
637 };
638 let mut body = if stream {
639 json!({
640 "model": model.clone(),
641 "messages": messages,
642 "max_tokens": request.max_tokens,
643 "stream": true,
644 "stream_options": {
645 "include_usage": true
646 },
647 })
648 } else {
649 json!({
650 "model": model.clone(),
651 "messages": messages,
652 "max_tokens": request.max_tokens,
653 })
654 };
655 apply_provider_token_limit(&mut body, provider, base_url, &model, request.max_tokens);
656
657 if let Some(temperature) = request.temperature {
658 body["temperature"] = json!(temperature);
659 }
660 if let Some(top_p) = request.top_p {
661 body["top_p"] = json!(top_p);
662 }
663 if let Some(tools) = request.tools.as_ref() {
664 let mut chat_tools: Vec<_> = tools
665 .iter()
666 .map(|tool| tool_to_chat_for_base_url(tool, base_url))
667 .collect();
668 // Moonshot function parameters must end at a plain object root.
669 // Flatten root composition, preserve valid nested anyOf, and fail
670 // closed before transport when an internal root ref is unsafe.
671 if matches!(provider, crate::config::ApiProvider::Moonshot) {
672 sanitize_moonshot_chat_tools(&mut chat_tools)?;
673 }
674 // xAI rejects a parameters root that is not a plain object schema
675 // (e.g. apply_patch's root `oneOf` required-groups) with a 400.
676 if matches!(provider, crate::config::ApiProvider::Xai) {
677 for t in &mut chat_tools {
678 let Some(function) = t
679 .as_object_mut()
680 .and_then(|t| t.get_mut("function"))
681 .and_then(|f| f.as_object_mut())
682 else {
683 continue;
684 };
685 let note = function.get_mut("parameters").and_then(|parameters| {
686 crate::tools::schema_sanitize::sanitize_for_xai_parameters(parameters)
687 });
688 if let Some(note) = note
689 && let Some(description) = function
690 .get_mut("description")
691 .and_then(|d| d.as_str().map(str::to_string))
692 {
693 function.insert(
694 "description".to_string(),
695 json!(format!("{description} {note}")),
696 );
697 }
698 }
699 }
700 body["tools"] = json!(chat_tools);
701 }
702 if should_send_tool_choice_for_chat(provider, request.reasoning_effort.as_deref())
703 && let Some(choice) = request.tool_choice.as_ref()
704 && let Some(mapped) = map_tool_choice_for_chat(choice)
705 {
706 body["tool_choice"] = mapped;
707 }
708 apply_route_reasoning_controls(
709 &mut body,
710 provider,
711 base_url,
712 &model,
713 request.reasoning_effort.as_deref(),
714 );
715 apply_direct_moonshot_k3_fixed_sampling(&mut body, provider, base_url, &model);
716
717 // Bulletproof final sanitizer: walk the wire payload and force
718 // `reasoning_content` onto any assistant message that has tool_calls
719 // but no reasoning_content. DeepSeek's thinking-mode API rejects
720 // such messages with a 400. This is the last line of defense after
721 // engine-side and build-side substitution; if either upstream path
722 // misses a case (e.g. a session restored from disk, a sub-agent
723 // adding messages directly, or a cached prefix mismatch), this pass
724 // still produces a valid request.
725 let replay_input_tokens = if stream {
726 sanitize_thinking_mode_messages_for_route(
727 &mut body,
728 &model,
729 request.reasoning_effort.as_deref(),
730 provider,
731 base_url,
732 )
733 } else {
734 None
735 };
736 mirror_minimax_reasoning_details_for_body(&mut body, provider);
737
738 Ok(ChatWireBody {
739 body,
740 model,
741 replay_input_tokens,
742 })
743 }
744
745 impl DeepSeekClient {
746 pub(super) async fn create_message_chat(
747 &self,
748 prepared: &super::PreparedOutboundRequest,
749 cacheable: bool,
750 ) -> Result<MessageResponse> {
751 let body = &prepared.body;
752
753 let response_cache_key = if cacheable {
754 let wire_body =
755 serde_json::to_vec(&body).context("Failed to serialize Chat API cache key")?;
756 let key = crate::llm_response_cache::ResponseCache::make_key(
757 self.api_provider.as_str(),
758 &self.base_url,
759 self.path_suffix.as_deref(),
760 &self.api_key,
761 &wire_body,
762 );
763 if let Some(cached) = crate::llm_response_cache::response_cache().get(&key) {
764 return Ok(cached);
765 }
766 Some(key)
767 } else {
768 None
769 };
770
771 // The endpoint was resolved by the shared seam alongside the body, so
772 // a route-shape decision (e.g. DeepSeek's strict-tools `/beta` path)
773 // cannot be made twice with two different answers.
774 let url = prepared.endpoint.url.as_str();
775 let response = self.send_json_with_retry(url, body).await?;
776
777 let status = response.status();
778 crate::client::record_provider_response(self.api_provider, status.as_u16());
779 if !status.is_success() {
780 let raw_error_text = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await;
781 let error_text = sanitize_http_error_body(
782 Some(self.api_provider.display_name()),
783 status.as_u16(),
784 &raw_error_text,
785 );
786 anyhow::bail!("Failed to call DeepSeek Chat API: HTTP {status}: {error_text}");
787 }
788
789 let response_text = response
790 .text()
791 .await
792 .context("Failed to read Chat API response body")?;
793 let value: Value =
794 serde_json::from_str(&response_text).context("Failed to parse Chat API JSON")?;
795 let parsed = parse_chat_message(&value)?;
796 if let Some(key) = response_cache_key {
797 crate::llm_response_cache::response_cache().put(key, parsed.clone());
798 }
799 Ok(parsed)
800 }
801 }
802
803 impl DeepSeekClient {
804 async fn open_chat_stream_response(
805 &self,
806 url: &str,
807 body: &Value,
808 ) -> Result<(reqwest::Response, Duration)> {
809 let open_req = super::stream_entry::StreamOpenRequest::new(
810 stream_open_timeout(),
811 self.stream_idle_timeout,
812 );
813 let idle_timeout = open_req.idle_timeout;
814 let response = super::stream_entry::open_sse_response(&open_req, |policy| async move {
815 match policy {
816 // The prebuilt HTTP/1.1 twin carries the same default
817 // headers/auth; send once, without the JSON retry loop
818 // (matching the pre-seam H1-pin behavior).
819 super::stream_entry::StreamHttpPolicy::Http1Only => {
820 let client = super::stream_entry::client_for_policy(
821 &self.http_client,
822 self.http1_fallback_client(),
823 policy,
824 );
825 Ok(client
826 .post(url)
827 .header(reqwest::header::CONTENT_TYPE, "application/json")
828 .json(body)
829 .send()
830 .await?)
831 }
832 super::stream_entry::StreamHttpPolicy::DualWithH1Fallback => {
833 self.send_json_with_retry(url, body).await
834 }
835 }
836 })
837 .await?;
838 Ok((response, idle_timeout))
839 }
840
841 pub(super) async fn handle_chat_completion_stream(
842 &self,
843 prepared: super::PreparedOutboundRequest,
844 ) -> Result<StreamEventBox> {
845 // Try true SSE streaming via chat completions (widely supported).
846 // Body and endpoint both come from the shared prepared-request seam,
847 // so a preview or a non-stream call can never diverge from the
848 // streamed request.
849 let super::PreparedOutboundRequest {
850 body,
851 wire_model: model,
852 replay_input_tokens,
853 endpoint,
854 ..
855 } = prepared;
856 let url = endpoint.url;
857
858 let (response, stream_idle_timeout) = self.open_chat_stream_response(&url, &body).await?;
859
860 let status = response.status();
861 crate::client::record_provider_response(self.api_provider, status.as_u16());
862 if !status.is_success() {
863 let raw_error_text = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await;
864 let error_text = sanitize_http_error_body(
865 Some(self.api_provider.display_name()),
866 status.as_u16(),
867 &raw_error_text,
868 );
869 // If DeepSeek rejected for missing reasoning_content despite the
870 // sanitizer, dump the offending indices so we can diagnose where
871 // they came from on the next failure.
872 if error_text.contains("reasoning_content") {
873 log_thinking_mode_violations(&body);
874 }
875 anyhow::bail!("SSE stream request failed: HTTP {status}: {error_text}");
876 }
877
878 let api_provider = self.api_provider;
879 let base_url = self.base_url.clone();
880
881 // Capture transport-shape headers before we consume `response` into
882 // `bytes_stream()`. They are surfaced in the decode-error log path so
883 // we can tell HTTP/2 RST_STREAM from chunked-encoding corruption from
884 // gzip-compressor failure when investigating #103.
885 let response_headers = format_stream_headers(response.headers());
886 let byte_stream = response.bytes_stream();
887 let configured_reasoning_stream_style = self.reasoning_stream_style.clone();
888
889 let stream = async_stream::stream! {
890 use futures_util::StreamExt;
891
892 // Emit a synthetic MessageStart
893 yield Ok(StreamEvent::MessageStart {
894 message: MessageResponse {
895 id: String::new(),
896 r#type: "message".to_string(),
897 role: "assistant".to_string(),
898 content: Vec::new(),
899 model: model.clone(),
900 stop_reason: None,
901 stop_sequence: None,
902 container: None,
903 usage: Usage {
904 input_tokens: 0,
905 output_tokens: 0,
906 ..Usage::default()
907 },
908 },
909 });
910
911 let mut line_buf = String::new();
912 let mut byte_buf = acquire_stream_buffer();
913 let mut content_index: u32 = 0;
914 let mut text_started = false;
915 let mut thinking_started = false;
916 let mut tool_indices: std::collections::HashMap<u32, u32> = std::collections::HashMap::new();
917 let mut reasoning_detail_buffers: std::collections::HashMap<u32, String> = std::collections::HashMap::new();
918 let mut inline_reasoning_tags = InlineReasoningTagState::default();
919 let reasoning_stream_style = reasoning_stream_style_for_route(
920 api_provider,
921 &base_url,
922 &model,
923 configured_reasoning_stream_style.as_deref(),
924 );
925
926 let mut byte_stream = std::pin::pin!(byte_stream);
927 let idle = stream_idle_timeout;
928
929 // Telemetry for #103 stream-decode diagnostics: bytes received
930 // since the start of this stream and last successful event time.
931 // Surfaces in the error log when reqwest yields a chunk error so
932 // we can tell HTTP/2 RST_STREAM from chunk-decode-failure from
933 // gzip-corruption when investigating a flaky session.
934 let stream_start = std::time::Instant::now();
935 let mut last_event_at = std::time::Instant::now();
936 let mut bytes_received: usize = 0;
937 // Set when a `[DONE]` sentinel was seen, so the post-loop flush does
938 // not re-process trailing post-DONE bytes.
939 let mut saw_done = false;
940
941 'stream: loop {
942 let chunk_result = match tokio_timeout(idle, byte_stream.next()).await {
943 Ok(Some(result)) => result,
944 Ok(None) => break, // Stream ended normally
945 Err(_elapsed) => {
946 yield Err(anyhow::anyhow!(stream_idle_timeout_message(
947 idle,
948 bytes_received,
949 stream_start.elapsed(),
950 last_event_at.elapsed(),
951 )));
952 break;
953 }
954 };
955 let chunk = match chunk_result {
956 Ok(bytes) => bytes,
957 Err(e) => {
958 // Walk the error source chain so reqwest's underlying
959 // hyper / h2 / io error is visible — without this the
960 // outer "error decoding response body" message tells
961 // us nothing about WHY the stream died.
962 let mut error_chain = format!("{e}");
963 let mut current: Option<&(dyn std::error::Error + 'static)> =
964 std::error::Error::source(&e);
965 while let Some(source) = current {
966 error_chain.push_str(&format!(" -> {source}"));
967 current = std::error::Error::source(source);
968 }
969 crate::logging::warn(format!(
970 "Stream read error: {error_chain} \
971 (elapsed: {}ms, bytes_received: {}, ms_since_last_event: {}, headers: {})",
972 stream_start.elapsed().as_millis(),
973 bytes_received,
974 last_event_at.elapsed().as_millis(),
975 response_headers,
976 ));
977 yield Err(anyhow::anyhow!("Stream read error: {e}"));
978 break;
979 }
980 };
981
982 bytes_received = bytes_received.saturating_add(chunk.len());
983 last_event_at = std::time::Instant::now();
984 byte_buf.extend_from_slice(&chunk);
985
986 // Guard against unbounded buffer growth (e.g., malformed stream without newlines)
987 const MAX_SSE_BUF: usize = 10 * 1024 * 1024; // 10 MB
988 if byte_buf.len() > MAX_SSE_BUF {
989 yield Err(anyhow::anyhow!("SSE buffer exceeded {MAX_SSE_BUF} bytes — aborting stream"));
990 break;
991 }
992
993 if byte_buf.len() > SSE_BACKPRESSURE_HIGH_WATERMARK {
994 tokio::time::sleep(Duration::from_millis(SSE_BACKPRESSURE_SLEEP_MS)).await;
995 }
996
997 // Process complete SSE lines from the buffer
998 let mut lines_processed = 0usize;
999 while let Some(newline_pos) = byte_buf.iter().position(|&b| b == b'\n') {
1000 let mut end = newline_pos;
1001 if end > 0 && byte_buf[end - 1] == b'\r' {
1002 end -= 1;
1003 }
1004 let line = String::from_utf8_lossy(&byte_buf[..end]).into_owned();
1005 byte_buf.drain(..newline_pos + 1);
1006
1007 if line.is_empty() {
1008 // Empty line = event boundary, process accumulated data
1009 if !line_buf.is_empty() {
1010 let data = std::mem::take(&mut line_buf);
1011 match parse_sse_data_frame(
1012 &data,
1013 &mut content_index,
1014 &mut text_started,
1015 &mut thinking_started,
1016 &mut tool_indices,
1017 &mut reasoning_detail_buffers,
1018 &mut inline_reasoning_tags,
1019 reasoning_stream_style,
1020 ) {
1021 SseDataFrame::Done => {
1022 saw_done = true;
1023 break 'stream;
1024 }
1025 SseDataFrame::Events(events) => {
1026 for mut event in events {
1027 // Stamp the client-side replay-token estimate
1028 // onto the final usage so the UI can surface
1029 // it (#30). We compute it pre-request and
1030 // overlay it on the server-reported usage at
1031 // stream completion.
1032 if let Some(tokens) = replay_input_tokens
1033 && let StreamEvent::MessageDelta {
1034 usage: Some(usage),
1035 ..
1036 } = &mut event
1037 {
1038 usage.reasoning_replay_tokens = Some(tokens);
1039 }
1040 yield Ok(event);
1041 }
1042 }
1043 }
1044 }
1045 continue;
1046 }
1047
1048 if let Some(data) = super::extract_sse_data_value(&line) {
1049 // The SSE spec joins multiple `data:` fields within one
1050 // event with '\n'; concatenating with no separator would
1051 // yield `{…}{…}` and fail JSON parsing, silently dropping
1052 // the frame.
1053 if !line_buf.is_empty() {
1054 line_buf.push('\n');
1055 }
1056 line_buf.push_str(data);
1057 }
1058 // Ignore other SSE fields (event:, id:, retry:)
1059
1060 lines_processed = lines_processed.saturating_add(1);
1061 if lines_processed >= SSE_MAX_LINES_PER_CHUNK {
1062 // Yield backpressure relief to avoid starving downstream consumers.
1063 break;
1064 }
1065 }
1066 }
1067
1068 // Flush a final SSE frame that arrived without a terminating blank
1069 // line (the stream closed straight after the last `data:` line, or
1070 // that line lacked a trailing newline). Without this the final delta
1071 // — last tokens, finish_reason, and usage — is silently dropped.
1072 // Skipped after `[DONE]`, whose frame was already processed.
1073 if !saw_done {
1074 if !byte_buf.is_empty() {
1075 let mut end = byte_buf.len();
1076 if end > 0 && byte_buf[end - 1] == b'\r' {
1077 end -= 1;
1078 }
1079 let line = String::from_utf8_lossy(&byte_buf[..end]).into_owned();
1080 if let Some(data) = super::extract_sse_data_value(&line) {
1081 if !line_buf.is_empty() {
1082 line_buf.push('\n');
1083 }
1084 line_buf.push_str(data);
1085 }
1086 }
1087 if !line_buf.is_empty() {
1088 let data = std::mem::take(&mut line_buf);
1089 if let SseDataFrame::Events(events) = parse_sse_data_frame(
1090 &data,
1091 &mut content_index,
1092 &mut text_started,
1093 &mut thinking_started,
1094 &mut tool_indices,
1095 &mut reasoning_detail_buffers,
1096 &mut inline_reasoning_tags,
1097 reasoning_stream_style,
1098 ) {
1099 for mut event in events {
1100 if let Some(tokens) = replay_input_tokens
1101 && let StreamEvent::MessageDelta {
1102 usage: Some(usage), ..
1103 } = &mut event
1104 {
1105 usage.reasoning_replay_tokens = Some(tokens);
1106 }
1107 yield Ok(event);
1108 }
1109 }
1110 }
1111 }
1112
1113 // Close any open blocks — content_index points to the
1114 // currently active open block (it is only incremented
1115 // *after* a block is closed, not when opened).
1116 if thinking_started || text_started {
1117 yield Ok(StreamEvent::ContentBlockStop { index: content_index });
1118 }
1119
1120 release_stream_buffer(byte_buf);
1121 yield Ok(StreamEvent::MessageStop);
1122 };
1123
1124 Ok(Pin::from(Box::new(stream)
1125 as Box<
1126 dyn futures_util::Stream<Item = Result<StreamEvent>> + Send,
1127 >))
1128 }
1129 }
1130
1131 // === Chat Completions Helpers ===
1132
1133 #[cfg(test)]
1134 pub(super) fn build_chat_messages(
1135 system: Option<&SystemPrompt>,
1136 messages: &[Message],
1137 model: &str,
1138 ) -> Vec<Value> {
1139 build_chat_messages_with_reasoning(
1140 system,
1141 messages,
1142 model,
1143 should_replay_reasoning_content(model, None),
1144 false,
1145 )
1146 }
1147
1148 #[cfg(test)]
1149 pub(super) fn build_chat_messages_for_request(request: &MessageRequest) -> Vec<Value> {
1150 PromptBuilder::for_request(request).build()
1151 }
1152
1153 #[cfg(test)]
1154 pub(super) fn build_chat_messages_for_request_and_provider(
1155 request: &MessageRequest,
1156 provider: ApiProvider,
1157 ) -> Vec<Value> {
1158 build_chat_messages_for_request_and_provider_and_route(request, provider, "")
1159 }
1160
1161 /// Build a wire prompt for one fully resolved provider route.
1162 ///
1163 /// Most provider behavior is keyed only by the provider kind and model. Kimi
1164 /// Code K3 is deliberately narrower: the bare `k3` model owns reasoning
1165 /// replay only on its official membership-plan endpoint, so callers that have
1166 /// a concrete base URL must retain it through prompt construction.
1167 pub(super) fn build_chat_messages_for_request_and_provider_and_route(
1168 request: &MessageRequest,
1169 provider: ApiProvider,
1170 base_url: &str,
1171 ) -> Vec<Value> {
1172 PromptBuilder::for_request(request).build_for_provider_and_route(provider, base_url)
1173 }
1174
1175 pub(crate) fn inspect_prompt_for_request(request: &MessageRequest) -> PromptInspection {
1176 PromptBuilder::for_request(request).inspect()
1177 }
1178
1179 pub(crate) fn build_cache_warmup_request(request: &MessageRequest) -> MessageRequest {
1180 PromptBuilder::for_request(request).build_cache_warmup_request()
1181 }
1182
1183 struct PromptBuilder<'a> {
1184 system: Option<&'a SystemPrompt>,
1185 messages: &'a [Message],
1186 tools: Option<&'a [Tool]>,
1187 model: &'a str,
1188 reasoning_effort: Option<&'a str>,
1189 }
1190
1191 impl<'a> PromptBuilder<'a> {
1192 fn for_request(request: &'a MessageRequest) -> Self {
1193 Self {
1194 system: request.system.as_ref(),
1195 messages: &request.messages,
1196 tools: request.tools.as_deref(),
1197 model: &request.model,
1198 reasoning_effort: request.reasoning_effort.as_deref(),
1199 }
1200 }
1201
1202 #[cfg(test)]
1203 fn build(self) -> Vec<Value> {
1204 build_chat_messages_with_reasoning(
1205 self.system,
1206 self.messages,
1207 self.model,
1208 should_replay_reasoning_content(self.model, self.reasoning_effort),
1209 false,
1210 )
1211 }
1212
1213 fn build_for_provider_and_route(self, provider: ApiProvider, base_url: &str) -> Vec<Value> {
1214 let mut messages = build_chat_messages_with_reasoning(
1215 self.system,
1216 self.messages,
1217 self.model,
1218 should_replay_reasoning_content_for_provider_on_route(
1219 provider,
1220 base_url,
1221 self.model,
1222 self.reasoning_effort,
1223 ),
1224 false,
1225 );
1226 dump_system_prompt_if_requested(&messages);
1227 if provider == ApiProvider::Arcee {
1228 apply_arcee_waf_safe_message_encoding(&mut messages);
1229 }
1230 if provider == ApiProvider::Minimax {
1231 mirror_minimax_reasoning_details_for_messages(&mut messages);
1232 }
1233 messages
1234 }
1235
1236 fn inspect(self) -> PromptInspection {
1237 let messages = build_chat_messages_with_reasoning(
1238 self.system,
1239 self.messages,
1240 self.model,
1241 should_replay_reasoning_content(self.model, self.reasoning_effort),
1242 true,
1243 );
1244 inspect_wire_request(self.tools, &messages)
1245 }
1246
1247 fn build_cache_warmup_request(self) -> MessageRequest {
1248 let system = stable_system_prompt(self.system);
1249 let mut messages = stable_history_messages(self.messages);
1250 let tools = self
1251 .tools
1252 .filter(|tools| !tools.is_empty())
1253 .map(<[Tool]>::to_vec);
1254 let tool_choice = tools.as_ref().map(|_| json!("none"));
1255 messages.push(Message {
1256 role: "user".to_string(),
1257 content: vec![ContentBlock::Text {
1258 text: CACHE_WARMUP_USER_TAIL.to_string(),
1259 cache_control: None,
1260 }],
1261 });
1262
1263 MessageRequest {
1264 model: self.model.to_string(),
1265 messages,
1266 max_tokens: 8,
1267 system,
1268 tools,
1269 tool_choice,
1270 metadata: None,
1271 thinking: None,
1272 reasoning_effort: self.reasoning_effort.map(str::to_string),
1273 stream: None,
1274 temperature: Some(0.0),
1275 top_p: None,
1276 }
1277 }
1278 }
1279
1280 const SYSTEM_PROMPT_DUMP_ENV: &str = "CODEWHALE_DUMP_SYSTEM_PROMPT";
1281 const SYSTEM_PROMPT_DUMP_BEGIN: &str = "<<<CODEWHALE_SYSTEM_PROMPT_BEGIN>>>";
1282 const SYSTEM_PROMPT_DUMP_END: &str = "<<<CODEWHALE_SYSTEM_PROMPT_END>>>";
1283 const ARCEE_WAF_TEXT_SPLIT_TRIGGERS: &[(&str, &str, &str)] = &[("python -c", "python ", "-c")];
1284
1285 fn dump_system_prompt_if_requested(messages: &[Value]) {
1286 let Ok(flag) = std::env::var(SYSTEM_PROMPT_DUMP_ENV) else {
1287 return;
1288 };
1289 if !matches!(flag.trim(), "1" | "true" | "TRUE" | "yes" | "YES") {
1290 return;
1291 }
1292 let Some(prompt) = messages.iter().find_map(system_message_text) else {
1293 return;
1294 };
1295 let mut stderr = std::io::stderr().lock();
1296 let _ = writeln!(stderr, "{SYSTEM_PROMPT_DUMP_BEGIN}");
1297 let _ = writeln!(stderr, "{prompt}");
1298 let _ = writeln!(stderr, "{SYSTEM_PROMPT_DUMP_END}");
1299 }
1300
1301 fn system_message_text(message: &Value) -> Option<String> {
1302 if message.get("role").and_then(Value::as_str) != Some("system") {
1303 return None;
1304 }
1305 match message.get("content")? {
1306 Value::String(text) => Some(text.clone()),
1307 Value::Array(parts) => {
1308 let text = parts
1309 .iter()
1310 .filter_map(|part| part.get("text").and_then(Value::as_str))
1311 .collect::<Vec<_>>()
1312 .join("");
1313 (!text.is_empty()).then_some(text)
1314 }
1315 _ => None,
1316 }
1317 }
1318
1319 fn apply_arcee_waf_safe_message_encoding(messages: &mut [Value]) {
1320 for message in messages {
1321 if message.get("role").and_then(Value::as_str) != Some("system") {
1322 continue;
1323 }
1324 let Some(content) = message.get("content").and_then(Value::as_str) else {
1325 continue;
1326 };
1327 let Some(parts) = arcee_waf_safe_text_parts(content) else {
1328 continue;
1329 };
1330 message["content"] = json!(parts);
1331 }
1332 }
1333
1334 fn arcee_waf_safe_text_parts(content: &str) -> Option<Vec<Value>> {
1335 let mut parts = Vec::new();
1336 let mut cursor = 0usize;
1337 let mut split_any = false;
1338
1339 while cursor < content.len() {
1340 let Some((trigger_start, trigger, left, right)) = next_arcee_waf_trigger(content, cursor)
1341 else {
1342 push_text_part(&mut parts, &content[cursor..]);
1343 break;
1344 };
1345
1346 push_text_part(&mut parts, &content[cursor..trigger_start]);
1347 push_text_part(&mut parts, left);
1348 push_text_part(&mut parts, right);
1349 cursor = trigger_start + trigger.len();
1350 split_any = true;
1351 }
1352
1353 split_any.then_some(parts)
1354 }
1355
1356 fn next_arcee_waf_trigger(content: &str, cursor: usize) -> Option<(usize, &str, &str, &str)> {
1357 ARCEE_WAF_TEXT_SPLIT_TRIGGERS
1358 .iter()
1359 .filter_map(|(trigger, left, right)| {
1360 content[cursor..]
1361 .find(trigger)
1362 .map(|offset| (cursor + offset, *trigger, *left, *right))
1363 })
1364 .min_by_key(|(start, _, _, _)| *start)
1365 }
1366
1367 fn push_text_part(parts: &mut Vec<Value>, text: &str) {
1368 if !text.is_empty() {
1369 parts.push(json!({
1370 "type": "text",
1371 "text": text,
1372 }));
1373 }
1374 }
1375
1376 pub(crate) const CACHE_WARMUP_USER_TAIL: &str = "请只回复 OK";
1377 const TOOL_RESULT_SENT_CHAR_BUDGET: usize = 12_000;
1378 const TOOL_RESULT_HEAD_CHARS: usize = 4_000;
1379 const TOOL_RESULT_TAIL_CHARS: usize = 4_000;
1380 /// Tool results shorter than this stay inline even when repeated. The
1381 /// extra prompt bytes are cheaper than adding an earlier-message reference
1382 /// for tiny command outputs.
1383 const TOOL_RESULT_DEDUP_MIN_CHARS: usize = 1_024;
1384
1385 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1386 pub(crate) struct PromptInspection {
1387 pub base_static_prefix_hash: String,
1388 pub full_request_prefix_hash: String,
1389 /// Hash of the rendered tool catalog JSON, or empty when no tools were supplied.
1390 pub tool_catalog_hash: String,
1391 pub layers: Vec<PromptLayerInspection>,
1392 }
1393
1394 /// Identifies the stable prefix that a cache warmup primes.
1395 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1396 pub(crate) struct CacheWarmupKey {
1397 pub provider: String,
1398 pub model: String,
1399 pub base_url: String,
1400 pub static_prefix_hash: String,
1401 pub tool_catalog_hash: String,
1402 pub project_pack_hash: String,
1403 pub skills_hash: String,
1404 }
1405
1406 impl CacheWarmupKey {
1407 pub(crate) fn from_inspection(
1408 provider: &str,
1409 model: &str,
1410 base_url: &str,
1411 inspection: &PromptInspection,
1412 ) -> Self {
1413 Self {
1414 provider: provider.to_string(),
1415 model: model.to_string(),
1416 base_url: base_url.to_string(),
1417 static_prefix_hash: inspection.base_static_prefix_hash.clone(),
1418 tool_catalog_hash: inspection.tool_catalog_hash.clone(),
1419 project_pack_hash: layer_hash(inspection, "Project context pack"),
1420 skills_hash: layer_hash(inspection, "Skills"),
1421 }
1422 }
1423
1424 pub(crate) fn hash_short(&self) -> String {
1425 let json = serde_json::to_string(self).unwrap_or_default();
1426 let hash = sha256_hex(json.as_bytes());
1427 hash[..hash.len().min(12)].to_string()
1428 }
1429 }
1430
1431 fn layer_hash(inspection: &PromptInspection, name: &str) -> String {
1432 inspection
1433 .layers
1434 .iter()
1435 .find(|layer| layer.name == name)
1436 .map(|layer| layer.sha256.clone())
1437 .unwrap_or_default()
1438 }
1439
1440 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1441 pub(crate) struct PromptLayerInspection {
1442 pub name: String,
1443 pub stability: PromptLayerStability,
1444 pub char_len: usize,
1445 pub byte_len: usize,
1446 /// Rough token estimate for quick before/after cache-hit reports.
1447 pub token_estimate: usize,
1448 pub sha256: String,
1449 pub tool_result: Option<ToolResultInspection>,
1450 pub turn_meta: Option<TurnMetaInspection>,
1451 }
1452
1453 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1454 pub(crate) struct ToolResultInspection {
1455 pub original_chars: usize,
1456 pub sent_chars: usize,
1457 pub truncated: bool,
1458 pub deduplicated: bool,
1459 }
1460
1461 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1462 pub(crate) struct TurnMetaInspection {
1463 pub original_chars: usize,
1464 pub sent_chars: usize,
1465 pub deduplicated: bool,
1466 pub sha256: String,
1467 }
1468
1469 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1470 pub(crate) enum PromptLayerStability {
1471 Static,
1472 History,
1473 Dynamic,
1474 }
1475
1476 impl PromptLayerStability {
1477 pub(crate) fn label(self) -> &'static str {
1478 match self {
1479 Self::Static => "static",
1480 Self::History => "history",
1481 Self::Dynamic => "dynamic",
1482 }
1483 }
1484 }
1485
1486 fn inspect_wire_request(tools: Option<&[Tool]>, messages: &[Value]) -> PromptInspection {
1487 let mut layers = Vec::new();
1488 let mut base_static_prefix_parts = Vec::new();
1489 let mut full_request_prefix_parts = Vec::new();
1490 let mut tool_catalog_hash = String::new();
1491 let mut start_index = 0;
1492
1493 if let Some(message) = messages.first() {
1494 let role = message
1495 .get("role")
1496 .and_then(Value::as_str)
1497 .unwrap_or("unknown");
1498 let content = message_content_for_inspect(message);
1499 if role == "system" {
1500 for (name, stability, body) in split_system_layers(&content) {
1501 if stability == PromptLayerStability::Static {
1502 base_static_prefix_parts.push(body.to_string());
1503 }
1504 if stability != PromptLayerStability::Dynamic {
1505 full_request_prefix_parts.push(body.to_string());
1506 }
1507 layers.push(prompt_layer(name, stability, body));
1508 }
1509 start_index = 1;
1510 }
1511 }
1512
1513 if let Some(tool_catalog) = tool_catalog_for_inspect(tools) {
1514 tool_catalog_hash = sha256_hex(tool_catalog.as_bytes());
1515 base_static_prefix_parts.push(tool_catalog.clone());
1516 full_request_prefix_parts.push(tool_catalog.clone());
1517 layers.push(prompt_layer(
1518 "Tool catalog".to_string(),
1519 PromptLayerStability::Static,
1520 &tool_catalog,
1521 ));
1522 }
1523
1524 for (index, message) in messages.iter().enumerate().skip(start_index) {
1525 let role = message
1526 .get("role")
1527 .and_then(Value::as_str)
1528 .unwrap_or("unknown");
1529 let content = message_content_for_inspect(message);
1530 let is_last = index + 1 == messages.len();
1531 let stability = if (is_last && role == "user") || role == "tool" {
1532 PromptLayerStability::Dynamic
1533 } else {
1534 PromptLayerStability::History
1535 };
1536 let name = if is_last && role == "user" {
1537 "User task".to_string()
1538 } else {
1539 format!("Message #{index} {role}")
1540 };
1541 if stability != PromptLayerStability::Dynamic {
1542 full_request_prefix_parts.push(content.clone());
1543 }
1544 let mut layer = prompt_layer(name, stability, &content);
1545 layer.tool_result = tool_result_inspection_for_message(message);
1546 layer.turn_meta = turn_meta_inspection_for_message(message);
1547 layers.push(layer);
1548 }
1549
1550 let base_static_prefix = base_static_prefix_parts.join("\n");
1551 let full_request_prefix = full_request_prefix_parts.join("\n");
1552
1553 PromptInspection {
1554 base_static_prefix_hash: sha256_hex(base_static_prefix.as_bytes()),
1555 full_request_prefix_hash: sha256_hex(full_request_prefix.as_bytes()),
1556 tool_catalog_hash,
1557 layers,
1558 }
1559 }
1560
1561 fn tool_catalog_for_inspect(tools: Option<&[Tool]>) -> Option<String> {
1562 let tools = tools.filter(|tools| !tools.is_empty())?;
1563 serde_json::to_string(&tools.iter().map(tool_to_chat).collect::<Vec<_>>()).ok()
1564 }
1565
1566 fn message_content_for_inspect(message: &Value) -> String {
1567 let mut parts = Vec::new();
1568 if let Some(content) = message.get("content").and_then(Value::as_str)
1569 && !content.is_empty()
1570 {
1571 parts.push(content.to_string());
1572 }
1573 if let Some(content) = message.get("content").and_then(Value::as_array) {
1574 for part in content {
1575 match part.get("type").and_then(Value::as_str) {
1576 Some("text") => {
1577 if let Some(text) = part.get("text").and_then(Value::as_str)
1578 && !text.is_empty()
1579 {
1580 parts.push(text.to_string());
1581 }
1582 }
1583 Some("image_url") => {
1584 let url = part
1585 .get("image_url")
1586 .and_then(|image_url| image_url.get("url"))
1587 .and_then(Value::as_str)
1588 .unwrap_or("");
1589 parts.push(format!(
1590 "[image_url:{}]",
1591 summarize_image_url_for_inspect(url)
1592 ));
1593 }
1594 _ => {}
1595 }
1596 }
1597 }
1598 if let Some(reasoning) = message.get("reasoning_content").and_then(Value::as_str)
1599 && !reasoning.is_empty()
1600 {
1601 parts.push(reasoning.to_string());
1602 }
1603 if let Some(tool_calls) = message.get("tool_calls") {
1604 parts.push(tool_calls.to_string());
1605 }
1606 parts.join("\n")
1607 }
1608
1609 fn summarize_image_url_for_inspect(url: &str) -> String {
1610 let Some((prefix, encoded)) = url.split_once(";base64,") else {
1611 return first_chars(url, 96);
1612 };
1613 format!("{prefix};base64,<{} chars>", encoded.len())
1614 }
1615
1616 fn tool_result_inspection_for_message(message: &Value) -> Option<ToolResultInspection> {
1617 if message.get("role").and_then(Value::as_str) != Some("tool") {
1618 return None;
1619 }
1620 let budget = message.get("_tool_result_budget")?;
1621 Some(ToolResultInspection {
1622 original_chars: budget
1623 .get("original_chars")
1624 .and_then(Value::as_u64)
1625 .and_then(|n| usize::try_from(n).ok())?,
1626 sent_chars: budget
1627 .get("sent_chars")
1628 .and_then(Value::as_u64)
1629 .and_then(|n| usize::try_from(n).ok())?,
1630 truncated: budget
1631 .get("truncated")
1632 .and_then(Value::as_bool)
1633 .unwrap_or(false),
1634 deduplicated: budget
1635 .get("deduplicated")
1636 .and_then(Value::as_bool)
1637 .unwrap_or(false),
1638 })
1639 }
1640
1641 fn turn_meta_inspection_for_message(message: &Value) -> Option<TurnMetaInspection> {
1642 let budget = message.get("_turn_meta_budget")?;
1643 Some(TurnMetaInspection {
1644 original_chars: budget
1645 .get("original_chars")
1646 .and_then(Value::as_u64)
1647 .and_then(|n| usize::try_from(n).ok())?,
1648 sent_chars: budget
1649 .get("sent_chars")
1650 .and_then(Value::as_u64)
1651 .and_then(|n| usize::try_from(n).ok())?,
1652 deduplicated: budget
1653 .get("deduplicated")
1654 .and_then(Value::as_bool)
1655 .unwrap_or(false),
1656 sha256: budget
1657 .get("sha256")
1658 .and_then(Value::as_str)
1659 .map(str::to_string)?,
1660 })
1661 }
1662
1663 fn split_system_layers(content: &str) -> Vec<(String, PromptLayerStability, &str)> {
1664 let markers = [
1665 ("Project context", "<project_instructions"),
1666 ("Project context pack", "## Project Context Pack"),
1667 ("Environment", "## Environment"),
1668 ("Configured instructions", "<instructions "),
1669 ("User memory", "## User Memory"),
1670 ("Current session goal", "## Current Session Goal"),
1671 ("Skills", "## Skills"),
1672 ("Core execution", "## Core Execution"),
1673 ("Compact template", "## Compact"),
1674 ("Previous session relay", "## Previous Session Relay"),
1675 ];
1676
1677 let mut starts: Vec<(usize, &str)> = markers
1678 .iter()
1679 .filter_map(|(name, marker)| content.find(marker).map(|idx| (idx, *name)))
1680 .collect();
1681 starts.sort_by_key(|(idx, _)| *idx);
1682
1683 let mut layers = Vec::new();
1684 let first_marker = starts.first().map_or(content.len(), |(idx, _)| *idx);
1685 if first_marker > 0 {
1686 layers.push((
1687 "Global system prefix".to_string(),
1688 PromptLayerStability::Static,
1689 content[..first_marker].trim(),
1690 ));
1691 }
1692
1693 for (i, (start, name)) in starts.iter().enumerate() {
1694 let end = starts.get(i + 1).map_or(content.len(), |(idx, _)| *idx);
1695 let stability = if *name == "Previous session relay" {
1696 PromptLayerStability::Dynamic
1697 } else if is_static_base_layer(name) {
1698 PromptLayerStability::Static
1699 } else {
1700 PromptLayerStability::History
1701 };
1702 layers.push(((*name).to_string(), stability, content[*start..end].trim()));
1703 }
1704
1705 if layers.is_empty() {
1706 layers.push((
1707 "Global system prefix".to_string(),
1708 PromptLayerStability::Static,
1709 content.trim(),
1710 ));
1711 }
1712 layers
1713 }
1714
1715 fn is_static_base_layer(name: &str) -> bool {
1716 matches!(
1717 name,
1718 "Global system prefix"
1719 | "Environment"
1720 | "Skills"
1721 | "Project context"
1722 | "Project context pack"
1723 | "Core execution"
1724 | "Compact template"
1725 )
1726 }
1727
1728 fn stable_system_prompt(system: Option<&SystemPrompt>) -> Option<SystemPrompt> {
1729 let instructions = system_to_instructions(system.cloned())?;
1730 let stable = split_system_layers(&instructions)
1731 .into_iter()
1732 .filter_map(|(_, stability, body)| {
1733 (stability == PromptLayerStability::Static).then_some(body)
1734 })
1735 .collect::<Vec<_>>()
1736 .join("\n\n");
1737 if stable.trim().is_empty() {
1738 None
1739 } else {
1740 Some(SystemPrompt::Text(stable))
1741 }
1742 }
1743
1744 fn stable_history_messages(messages: &[Message]) -> Vec<Message> {
1745 let mut end = messages.len();
1746 if messages
1747 .last()
1748 .is_some_and(|message| message.role.as_str() == "user")
1749 {
1750 end = end.saturating_sub(1);
1751 }
1752 messages[..end].to_vec()
1753 }
1754
1755 fn prompt_layer(
1756 name: String,
1757 stability: PromptLayerStability,
1758 content: &str,
1759 ) -> PromptLayerInspection {
1760 let char_len = content.chars().count();
1761 let token_estimate = if char_len == 0 {
1762 0
1763 } else if content.is_ascii() {
1764 (char_len / 4).max(1)
1765 } else {
1766 char_len.max(1)
1767 };
1768 PromptLayerInspection {
1769 name,
1770 stability,
1771 char_len,
1772 byte_len: content.len(),
1773 token_estimate,
1774 sha256: sha256_hex(content.as_bytes()),
1775 tool_result: None,
1776 turn_meta: None,
1777 }
1778 }
1779
1780 fn sha256_hex(bytes: &[u8]) -> String {
1781 crate::hashing::sha256_hex(bytes)
1782 }
1783
1784 #[derive(Clone)]
1785 struct PendingToolCallInfo {
1786 tool_name: String,
1787 input: Value,
1788 }
1789
1790 struct SeenToolResult {
1791 message_label: String,
1792 original_chars: usize,
1793 }
1794
1795 struct WireToolResult {
1796 content: String,
1797 original_chars: usize,
1798 sent_chars: usize,
1799 truncated: bool,
1800 deduplicated: bool,
1801 }
1802
1803 #[derive(Clone)]
1804 struct TurnMetaBudget {
1805 original_chars: usize,
1806 sent_chars: usize,
1807 deduplicated: bool,
1808 sha256: String,
1809 }
1810
1811 struct LastFullTurnMeta {
1812 sha256: String,
1813 }
1814
1815 fn render_turn_meta_for_wire(
1816 text: &str,
1817 last_full_turn_meta: &mut Option<LastFullTurnMeta>,
1818 ) -> (String, TurnMetaBudget) {
1819 let original_chars = text.chars().count();
1820 let sha = sha256_hex(text.as_bytes());
1821
1822 if last_full_turn_meta
1823 .as_ref()
1824 .is_some_and(|previous| previous.sha256 == sha)
1825 {
1826 // Keep the repeated metadata slot short without surfacing an
1827 // opaque hash the model cannot resolve.
1828 let rendered = "<turn_meta_unchanged />".to_string();
1829 let budget = TurnMetaBudget {
1830 original_chars,
1831 sent_chars: rendered.chars().count(),
1832 deduplicated: true,
1833 sha256: sha,
1834 };
1835 return (rendered, budget);
1836 }
1837
1838 *last_full_turn_meta = Some(LastFullTurnMeta {
1839 sha256: sha.clone(),
1840 });
1841 (
1842 text.to_string(),
1843 TurnMetaBudget {
1844 original_chars,
1845 sent_chars: original_chars,
1846 deduplicated: false,
1847 sha256: sha,
1848 },
1849 )
1850 }
1851
1852 fn is_turn_meta_text(text: &str) -> bool {
1853 text.trim_start().starts_with("<turn_meta>")
1854 }
1855
1856 fn turn_meta_budget_json(turn_meta: &TurnMetaBudget) -> Value {
1857 json!({
1858 "original_chars": turn_meta.original_chars,
1859 "sent_chars": turn_meta.sent_chars,
1860 "deduplicated": turn_meta.deduplicated,
1861 "sha256": turn_meta.sha256,
1862 })
1863 }
1864
1865 /// Mutating/write tools whose result body is a *confirmation* (it embeds
1866 /// the unified diff + summary of what was just written), not retrievable
1867 /// reference data. Two identical large `write_file` calls must each keep
1868 /// their full confirmation inline: collapsing the later one to a
1869 /// `<TOOL_RESULT_REF sha="..." />` makes the model lose the write-success
1870 /// context and behave as if the file is missing (issue #1695). Read-style
1871 /// tools (`read_file`, `grep_files`, `exec_shell`, …) may deduplicate medium
1872 /// outputs by pointing at an earlier full message in the same request. They
1873 /// never advertise a process-wide SHA as retrievable: that store cannot prove
1874 /// session ownership.
1875 fn is_mutation_tool(tool_name: &str) -> bool {
1876 matches!(tool_name, "write_file" | "edit_file" | "apply_patch")
1877 }
1878
1879 fn compact_tool_result_for_wire(
1880 tool_name: &str,
1881 input: &Value,
1882 content: &str,
1883 message_label: &str,
1884 seen_tool_results: &mut HashMap<String, SeenToolResult>,
1885 ) -> WireToolResult {
1886 let original_chars = content.chars().count();
1887 let sha = sha256_hex(content.as_bytes());
1888
1889 // Only medium, non-mutation results can point back to a full earlier
1890 // message in this one request. Oversized results are already excerpts, so
1891 // a back-reference would falsely imply the exact bytes remain available.
1892 let dedup_eligible = (TOOL_RESULT_DEDUP_MIN_CHARS..=TOOL_RESULT_SENT_CHAR_BUDGET)
1893 .contains(&original_chars)
1894 && !is_mutation_tool(tool_name);
1895
1896 if dedup_eligible && let Some(previous) = seen_tool_results.get(&sha) {
1897 let content = format!(
1898 "<TOOL_RESULT_REF sha=\"{sha}\" original_message=\"{label}\" chars=\"{chars}\">\n\
1899 source: full content appears in {label} earlier in this request\n\
1900 </TOOL_RESULT_REF>",
1901 label = previous.message_label,
1902 chars = previous.original_chars,
1903 );
1904 return WireToolResult {
1905 sent_chars: content.chars().count(),
1906 content,
1907 original_chars,
1908 truncated: false,
1909 deduplicated: true,
1910 };
1911 }
1912
1913 if dedup_eligible {
1914 seen_tool_results.insert(
1915 sha.clone(),
1916 SeenToolResult {
1917 message_label: message_label.to_string(),
1918 original_chars,
1919 },
1920 );
1921 }
1922
1923 if original_chars <= TOOL_RESULT_SENT_CHAR_BUDGET {
1924 return WireToolResult {
1925 content: content.to_string(),
1926 original_chars,
1927 sent_chars: original_chars,
1928 truncated: false,
1929 deduplicated: false,
1930 };
1931 }
1932
1933 // Content already bounded by the adaptive evidence envelope carries its
1934 // own honest footer: the omitted count, the on-disk artifact path, and a
1935 // recovery instruction. Truncating it again here would destroy that
1936 // recovery contract and falsely report that no session-owned artifact
1937 // was recorded, so pass it through untouched.
1938 if content.contains(crate::tools::truncate::SPILLOVER_RECOVERY_HINT) {
1939 return WireToolResult {
1940 content: content.to_string(),
1941 original_chars,
1942 sent_chars: original_chars,
1943 truncated: false,
1944 deduplicated: false,
1945 };
1946 }
1947
1948 let head = first_chars(content, TOOL_RESULT_HEAD_CHARS);
1949 let tail = last_chars(content, TOOL_RESULT_TAIL_CHARS);
1950 let kept = head.chars().count() + tail.chars().count();
1951 let omitted = original_chars.saturating_sub(kept);
1952 let compacted = format!(
1953 "[TOOL_RESULT_TRUNCATED]\n\
1954 tool_name: {tool_name}\n\
1955 command_or_query: {}\n\
1956 exit_status: {}\n\
1957 original_chars: {original_chars}\n\
1958 sha256: {sha}\n\
1959 exact_detail: unavailable; no session-owned artifact was recorded\n\
1960 first_chars:\n\
1961 {head}\n\n\
1962 [... truncated {omitted} chars from middle ...]\n\n\
1963 last_chars:\n\
1964 {tail}",
1965 tool_command_or_query(input),
1966 tool_exit_status(content)
1967 );
1968
1969 WireToolResult {
1970 sent_chars: compacted.chars().count(),
1971 content: compacted,
1972 original_chars,
1973 truncated: true,
1974 deduplicated: false,
1975 }
1976 }
1977
1978 fn tool_command_or_query(input: &Value) -> String {
1979 for key in ["command", "cmd", "query", "q", "pattern", "path", "url"] {
1980 if let Some(value) = input.get(key) {
1981 return summarize_for_metadata(value, 500);
1982 }
1983 }
1984 summarize_for_metadata(input, 500)
1985 }
1986
1987 fn tool_exit_status(content: &str) -> String {
1988 if let Ok(value) = serde_json::from_str::<Value>(content) {
1989 for key in ["exit_code", "exit_status", "status", "code"] {
1990 if let Some(value) = value.get(key) {
1991 return summarize_for_metadata(value, 120);
1992 }
1993 }
1994 }
1995
1996 for line in content.lines().take(20) {
1997 let trimmed = line.trim();
1998 for prefix in ["Exit code:", "exit code:", "Exit status:", "exit status:"] {
1999 if let Some(value) = trimmed.strip_prefix(prefix) {
2000 return value.trim().to_string();
2001 }
2002 }
2003 }
2004 "unknown".to_string()
2005 }
2006
2007 fn summarize_for_metadata(value: &Value, max_chars: usize) -> String {
2008 let raw = value
2009 .as_str()
2010 .map(str::to_string)
2011 .unwrap_or_else(|| value.to_string());
2012 let mut summarized = first_chars(&raw.replace('\n', "\\n"), max_chars);
2013 if raw.chars().count() > max_chars {
2014 summarized.push_str("...");
2015 }
2016 summarized
2017 }
2018
2019 fn first_chars(value: &str, count: usize) -> String {
2020 value.chars().take(count).collect()
2021 }
2022
2023 fn last_chars(value: &str, count: usize) -> String {
2024 let mut chars: Vec<char> = value.chars().rev().take(count).collect();
2025 chars.reverse();
2026 chars.into_iter().collect()
2027 }
2028
2029 fn build_chat_messages_with_reasoning(
2030 system: Option<&SystemPrompt>,
2031 messages: &[Message],
2032 _model: &str,
2033 include_reasoning: bool,
2034 include_tool_budget_metadata: bool,
2035 ) -> Vec<Value> {
2036 let mut out = Vec::new();
2037 let mut pending_tool_calls: HashMap<String, PendingToolCallInfo> = HashMap::new();
2038 let mut seen_tool_results: HashMap<String, SeenToolResult> = HashMap::new();
2039 let mut last_full_turn_meta: Option<LastFullTurnMeta> = None;
2040
2041 if let Some(instructions) = system_to_instructions(system.cloned())
2042 && !instructions.trim().is_empty()
2043 {
2044 out.push(json!({
2045 "role": "system",
2046 "content": instructions,
2047 }));
2048 }
2049
2050 for (message_index, message) in messages.iter().enumerate() {
2051 let role = message.role.as_str();
2052 let mut text_parts = Vec::new();
2053 let mut image_parts = Vec::new();
2054 let mut thinking_parts = Vec::new();
2055 let mut tool_calls = Vec::new();
2056 let mut tool_call_infos = Vec::new();
2057 let mut tool_results: Vec<(String, String, String)> = Vec::new();
2058 let mut turn_meta_budget: Option<TurnMetaBudget> = None;
2059
2060 for block in &message.content {
2061 match block {
2062 ContentBlock::Text { text, .. } => {
2063 if is_turn_meta_text(text) {
2064 let (rendered, budget) =
2065 render_turn_meta_for_wire(text, &mut last_full_turn_meta);
2066 text_parts.push(rendered);
2067 turn_meta_budget = Some(budget);
2068 } else {
2069 text_parts.push(text.clone());
2070 }
2071 }
2072 ContentBlock::ImageUrl { image_url } => {
2073 image_parts.push(json!({
2074 "type": "image_url",
2075 "image_url": {
2076 "url": image_url.url.clone(),
2077 },
2078 }));
2079 }
2080 ContentBlock::Thinking { thinking, .. } => thinking_parts.push(thinking.clone()),
2081 ContentBlock::ToolUse {
2082 id,
2083 name,
2084 input,
2085 caller,
2086 ..
2087 } => {
2088 let args = serde_json::to_string(input).unwrap_or_else(|_| input.to_string());
2089 let mut call = json!({
2090 "id": id,
2091 "type": "function",
2092 "function": {
2093 "name": to_api_tool_name(name),
2094 "arguments": args,
2095 }
2096 });
2097 if let Some(caller) = caller {
2098 call["caller"] = json!({
2099 "type": caller.caller_type,
2100 "tool_id": caller.tool_id,
2101 });
2102 }
2103 tool_calls.push(call);
2104 tool_call_infos.push((
2105 id.clone(),
2106 PendingToolCallInfo {
2107 tool_name: name.clone(),
2108 input: input.clone(),
2109 },
2110 ));
2111 }
2112 ContentBlock::ToolResult {
2113 tool_use_id,
2114 content,
2115 ..
2116 } => {
2117 let message_label = format!("Message #{message_index}");
2118 tool_results.push((tool_use_id.clone(), content.clone(), message_label));
2119 }
2120 ContentBlock::ServerToolUse { .. }
2121 | ContentBlock::ToolSearchToolResult { .. }
2122 | ContentBlock::CodeExecutionToolResult { .. } => {}
2123 }
2124 }
2125
2126 if role == "assistant" || role == crate::models::INTERRUPTED_ASSISTANT_ROLE {
2127 let content = if role == crate::models::INTERRUPTED_ASSISTANT_ROLE {
2128 format!(
2129 "{}{}",
2130 crate::models::INTERRUPTED_ASSISTANT_CONTEXT_PREFIX,
2131 text_parts.join("\n")
2132 )
2133 } else {
2134 text_parts.join("\n")
2135 };
2136 let mut reasoning_content = thinking_parts.join("\n");
2137 let has_text = !content.trim().is_empty();
2138 let has_tool_calls = !tool_calls.is_empty();
2139 // Reasoning replay must be a function of the stored message ONLY,
2140 // never of later history. DeepSeek's prefix cache hashes the raw
2141 // bytes of every message; flipping `reasoning_content` on/off
2142 // depending on whether a follow-up user turn exists rewrites a
2143 // historical message between turns and busts the cache from that
2144 // point onwards. Always emit `reasoning_content` when the model
2145 // requires replay AND the stored message carries thinking text.
2146 // Tool-call messages with empty thinking still need a placeholder
2147 // (DeepSeek 400s without it), but text-only assistant messages
2148 // simply omit the field when there's nothing to replay.
2149 let mut has_reasoning = include_reasoning && !reasoning_content.trim().is_empty();
2150 if include_reasoning && has_tool_calls && !has_reasoning {
2151 logging::warn(
2152 "Substituting placeholder reasoning_content for DeepSeek tool-call assistant message",
2153 );
2154 reasoning_content = String::from("(reasoning omitted)");
2155 has_reasoning = true;
2156 }
2157
2158 // DeepSeek rejects assistant messages where both `content` and
2159 // `tool_calls` are missing/null. Skip such entries even if they
2160 // carry reasoning-only metadata unless we can send a non-null
2161 // placeholder content field.
2162 if !has_text && !has_tool_calls && !has_reasoning {
2163 pending_tool_calls.clear();
2164 continue;
2165 }
2166
2167 let mut msg = json!({
2168 "role": "assistant",
2169 "content": if has_text {
2170 json!(content)
2171 } else if has_reasoning {
2172 json!("")
2173 } else {
2174 Value::Null
2175 },
2176 });
2177 if has_reasoning {
2178 msg["reasoning_content"] = json!(reasoning_content);
2179 }
2180 if has_tool_calls {
2181 msg["tool_calls"] = json!(tool_calls);
2182 pending_tool_calls = tool_call_infos.into_iter().collect();
2183 } else {
2184 pending_tool_calls.clear();
2185 }
2186 out.push(msg);
2187 } else if role == "system" {
2188 let content = text_parts.join("\n");
2189 if !content.trim().is_empty() {
2190 let mut msg = json!({
2191 "role": "system",
2192 "content": content,
2193 });
2194 if include_tool_budget_metadata && let Some(turn_meta) = &turn_meta_budget {
2195 msg["_turn_meta_budget"] = turn_meta_budget_json(turn_meta);
2196 }
2197 out.push(msg);
2198 }
2199 } else if role == "user" {
2200 let content = text_parts.join("\n");
2201 let has_text = !content.trim().is_empty();
2202 let has_images = !image_parts.is_empty();
2203 if has_text || has_images {
2204 let wire_content = if has_images {
2205 let mut parts = Vec::new();
2206 if has_text {
2207 parts.push(json!({
2208 "type": "text",
2209 "text": content,
2210 }));
2211 }
2212 parts.extend(image_parts);
2213 json!(parts)
2214 } else {
2215 json!(content)
2216 };
2217 let mut msg = json!({
2218 "role": "user",
2219 "content": wire_content,
2220 });
2221 if include_tool_budget_metadata && let Some(turn_meta) = &turn_meta_budget {
2222 msg["_turn_meta_budget"] = turn_meta_budget_json(turn_meta);
2223 }
2224 out.push(msg);
2225 }
2226 }
2227
2228 if !tool_results.is_empty() {
2229 if pending_tool_calls.is_empty() {
2230 logging::warn("Dropping tool results without matching tool_calls");
2231 } else {
2232 for (tool_id, content, message_label) in tool_results {
2233 if let Some(tool_info) = pending_tool_calls.remove(&tool_id) {
2234 let wire_result = compact_tool_result_for_wire(
2235 &tool_info.tool_name,
2236 &tool_info.input,
2237 &content,
2238 &message_label,
2239 &mut seen_tool_results,
2240 );
2241 let mut tool_msg = json!({
2242 "role": "tool",
2243 "tool_call_id": tool_id,
2244 "content": wire_result.content,
2245 });
2246 if include_tool_budget_metadata {
2247 tool_msg["_tool_result_budget"] = json!({
2248 "original_chars": wire_result.original_chars,
2249 "sent_chars": wire_result.sent_chars,
2250 "truncated": wire_result.truncated,
2251 "deduplicated": wire_result.deduplicated,
2252 });
2253 }
2254 out.push(tool_msg);
2255 } else {
2256 logging::warn(format!(
2257 "Dropping tool result for unknown tool_call_id: {tool_id}"
2258 ));
2259 }
2260 }
2261 }
2262 } else if role != "assistant" && role != crate::models::INTERRUPTED_ASSISTANT_ROLE {
2263 pending_tool_calls.clear();
2264 }
2265 }
2266
2267 // Safety net: after compaction, an assistant message may have tool_calls
2268 // whose results were summarized away. The API rejects these, so strip
2269 // the tool_calls (downgrading to a plain assistant message) and remove
2270 // the now-orphaned tool result messages.
2271 let mut i = 0;
2272 while i < out.len() {
2273 let is_assistant_with_tools = out[i].get("role").and_then(Value::as_str)
2274 == Some("assistant")
2275 && out[i].get("tool_calls").is_some();
2276
2277 if is_assistant_with_tools {
2278 let expected_ids: HashSet<String> = out[i]
2279 .get("tool_calls")
2280 .and_then(Value::as_array)
2281 .map(|calls| {
2282 calls
2283 .iter()
2284 .filter_map(|c| c.get("id").and_then(Value::as_str).map(String::from))
2285 .collect()
2286 })
2287 .unwrap_or_default();
2288
2289 // Collect tool result IDs immediately following this assistant message.
2290 let mut found_ids: HashSet<String> = HashSet::new();
2291 let mut tool_result_end = i + 1;
2292 while tool_result_end < out.len() {
2293 if out[tool_result_end].get("role").and_then(Value::as_str) == Some("tool") {
2294 if let Some(id) = out[tool_result_end]
2295 .get("tool_call_id")
2296 .and_then(Value::as_str)
2297 {
2298 found_ids.insert(id.to_string());
2299 }
2300 tool_result_end += 1;
2301 } else {
2302 break;
2303 }
2304 }
2305
2306 // Also scan non-contiguous tool results up to the next assistant message
2307 // in case compaction left gaps.
2308 let mut scan = tool_result_end;
2309 while scan < out.len() {
2310 if out[scan].get("role").and_then(Value::as_str) == Some("assistant") {
2311 break;
2312 }
2313 if out[scan].get("role").and_then(Value::as_str) == Some("tool")
2314 && let Some(id) = out[scan].get("tool_call_id").and_then(Value::as_str)
2315 {
2316 found_ids.insert(id.to_string());
2317 }
2318 scan += 1;
2319 }
2320
2321 if !expected_ids.is_subset(&found_ids) {
2322 let missing: Vec<_> = expected_ids.difference(&found_ids).collect();
2323 logging::warn(format!(
2324 "Stripping orphaned tool_calls from assistant message \
2325 (expected {} tool results, found {}, missing: {:?})",
2326 expected_ids.len(),
2327 found_ids.len(),
2328 missing
2329 ));
2330 if let Some(obj) = out[i].as_object_mut() {
2331 obj.remove("tool_calls");
2332 }
2333 // If tool_calls were the only assistant content, remove the now-invalid
2334 // assistant message entirely (DeepSeek requires content or tool_calls).
2335 let assistant_content_empty = out[i]
2336 .get("content")
2337 .is_none_or(|v| v.is_null() || v.as_str().is_some_and(str::is_empty));
2338 if assistant_content_empty {
2339 // Remove orphaned tool results tied to this stripped assistant call set.
2340 let mut j = out.len();
2341 while j > i + 1 {
2342 j -= 1;
2343 if out[j].get("role").and_then(Value::as_str) == Some("tool")
2344 && let Some(id) = out[j].get("tool_call_id").and_then(Value::as_str)
2345 && expected_ids.contains(id)
2346 {
2347 out.remove(j);
2348 }
2349 }
2350 out.remove(i);
2351 i = i.saturating_sub(1);
2352 continue;
2353 }
2354 // Remove contiguous tool results first
2355 if tool_result_end > i + 1 {
2356 out.drain((i + 1)..tool_result_end);
2357 }
2358 // Remove any remaining non-contiguous tool results referencing expected_ids
2359 // (scan backward to avoid index shifting issues)
2360 let mut j = out.len();
2361 while j > i + 1 {
2362 j -= 1;
2363 if out[j].get("role").and_then(Value::as_str) == Some("tool")
2364 && let Some(id) = out[j].get("tool_call_id").and_then(Value::as_str)
2365 && expected_ids.contains(id)
2366 {
2367 out.remove(j);
2368 }
2369 }
2370 }
2371 }
2372 i += 1;
2373 }
2374
2375 out
2376 }
2377
2378 pub(super) fn tool_to_chat(tool: &Tool) -> Value {
2379 let mut value = json!({
2380 "type": "function",
2381 "function": {
2382 "name": to_api_tool_name(&tool.name),
2383 "description": tool.description,
2384 "parameters": tool.input_schema,
2385 }
2386 });
2387 if let Some(strict) = tool.strict
2388 && let Some(function) = value.get_mut("function")
2389 {
2390 function["strict"] = json!(strict);
2391 }
2392 value
2393 }
2394
2395 pub(super) fn tool_to_chat_for_base_url(tool: &Tool, base_url: &str) -> Value {
2396 let mut value = tool_to_chat(tool);
2397 if !deepseek_base_url_supports_strict_tools(base_url)
2398 && let Some(function) = value.get_mut("function")
2399 && let Some(obj) = function.as_object_mut()
2400 {
2401 obj.remove("strict");
2402 }
2403 value
2404 }
2405
2406 fn deepseek_base_url_supports_strict_tools(base_url: &str) -> bool {
2407 let trimmed = base_url.trim_end_matches('/').to_ascii_lowercase();
2408 let is_deepseek = trimmed == "https://api.deepseek.com"
2409 || trimmed == "https://api.deepseek.com/v1"
2410 || trimmed == "https://api.deepseek.com/beta"
2411 || trimmed == "https://api.deepseeki.com"
2412 || trimmed == "https://api.deepseeki.com/v1"
2413 || trimmed == "https://api.deepseeki.com/beta";
2414 !is_deepseek || trimmed.ends_with("/beta")
2415 }
2416
2417 fn map_tool_choice_for_chat(choice: &Value) -> Option<Value> {
2418 if let Some(choice_str) = choice.as_str() {
2419 return Some(json!(choice_str));
2420 }
2421 let Some(choice_type) = choice.get("type").and_then(Value::as_str) else {
2422 return Some(choice.clone());
2423 };
2424
2425 match choice_type {
2426 "auto" | "none" => Some(json!(choice_type)),
2427 "any" => Some(json!("auto")),
2428 "tool" => choice.get("name").and_then(Value::as_str).map(|name| {
2429 json!({
2430 "type": "function",
2431 "function": { "name": to_api_tool_name(name) }
2432 })
2433 }),
2434 _ => Some(choice.clone()),
2435 }
2436 }
2437
2438 fn should_send_tool_choice_for_chat(provider: ApiProvider, effort: Option<&str>) -> bool {
2439 if !matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {
2440 return true;
2441 }
2442 !reasoning_effort_enables_thinking(effort)
2443 }
2444
2445 fn reasoning_effort_enables_thinking(effort: Option<&str>) -> bool {
2446 let Some(effort) = effort else {
2447 return false;
2448 };
2449 !matches!(
2450 effort.trim().to_ascii_lowercase().as_str(),
2451 "off" | "disabled" | "none" | "false"
2452 )
2453 }
2454
2455 /// Final-pass sanitizer over the outgoing chat-completions JSON payload.
2456 /// Forces a non-empty `reasoning_content` onto assistant messages that carry
2457 /// `tool_calls`, when the model + effort combination requires it. DeepSeek's
2458 /// thinking-mode API rejects such messages with a 400 error; substituting a
2459 /// placeholder keeps the conversation chain intact. Non-tool assistant
2460 /// reasoning can stay omitted once a later user text turn begins.
2461 ///
2462 /// Also tallies the size of all replayed `reasoning_content` and logs it, so
2463 /// users on `RUST_LOG=codewhale_tui=debug` can see how much of their input
2464 /// budget is being spent re-sending prior thinking traces.
2465 #[cfg(test)]
2466 pub(super) fn sanitize_thinking_mode_messages(
2467 body: &mut Value,
2468 model: &str,
2469 effort: Option<&str>,
2470 provider: ApiProvider,
2471 ) -> Option<u32> {
2472 sanitize_thinking_mode_messages_for_route(body, model, effort, provider, "")
2473 }
2474
2475 /// Route-aware variant of `sanitize_thinking_mode_messages`.
2476 ///
2477 /// The wrapper above remains intentionally route-agnostic for existing test
2478 /// helpers and generic callers. Production chat requests call this version so
2479 /// exact Kimi Code K3 assistant tool turns retain the reasoning trace that
2480 /// K3 expects on the next request.
2481 pub(super) fn sanitize_thinking_mode_messages_for_route(
2482 body: &mut Value,
2483 model: &str,
2484 effort: Option<&str>,
2485 provider: ApiProvider,
2486 base_url: &str,
2487 ) -> Option<u32> {
2488 if !should_replay_reasoning_content_for_provider_on_route(provider, base_url, model, effort) {
2489 return None;
2490 }
2491 let messages = body.get_mut("messages").and_then(Value::as_array_mut)?;
2492 let mut substitutions: u32 = 0;
2493 let mut replay_chars: u64 = 0;
2494 let mut replay_messages: u32 = 0;
2495 for (idx, msg) in messages.iter_mut().enumerate() {
2496 if msg.get("role").and_then(Value::as_str) != Some("assistant") {
2497 continue;
2498 }
2499 let has_tool_calls = msg.get("tool_calls").is_some();
2500 let needs_placeholder = msg
2501 .get("reasoning_content")
2502 .and_then(Value::as_str)
2503 .is_none_or(|s| s.trim().is_empty());
2504 if has_tool_calls && needs_placeholder {
2505 msg["reasoning_content"] = json!("(reasoning omitted)");
2506 substitutions = substitutions.saturating_add(1);
2507 logging::warn(format!(
2508 "Final sanitizer: forced reasoning_content placeholder on assistant[{idx}]",
2509 ));
2510 }
2511 if let Some(reasoning) = msg.get("reasoning_content").and_then(Value::as_str) {
2512 let len = reasoning.len() as u64;
2513 if len > 0 {
2514 replay_chars = replay_chars.saturating_add(len);
2515 replay_messages = replay_messages.saturating_add(1);
2516 }
2517 }
2518 }
2519 if substitutions > 0 {
2520 logging::warn(format!(
2521 "Final sanitizer: {substitutions} assistant message(s) needed reasoning_content placeholder",
2522 ));
2523 }
2524 if replay_messages == 0 {
2525 return None;
2526 }
2527 // ~4 chars/token is the standard rough estimate; DeepSeek tokens skew
2528 // a touch shorter on Chinese/code but this is order-of-magnitude info.
2529 let approx_tokens = (replay_chars / 4).min(u64::from(u32::MAX)) as u32;
2530 logging::info(format!(
2531 "Reasoning-content replay: {replay_messages} assistant message(s), ~{approx_tokens} input tokens ({replay_chars} chars) being re-sent in this request",
2532 ));
2533 Some(approx_tokens)
2534 }
2535
2536 /// Sums the byte length of `reasoning_content` across all assistant messages in
2537 /// an outgoing chat-completions body. Used by tests; the production sanitizer
2538 /// computes the same number inline and logs it.
2539 #[cfg(test)]
2540 pub(super) fn count_reasoning_replay_chars(body: &Value) -> u64 {
2541 let Some(messages) = body.get("messages").and_then(Value::as_array) else {
2542 return 0;
2543 };
2544 messages
2545 .iter()
2546 .filter(|m| m.get("role").and_then(Value::as_str) == Some("assistant"))
2547 .filter_map(|m| m.get("reasoning_content").and_then(Value::as_str))
2548 .map(|s| s.len() as u64)
2549 .sum()
2550 }
2551
2552 /// Render the transport-shape headers we care about for #103 diagnostics.
2553 /// Always returns SOMETHING printable so the decode-error log line is parseable
2554 /// even when the server stripped a header we expected.
2555 fn format_stream_headers(headers: &reqwest::header::HeaderMap) -> String {
2556 const FIELDS: &[&str] = &[
2557 "content-encoding",
2558 "transfer-encoding",
2559 "connection",
2560 "server",
2561 ];
2562 let mut parts: Vec<String> = Vec::with_capacity(FIELDS.len());
2563 for field in FIELDS {
2564 let rendered = headers
2565 .get(*field)
2566 .and_then(|v| v.to_str().ok())
2567 .unwrap_or("(absent)");
2568 parts.push(format!("{field}={rendered}"));
2569 }
2570 parts.join(", ")
2571 }
2572
2573 /// Diagnostic logger fired when DeepSeek rejects the request despite the
2574 /// sanitizer. Walks the body and logs which assistant messages have tool_calls
2575 /// but no `reasoning_content` — useful to track down a code path that bypasses
2576 /// the sanitizer entirely.
2577 fn log_thinking_mode_violations(body: &Value) {
2578 let Some(messages) = body.get("messages").and_then(Value::as_array) else {
2579 logging::warn("400-after-sanitizer: body has no `messages` array");
2580 return;
2581 };
2582 let mut violations: Vec<String> = Vec::new();
2583 for (idx, msg) in messages.iter().enumerate() {
2584 if msg.get("role").and_then(Value::as_str) != Some("assistant") {
2585 continue;
2586 }
2587 let reasoning = msg
2588 .get("reasoning_content")
2589 .and_then(Value::as_str)
2590 .unwrap_or("");
2591 let has_tc = msg.get("tool_calls").is_some();
2592 if reasoning.trim().is_empty() {
2593 violations.push(format!(
2594 "assistant[{idx}] (reasoning_content missing, tool_calls={has_tc})"
2595 ));
2596 }
2597 }
2598 if violations.is_empty() {
2599 logging::warn(
2600 "400-after-sanitizer: all assistant messages have reasoning_content — DeepSeek rejected for a different reason",
2601 );
2602 } else {
2603 logging::warn(format!(
2604 "400-after-sanitizer: {} assistant message(s) lack reasoning_content despite sanitizer: {}",
2605 violations.len(),
2606 violations.join(", ")
2607 ));
2608 }
2609 }
2610
2611 fn requires_reasoning_content(model: &str) -> bool {
2612 let lower = model.to_lowercase();
2613 // V4-family direct model IDs.
2614 lower.contains("deepseek-v4")
2615 // Public DeepSeek API aliases routed server-side to the V4 family.
2616 // `deepseek-chat` resolves to `deepseek-v4-flash` and `deepseek-reasoner`
2617 // resolves to `deepseek-v4-pro`; both have thinking mode enabled by
2618 // default, so any assistant message carrying tool_calls must replay
2619 // `reasoning_content` on subsequent turns or the API returns 400.
2620 || lower.starts_with("deepseek-chat")
2621 || lower.starts_with("deepseek-reasoner")
2622 // Generic reasoning markers used by custom/proxied deployments.
2623 || lower.contains("reasoner")
2624 || lower.contains("-reasoning")
2625 || lower.contains("-thinking")
2626 || has_deepseek_r_series_marker(&lower)
2627 }
2628
2629 fn should_replay_reasoning_content(model: &str, effort: Option<&str>) -> bool {
2630 if effort
2631 .map(|value| {
2632 matches!(
2633 value.trim().to_ascii_lowercase().as_str(),
2634 "off" | "disabled" | "none" | "false"
2635 )
2636 })
2637 .unwrap_or(false)
2638 {
2639 return false;
2640 }
2641
2642 requires_reasoning_content(model)
2643 }
2644
2645 #[cfg(test)]
2646 fn should_replay_reasoning_content_for_provider(
2647 provider: ApiProvider,
2648 model: &str,
2649 effort: Option<&str>,
2650 ) -> bool {
2651 should_replay_reasoning_content_for_provider_on_route(provider, "", model, effort)
2652 }
2653
2654 /// Route-aware reasoning replay policy.
2655 ///
2656 /// Keep the bare K3 identifier out of the global model catalog: direct
2657 /// Moonshot and arbitrary OpenAI-compatible routes can also expose a `k3`
2658 /// model name, but only Kimi Code's exact membership-plan endpoint has this
2659 /// replay contract.
2660 fn should_replay_reasoning_content_for_provider_on_route(
2661 provider: ApiProvider,
2662 base_url: &str,
2663 model: &str,
2664 effort: Option<&str>,
2665 ) -> bool {
2666 // Both exact K3 routes and Model Studio's thinking-only models are
2667 // always-thinking. A stale caller may still carry `off` before route
2668 // normalization; retaining the assistant reasoning trace is required for
2669 // multi-turn/tool-call continuity regardless.
2670 if is_exact_direct_moonshot_k3_route(provider, base_url, model)
2671 || is_exact_kimi_code_k3_route(provider, base_url, model)
2672 || is_exact_modelstudio_thinking_only_route(provider, base_url, model)
2673 {
2674 return true;
2675 }
2676 if effort
2677 .map(|value| {
2678 matches!(
2679 value.trim().to_ascii_lowercase().as_str(),
2680 "off" | "disabled" | "none" | "false"
2681 )
2682 })
2683 .unwrap_or(false)
2684 {
2685 return false;
2686 }
2687
2688 if requires_reasoning_content(model) {
2689 return true;
2690 }
2691
2692 // Model Studio replay is deliberately narrower than PR #5233 proposed:
2693 // only models Alibaba documents as accepting `preserve_thinking` get their
2694 // `reasoning_content` sent back. `deepseek-v3.1`, `deepseek-v3.2` and the
2695 // `glm-*` ids stay stripped until someone with a Model Studio key confirms
2696 // DashScope does not 400 on `reasoning_content` in input messages.
2697 // `deepseek-v4*` already replays via `requires_reasoning_content` above, so
2698 // this narrowing removes nothing that exists.
2699 if is_exact_modelstudio_chat_route(provider, base_url)
2700 && modelstudio_model_supports_preserve_thinking(model)
2701 {
2702 return true;
2703 }
2704
2705 if !provider_accepts_reasoning_content(provider) {
2706 // Generic non-DeepSeek model on a provider that rejects the field:
2707 // keep stripping it (preserves the #1542 fix). But a known DeepSeek
2708 // reasoning model pointed at a DeepSeek-compatible endpoint via the
2709 // generic `openai` provider still requires reasoning_content replay,
2710 // or the thinking-mode API returns 400 (#1739 / #1694).
2711 return false;
2712 }
2713
2714 model_supports_reasoning(model)
2715 }
2716
2717 /// Should the SSE parser treat incoming `reasoning_content` deltas as thinking
2718 /// (vs. inlining them as answer text)?
2719 ///
2720 /// DeepSeek-family models are classified on any provider because their API
2721 /// requires `reasoning_content` replay on later turns (#1739 / #1694). Other
2722 /// known reasoning-capable large models are classified only on providers whose
2723 /// streaming shape exposes reasoning fields, so `reasoning`/`reasoning_content`
2724 /// deltas become Thinking cells instead of leaking as normal answer text.
2725 #[cfg(test)]
2726 fn is_reasoning_model_for_stream(provider: ApiProvider, model: &str) -> bool {
2727 is_reasoning_model_for_stream_on_route(provider, "", model)
2728 }
2729
2730 /// Route-aware stream classification for providers that share model names.
2731 fn is_reasoning_model_for_stream_on_route(
2732 provider: ApiProvider,
2733 base_url: &str,
2734 model: &str,
2735 ) -> bool {
2736 if is_exact_kimi_code_k3_route(provider, base_url, model)
2737 || is_exact_direct_moonshot_k3_route(provider, base_url, model)
2738 {
2739 return true;
2740 }
2741
2742 if requires_reasoning_content(model) {
2743 return true;
2744 }
2745
2746 // Model Studio's OpenAI-compatible endpoints (Token Plan / Coding Plan)
2747 // stream hybrid-model reasoning as `delta.reasoning_content` (DashScope
2748 // dialect) whenever thinking is on — and for the qwen3.x families thinking
2749 // is on by server default. Surface those deltas as Thinking instead of
2750 // inlining them into the answer text. `reasoning_content` is deliberately
2751 // NOT replayed back on later turns (the provider is absent from
2752 // `provider_accepts_reasoning_content`): DashScope does not require the
2753 // reasoning field in request history.
2754 if matches!(
2755 provider,
2756 ApiProvider::ModelstudioTokenPlan
2757 | ApiProvider::ModelstudioTokenPlanAnthropic
2758 | ApiProvider::ModelstudioCodingPlan
2759 | ApiProvider::ModelstudioCodingPlanAnthropic
2760 ) && model_supports_reasoning(model)
2761 {
2762 return true;
2763 }
2764
2765 provider_accepts_reasoning_content(provider) && model_supports_reasoning(model)
2766 }
2767
2768 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2769 pub(super) enum ReasoningStreamStyle {
2770 SeparateField,
2771 InlineTags,
2772 None,
2773 }
2774
2775 #[cfg(test)]
2776 fn reasoning_stream_style_for_stream(
2777 provider: ApiProvider,
2778 model: &str,
2779 configured: Option<&str>,
2780 ) -> ReasoningStreamStyle {
2781 reasoning_stream_style_for_route(provider, "", model, configured)
2782 }
2783
2784 /// Choose stream decoding semantics for a fully resolved provider route.
2785 fn reasoning_stream_style_for_route(
2786 provider: ApiProvider,
2787 base_url: &str,
2788 model: &str,
2789 configured: Option<&str>,
2790 ) -> ReasoningStreamStyle {
2791 if let Some(configured) = configured {
2792 if let Some(style) = parse_reasoning_stream_style(configured) {
2793 return style;
2794 }
2795 logging::warn(format!(
2796 "Ignoring unrecognized reasoning_stream_style `{configured}`; expected separate_field, inline_tags, or none"
2797 ));
2798 }
2799 if is_reasoning_model_for_stream_on_route(provider, base_url, model) {
2800 ReasoningStreamStyle::SeparateField
2801 } else {
2802 ReasoningStreamStyle::None
2803 }
2804 }
2805
2806 fn parse_reasoning_stream_style(value: &str) -> Option<ReasoningStreamStyle> {
2807 match value.trim().to_ascii_lowercase().replace('-', "_").as_str() {
2808 "separate_field" | "separate" | "field" => Some(ReasoningStreamStyle::SeparateField),
2809 "inline_tags" | "inline" | "think_tags" | "thinking_tags" => {
2810 Some(ReasoningStreamStyle::InlineTags)
2811 }
2812 "none" | "text" | "disabled" | "off" => Some(ReasoningStreamStyle::None),
2813 _ => None,
2814 }
2815 }
2816
2817 /// Providers whose chat-completions API both returns and accepts a dedicated
2818 /// `reasoning_content` field on assistant messages.
2819 ///
2820 /// Arcee is intentionally included. Trinity-Large-Thinking natively emits
2821 /// `<think>...</think>` traces, but Arcee's hosted API serves it through vLLM
2822 /// with `--reasoning-parser deepseek_r1`, which parses those blocks into a
2823 /// `reasoning_content` field (verified live against `api.arcee.ai`: thinking
2824 /// streams as `delta.reasoning_content`, the answer as `delta.content`, with no
2825 /// `<think>` tags on the wire). Arcee's docs require replaying `reasoning_content`
2826 /// on assistant tool-call turns; dropping it makes the model emit tool calls as
2827 /// raw XML inside its thinking ("xml_in_reasoning" pitfall). Do not remove Arcee
2828 /// here without new live evidence — see docs.arcee.ai/capabilities/reasoning-traces.
2829 fn provider_accepts_reasoning_content(provider: ApiProvider) -> bool {
2830 matches!(
2831 provider,
2832 ApiProvider::Deepseek
2833 | ApiProvider::DeepseekCN
2834 | ApiProvider::NvidiaNim
2835 | ApiProvider::Openrouter
2836 | ApiProvider::XiaomiMimo
2837 | ApiProvider::Novita
2838 | ApiProvider::Fireworks
2839 | ApiProvider::Siliconflow
2840 | ApiProvider::SiliconflowCn
2841 | ApiProvider::Volcengine
2842 | ApiProvider::Arcee
2843 | ApiProvider::Minimax
2844 | ApiProvider::Sglang
2845 | ApiProvider::Zai
2846 | ApiProvider::Moonshot // #3016: Kimi thinking traces use reasoning_content
2847 )
2848 }
2849
2850 fn has_deepseek_r_series_marker(model_lower: &str) -> bool {
2851 const PREFIX: &str = "deepseek-r";
2852 model_lower.match_indices(PREFIX).any(|(idx, _)| {
2853 model_lower[idx + PREFIX.len()..]
2854 .chars()
2855 .next()
2856 .is_some_and(|ch| ch.is_ascii_digit())
2857 })
2858 }
2859
2860 fn reasoning_delta(
2861 value: &Value,
2862 choice_index: u32,
2863 reasoning_detail_buffers: &mut std::collections::HashMap<u32, String>,
2864 ) -> Option<String> {
2865 if let Some(reasoning) = value
2866 .get("reasoning_content")
2867 .or_else(|| value.get("reasoning"))
2868 .and_then(Value::as_str)
2869 {
2870 return Some(reasoning.to_string());
2871 }
2872
2873 let details = value.get("reasoning_details").and_then(Value::as_array)?;
2874 let full_text = details
2875 .iter()
2876 .filter_map(|detail| detail.get("text").and_then(Value::as_str))
2877 .collect::<String>();
2878 if full_text.is_empty() {
2879 return None;
2880 }
2881
2882 let previous = reasoning_detail_buffers.entry(choice_index).or_default();
2883 let delta = full_text
2884 .strip_prefix(previous.as_str())
2885 .unwrap_or(&full_text)
2886 .to_string();
2887 *previous = full_text;
2888 Some(delta)
2889 }
2890
2891 fn reasoning_message_text(value: &Value) -> Option<String> {
2892 if let Some(reasoning) = value
2893 .get("reasoning_content")
2894 .or_else(|| value.get("reasoning"))
2895 .and_then(Value::as_str)
2896 {
2897 return Some(reasoning.to_string());
2898 }
2899 value
2900 .get("reasoning_details")
2901 .and_then(Value::as_array)
2902 .map(|details| {
2903 details
2904 .iter()
2905 .filter_map(|detail| detail.get("text").and_then(Value::as_str))
2906 .collect::<String>()
2907 })
2908 }
2909
2910 pub(super) fn parse_chat_message(payload: &Value) -> Result<MessageResponse> {
2911 let id = payload
2912 .get("id")
2913 .and_then(Value::as_str)
2914 .unwrap_or("chatcmpl")
2915 .to_string();
2916 let model = payload
2917 .get("model")
2918 .and_then(Value::as_str)
2919 .unwrap_or("unknown")
2920 .to_string();
2921
2922 let choices = payload
2923 .get("choices")
2924 .and_then(Value::as_array)
2925 .context("Chat API response missing choices")?;
2926 let choice = choices
2927 .first()
2928 .context("Chat API response missing first choice")?;
2929 let message = choice
2930 .get("message")
2931 .context("Chat API response missing message")?;
2932
2933 let mut content_blocks = Vec::new();
2934 if let Some(reasoning) =
2935 reasoning_message_text(message).filter(|reasoning| !reasoning.trim().is_empty())
2936 {
2937 content_blocks.push(ContentBlock::Thinking {
2938 signature: None,
2939 thinking: reasoning.to_string(),
2940 });
2941 }
2942 if let Some(text) = message.get("content").and_then(Value::as_str)
2943 && !text.trim().is_empty()
2944 {
2945 content_blocks.push(ContentBlock::Text {
2946 text: text.to_string(),
2947 cache_control: None,
2948 });
2949 }
2950
2951 if let Some(tool_calls) = message.get("tool_calls").and_then(Value::as_array) {
2952 for call in tool_calls {
2953 let id = call
2954 .get("id")
2955 .and_then(Value::as_str)
2956 .unwrap_or("tool_call")
2957 .to_string();
2958 let function = call.get("function");
2959 let name = tool_name_or_fallback(
2960 function.and_then(|f| f.get("name")).and_then(Value::as_str),
2961 &id,
2962 "Non-streaming response",
2963 );
2964 let arguments = function
2965 .and_then(|f| f.get("arguments"))
2966 .and_then(Value::as_str)
2967 .map(|raw| serde_json::from_str(raw).unwrap_or(Value::String(raw.to_string())))
2968 .unwrap_or(Value::Null);
2969 let caller = call.get("caller").and_then(|v| {
2970 v.get("type")
2971 .and_then(Value::as_str)
2972 .map(|caller_type| ToolCaller {
2973 caller_type: caller_type.to_string(),
2974 tool_id: v
2975 .get("tool_id")
2976 .and_then(Value::as_str)
2977 .map(std::string::ToString::to_string),
2978 })
2979 });
2980
2981 content_blocks.push(ContentBlock::ToolUse {
2982 id,
2983 name: from_api_tool_name(&name),
2984 input: arguments,
2985 caller,
2986 });
2987 }
2988 }
2989
2990 let usage = parse_usage(payload.get("usage"));
2991
2992 Ok(MessageResponse {
2993 id,
2994 r#type: "message".to_string(),
2995 role: "assistant".to_string(),
2996 content: content_blocks,
2997 model,
2998 stop_reason: choice
2999 .get("finish_reason")
3000 .and_then(Value::as_str)
3001 .map(str::to_string),
3002 stop_sequence: None,
3003 container: None,
3004 usage,
3005 })
3006 }
3007
3008 #[derive(Debug, Default)]
3009 struct InlineReasoningTagState {
3010 inside_think: bool,
3011 pending: String,
3012 }
3013
3014 #[derive(Debug, PartialEq, Eq)]
3015 enum ReasoningSegment {
3016 Text(String),
3017 Thinking(String),
3018 }
3019
3020 fn inline_reasoning_segments(
3021 content: &str,
3022 state: &mut InlineReasoningTagState,
3023 flush: bool,
3024 ) -> Vec<ReasoningSegment> {
3025 state.pending.push_str(content);
3026 let mut segments = Vec::new();
3027
3028 loop {
3029 if state.pending.is_empty() {
3030 break;
3031 }
3032
3033 if state.inside_think {
3034 if let Some(close_at) = state.pending.find("</think>") {
3035 push_reasoning_segment(
3036 &mut segments,
3037 ReasoningSegment::Thinking(state.pending[..close_at].to_string()),
3038 );
3039 state.pending.drain(..close_at + "</think>".len());
3040 state.inside_think = false;
3041 continue;
3042 }
3043
3044 let hold_len = if flush {
3045 0
3046 } else {
3047 trailing_tag_prefix_len(&state.pending, "</think>")
3048 };
3049 let emit_len = state.pending.len().saturating_sub(hold_len);
3050 if emit_len > 0 {
3051 push_reasoning_segment(
3052 &mut segments,
3053 ReasoningSegment::Thinking(state.pending[..emit_len].to_string()),
3054 );
3055 state.pending.drain(..emit_len);
3056 }
3057 break;
3058 }
3059
3060 if let Some(open_at) = state.pending.find("<think>") {
3061 push_reasoning_segment(
3062 &mut segments,
3063 ReasoningSegment::Text(state.pending[..open_at].to_string()),
3064 );
3065 state.pending.drain(..open_at + "<think>".len());
3066 state.inside_think = true;
3067 continue;
3068 }
3069
3070 let hold_len = if flush {
3071 0
3072 } else {
3073 trailing_tag_prefix_len(&state.pending, "<think>")
3074 };
3075 let emit_len = state.pending.len().saturating_sub(hold_len);
3076 if emit_len > 0 {
3077 push_reasoning_segment(
3078 &mut segments,
3079 ReasoningSegment::Text(state.pending[..emit_len].to_string()),
3080 );
3081 state.pending.drain(..emit_len);
3082 }
3083 break;
3084 }
3085
3086 segments
3087 }
3088
3089 fn trailing_tag_prefix_len(content: &str, tag: &str) -> usize {
3090 let max_len = tag.len().min(content.len());
3091 for len in (1..=max_len).rev() {
3092 let start = content.len() - len;
3093 if content.is_char_boundary(start) && tag.starts_with(&content[start..]) {
3094 return len;
3095 }
3096 }
3097 0
3098 }
3099
3100 fn push_reasoning_segment(segments: &mut Vec<ReasoningSegment>, segment: ReasoningSegment) {
3101 match &segment {
3102 ReasoningSegment::Text(text) | ReasoningSegment::Thinking(text) if text.is_empty() => {}
3103 _ => segments.push(segment),
3104 }
3105 }
3106
3107 fn push_text_delta(
3108 events: &mut Vec<StreamEvent>,
3109 content_index: &mut u32,
3110 text_started: &mut bool,
3111 thinking_started: &mut bool,
3112 text: String,
3113 ) {
3114 if *thinking_started {
3115 events.push(StreamEvent::ContentBlockStop {
3116 index: *content_index,
3117 });
3118 *content_index += 1;
3119 *thinking_started = false;
3120 }
3121 if !*text_started {
3122 events.push(StreamEvent::ContentBlockStart {
3123 index: *content_index,
3124 content_block: ContentBlockStart::Text {
3125 text: String::new(),
3126 },
3127 });
3128 *text_started = true;
3129 }
3130 events.push(StreamEvent::ContentBlockDelta {
3131 index: *content_index,
3132 delta: Delta::TextDelta { text },
3133 });
3134 }
3135
3136 fn push_thinking_delta(
3137 events: &mut Vec<StreamEvent>,
3138 content_index: &mut u32,
3139 text_started: &mut bool,
3140 thinking_started: &mut bool,
3141 thinking: String,
3142 ) {
3143 if *text_started {
3144 events.push(StreamEvent::ContentBlockStop {
3145 index: *content_index,
3146 });
3147 *content_index += 1;
3148 *text_started = false;
3149 }
3150 if !*thinking_started {
3151 events.push(StreamEvent::ContentBlockStart {
3152 index: *content_index,
3153 content_block: ContentBlockStart::Thinking {
3154 thinking: String::new(),
3155 },
3156 });
3157 *thinking_started = true;
3158 }
3159 events.push(StreamEvent::ContentBlockDelta {
3160 index: *content_index,
3161 delta: Delta::ThinkingDelta { thinking },
3162 });
3163 }
3164
3165 // === SSE Chunk Parser ===
3166
3167 enum SseDataFrame {
3168 Done,
3169 Events(Vec<StreamEvent>),
3170 }
3171
3172 // The six `&mut` streaming-state fields plus the style flag are a deliberate,
3173 // shared parser-state set (mirrored by `parse_sse_chunk*`); bundling them into a
3174 // struct would only add reborrow noise on this hot SSE path.
3175 #[allow(clippy::too_many_arguments)]
3176 fn parse_sse_data_frame(
3177 data: &str,
3178 content_index: &mut u32,
3179 text_started: &mut bool,
3180 thinking_started: &mut bool,
3181 tool_indices: &mut std::collections::HashMap<u32, u32>,
3182 reasoning_detail_buffers: &mut std::collections::HashMap<u32, String>,
3183 inline_reasoning_tags: &mut InlineReasoningTagState,
3184 reasoning_stream_style: ReasoningStreamStyle,
3185 ) -> SseDataFrame {
3186 if data.trim() == "[DONE]" {
3187 return SseDataFrame::Done;
3188 }
3189 let events = serde_json::from_str::<Value>(data).map_or_else(
3190 |_| Vec::new(),
3191 |chunk_json| {
3192 parse_sse_chunk_with_reasoning_style(
3193 &chunk_json,
3194 content_index,
3195 text_started,
3196 thinking_started,
3197 tool_indices,
3198 reasoning_detail_buffers,
3199 inline_reasoning_tags,
3200 reasoning_stream_style,
3201 )
3202 },
3203 );
3204 SseDataFrame::Events(events)
3205 }
3206
3207 /// Parse a single SSE chunk from the Chat Completions streaming API into
3208 /// our internal `StreamEvent` representation.
3209 #[cfg(test)]
3210 pub(super) fn parse_sse_chunk(
3211 chunk: &Value,
3212 content_index: &mut u32,
3213 text_started: &mut bool,
3214 thinking_started: &mut bool,
3215 tool_indices: &mut std::collections::HashMap<u32, u32>,
3216 reasoning_detail_buffers: &mut std::collections::HashMap<u32, String>,
3217 is_reasoning_model: bool,
3218 ) -> Vec<StreamEvent> {
3219 let mut inline_reasoning_tags = InlineReasoningTagState::default();
3220 let reasoning_stream_style = if is_reasoning_model {
3221 ReasoningStreamStyle::SeparateField
3222 } else {
3223 ReasoningStreamStyle::None
3224 };
3225 parse_sse_chunk_with_reasoning_style(
3226 chunk,
3227 content_index,
3228 text_started,
3229 thinking_started,
3230 tool_indices,
3231 reasoning_detail_buffers,
3232 &mut inline_reasoning_tags,
3233 reasoning_stream_style,
3234 )
3235 }
3236
3237 // Same deliberate shared parser-state set as `parse_sse_data_frame`.
3238 #[allow(clippy::too_many_arguments)]
3239 fn parse_sse_chunk_with_reasoning_style(
3240 chunk: &Value,
3241 content_index: &mut u32,
3242 text_started: &mut bool,
3243 thinking_started: &mut bool,
3244 tool_indices: &mut std::collections::HashMap<u32, u32>,
3245 reasoning_detail_buffers: &mut std::collections::HashMap<u32, String>,
3246 inline_reasoning_tags: &mut InlineReasoningTagState,
3247 reasoning_stream_style: ReasoningStreamStyle,
3248 ) -> Vec<StreamEvent> {
3249 let mut events = Vec::new();
3250
3251 let Some(choices) = chunk.get("choices").and_then(Value::as_array) else {
3252 // Usage-only chunk (sent at end with stream_options)
3253 if let Some(usage_val) = chunk.get("usage") {
3254 let usage = parse_usage(Some(usage_val));
3255 events.push(StreamEvent::MessageDelta {
3256 delta: MessageDelta {
3257 stop_reason: None,
3258 stop_sequence: None,
3259 },
3260 usage: Some(usage),
3261 });
3262 }
3263 return events;
3264 };
3265
3266 if choices.is_empty() {
3267 if let Some(usage_val) = chunk.get("usage") {
3268 let usage = parse_usage(Some(usage_val));
3269 events.push(StreamEvent::MessageDelta {
3270 delta: MessageDelta {
3271 stop_reason: None,
3272 stop_sequence: None,
3273 },
3274 usage: Some(usage),
3275 });
3276 }
3277 return events;
3278 }
3279
3280 for choice in choices {
3281 let choice_index = choice.get("index").and_then(Value::as_u64).unwrap_or(0) as u32;
3282 let delta = choice.get("delta");
3283 let finish_reason = choice
3284 .get("finish_reason")
3285 .and_then(Value::as_str)
3286 .map(str::to_string);
3287
3288 if let Some(delta) = delta {
3289 let reasoning_text = reasoning_delta(delta, choice_index, reasoning_detail_buffers)
3290 .filter(|s| !s.is_empty());
3291 let content_text = delta
3292 .get("content")
3293 .and_then(Value::as_str)
3294 .filter(|s| !s.is_empty())
3295 .map(str::to_string);
3296
3297 // Handle reasoning_content / reasoning thinking deltas.
3298 if reasoning_stream_style == ReasoningStreamStyle::SeparateField
3299 && let Some(reasoning) = reasoning_text.as_deref()
3300 {
3301 push_thinking_delta(
3302 &mut events,
3303 content_index,
3304 text_started,
3305 thinking_started,
3306 reasoning.to_string(),
3307 );
3308 }
3309
3310 // Generic OpenAI-compatible proxies sometimes stream answer text
3311 // in `reasoning_content`. If this route is configured with no
3312 // reasoning semantics, render that field as normal text when no
3313 // `content` delta is present.
3314 match (content_text, reasoning_stream_style) {
3315 (Some(content), ReasoningStreamStyle::InlineTags) => {
3316 for segment in inline_reasoning_segments(&content, inline_reasoning_tags, false)
3317 {
3318 match segment {
3319 ReasoningSegment::Text(text) => push_text_delta(
3320 &mut events,
3321 content_index,
3322 text_started,
3323 thinking_started,
3324 text,
3325 ),
3326 ReasoningSegment::Thinking(thinking) => push_thinking_delta(
3327 &mut events,
3328 content_index,
3329 text_started,
3330 thinking_started,
3331 thinking,
3332 ),
3333 }
3334 }
3335 }
3336 (Some(content), _) => push_text_delta(
3337 &mut events,
3338 content_index,
3339 text_started,
3340 thinking_started,
3341 content,
3342 ),
3343 (None, ReasoningStreamStyle::None) => {
3344 if let Some(content) = reasoning_text {
3345 push_text_delta(
3346 &mut events,
3347 content_index,
3348 text_started,
3349 thinking_started,
3350 content,
3351 );
3352 }
3353 }
3354 (None, _) => {}
3355 }
3356
3357 // Handle tool calls
3358 if let Some(tool_calls) = delta.get("tool_calls").and_then(Value::as_array) {
3359 for tc in tool_calls {
3360 let tc_index = tc.get("index").and_then(Value::as_u64).unwrap_or(0) as u32;
3361 let tool_block_index = match tool_indices.entry(tc_index) {
3362 std::collections::hash_map::Entry::Occupied(entry) => *entry.get(),
3363 std::collections::hash_map::Entry::Vacant(entry) => {
3364 // Close text block if transitioning to tool use
3365 if *text_started {
3366 events.push(StreamEvent::ContentBlockStop {
3367 index: *content_index,
3368 });
3369 *content_index += 1;
3370 *text_started = false;
3371 }
3372 if *thinking_started {
3373 events.push(StreamEvent::ContentBlockStop {
3374 index: *content_index,
3375 });
3376 *content_index += 1;
3377 *thinking_started = false;
3378 }
3379
3380 let block_index = *content_index;
3381 let id = tc
3382 .get("id")
3383 .and_then(Value::as_str)
3384 .map(str::to_string)
3385 // Some upstream gateways (and the responses-API
3386 // bridge) elide the `id` on the first chunk of a
3387 // tool call. Falling back to a constant string
3388 // collides when the model emits parallel tool
3389 // calls in the same delta — every call ended up
3390 // with the same id and downstream tool-result
3391 // routing matched the first one twice. Index by
3392 // the content-block position to keep the
3393 // fallback unique within the response.
3394 .unwrap_or_else(|| format!("call_{block_index}"));
3395 let name = tc
3396 .get("function")
3397 .and_then(|f| f.get("name"))
3398 .and_then(Value::as_str);
3399 let name = tool_name_or_fallback(name, &id, "Streaming response chunk");
3400 let caller = tc.get("caller").and_then(|v| {
3401 v.get("type").and_then(Value::as_str).map(|caller_type| {
3402 ToolCaller {
3403 caller_type: caller_type.to_string(),
3404 tool_id: v
3405 .get("tool_id")
3406 .and_then(Value::as_str)
3407 .map(std::string::ToString::to_string),
3408 }
3409 })
3410 });
3411
3412 events.push(StreamEvent::ContentBlockStart {
3413 index: block_index,
3414 content_block: ContentBlockStart::ToolUse {
3415 id,
3416 name: from_api_tool_name(&name),
3417 input: json!({}),
3418 caller,
3419 },
3420 });
3421 *content_index = (*content_index).saturating_add(1);
3422 entry.insert(block_index);
3423 block_index
3424 }
3425 };
3426
3427 // Stream tool call arguments
3428 if let Some(args) = tc
3429 .get("function")
3430 .and_then(|f| f.get("arguments"))
3431 .and_then(Value::as_str)
3432 && !args.is_empty()
3433 {
3434 events.push(StreamEvent::ContentBlockDelta {
3435 index: tool_block_index,
3436 delta: Delta::InputJsonDelta {
3437 partial_json: args.to_string(),
3438 },
3439 });
3440 }
3441 }
3442 }
3443 }
3444
3445 // Handle finish reason
3446 if let Some(reason) = finish_reason {
3447 if reasoning_stream_style == ReasoningStreamStyle::InlineTags {
3448 for segment in inline_reasoning_segments("", inline_reasoning_tags, true) {
3449 match segment {
3450 ReasoningSegment::Text(text) => push_text_delta(
3451 &mut events,
3452 content_index,
3453 text_started,
3454 thinking_started,
3455 text,
3456 ),
3457 ReasoningSegment::Thinking(thinking) => push_thinking_delta(
3458 &mut events,
3459 content_index,
3460 text_started,
3461 thinking_started,
3462 thinking,
3463 ),
3464 }
3465 }
3466 }
3467 // Close any open blocks
3468 if *text_started {
3469 events.push(StreamEvent::ContentBlockStop {
3470 index: *content_index,
3471 });
3472 *text_started = false;
3473 }
3474 if *thinking_started {
3475 events.push(StreamEvent::ContentBlockStop {
3476 index: *content_index,
3477 });
3478 *thinking_started = false;
3479 }
3480 // Close tool blocks
3481 let mut open_tool_indices: Vec<u32> =
3482 tool_indices.drain().map(|(_, idx)| idx).collect();
3483 open_tool_indices.sort_unstable();
3484 for tool_block_index in open_tool_indices {
3485 events.push(StreamEvent::ContentBlockStop {
3486 index: tool_block_index,
3487 });
3488 }
3489
3490 // Emit usage from the chunk if available
3491 let chunk_usage = chunk.get("usage").map(|u| parse_usage(Some(u)));
3492 events.push(StreamEvent::MessageDelta {
3493 delta: MessageDelta {
3494 stop_reason: Some(reason),
3495 stop_sequence: None,
3496 },
3497 usage: chunk_usage,
3498 });
3499 }
3500 }
3501
3502 events
3503 }
3504
3505 fn tool_name_or_fallback(name: Option<&str>, id: &str, source: &str) -> String {
3506 let trimmed = name.unwrap_or("").trim();
3507 if trimmed.is_empty() {
3508 logging::warn(format!(
3509 "{source} returned an empty tool name for call {id}; using unknown_tool"
3510 ));
3511 "unknown_tool".to_string()
3512 } else {
3513 trimmed.to_string()
3514 }
3515 }
3516
3517 // === #103 Phase 1: stream-decode diagnostics ===================================
3518
3519 #[cfg(test)]
3520 mod stream_diagnostics_tests {
3521 use super::*;
3522 use reqwest::header::{HeaderMap, HeaderValue};
3523
3524 #[test]
3525 fn stream_idle_timeout_reports_progress_and_timing() {
3526 let message = stream_idle_timeout_message(
3527 Duration::from_secs(240),
3528 8192,
3529 Duration::from_millis(73_500),
3530 Duration::from_millis(41_250),
3531 );
3532
3533 assert_eq!(
3534 message,
3535 "SSE stream idle timeout after 240s — no data received \
3536 (bytes_received=8192, stream_age_ms=73500, ms_since_last_chunk=41250)"
3537 );
3538 }
3539
3540 #[test]
3541 fn deepseek_thinking_omits_tool_choice() {
3542 for effort in [Some("high"), Some("max"), Some("medium"), Some("")] {
3543 assert!(
3544 !should_send_tool_choice_for_chat(ApiProvider::Deepseek, effort),
3545 "DeepSeek thinking rejects explicit tool_choice for {effort:?}"
3546 );
3547 assert!(
3548 !should_send_tool_choice_for_chat(ApiProvider::DeepseekCN, effort),
3549 "DeepSeek CN thinking rejects explicit tool_choice for {effort:?}"
3550 );
3551 }
3552
3553 for effort in [
3554 None,
3555 Some("off"),
3556 Some("disabled"),
3557 Some("none"),
3558 Some("false"),
3559 ] {
3560 assert!(should_send_tool_choice_for_chat(
3561 ApiProvider::Deepseek,
3562 effort
3563 ));
3564 }
3565 assert!(should_send_tool_choice_for_chat(
3566 ApiProvider::Openrouter,
3567 Some("high")
3568 ));
3569 }
3570
3571 #[test]
3572 fn format_stream_headers_renders_all_fields_when_present() {
3573 let mut headers = HeaderMap::new();
3574 headers.insert("content-encoding", HeaderValue::from_static("gzip"));
3575 headers.insert("transfer-encoding", HeaderValue::from_static("chunked"));
3576 headers.insert("connection", HeaderValue::from_static("keep-alive"));
3577 headers.insert("server", HeaderValue::from_static("openresty/1.25.3.1"));
3578
3579 let rendered = format_stream_headers(&headers);
3580 // Order is fixed by FIELDS in the helper; assert each field appears.
3581 assert!(
3582 rendered.contains("content-encoding=gzip"),
3583 "got: {rendered}"
3584 );
3585 assert!(
3586 rendered.contains("transfer-encoding=chunked"),
3587 "got: {rendered}"
3588 );
3589 assert!(
3590 rendered.contains("connection=keep-alive"),
3591 "got: {rendered}"
3592 );
3593 assert!(
3594 rendered.contains("server=openresty/1.25.3.1"),
3595 "got: {rendered}"
3596 );
3597 }
3598
3599 #[test]
3600 fn format_stream_headers_marks_missing_fields_as_absent() {
3601 // DeepSeek frequently omits content-encoding when not compressing.
3602 // The diagnostic must still produce a parseable line so log scrapers
3603 // don't lose the slot.
3604 let headers = HeaderMap::new();
3605 let rendered = format_stream_headers(&headers);
3606 assert!(
3607 rendered.contains("content-encoding=(absent)"),
3608 "missing field must be explicitly marked; got: {rendered}"
3609 );
3610 assert!(
3611 rendered.contains("transfer-encoding=(absent)"),
3612 "missing field must be explicitly marked; got: {rendered}"
3613 );
3614 }
3615
3616 #[test]
3617 fn format_stream_headers_handles_non_ascii_value_gracefully() {
3618 // If a header value isn't UTF-8, `.to_str()` fails — we must not panic
3619 // and should still produce a parseable line.
3620 let mut headers = HeaderMap::new();
3621 // 0xFF is a valid byte but invalid UTF-8 start byte.
3622 headers.insert(
3623 "server",
3624 HeaderValue::from_bytes(b"\xff\xfemystery").expect("header value"),
3625 );
3626 let rendered = format_stream_headers(&headers);
3627 assert!(
3628 rendered.contains("server=(absent)"),
3629 "non-UTF8 header values fall back to (absent); got: {rendered}"
3630 );
3631 }
3632 }
3633
3634 #[cfg(test)]
3635 mod arcee_waf_message_encoding_tests {
3636 use super::build_chat_messages_for_request_and_provider;
3637 use crate::config::ApiProvider;
3638 use crate::models::{MessageRequest, SystemPrompt};
3639 use serde_json::Value;
3640
3641 fn request_with_system(system: &str) -> MessageRequest {
3642 MessageRequest {
3643 model: "trinity-large-thinking".to_string(),
3644 messages: Vec::new(),
3645 max_tokens: 16,
3646 system: Some(SystemPrompt::Text(system.to_string())),
3647 tools: None,
3648 tool_choice: None,
3649 metadata: None,
3650 thinking: None,
3651 reasoning_effort: None,
3652 stream: None,
3653 temperature: None,
3654 top_p: None,
3655 }
3656 }
3657
3658 fn decoded_content(content: &Value) -> String {
3659 if let Some(text) = content.as_str() {
3660 return text.to_string();
3661 }
3662 content
3663 .as_array()
3664 .expect("content parts")
3665 .iter()
3666 .map(|part| part.get("text").and_then(Value::as_str).expect("text part"))
3667 .collect()
3668 }
3669
3670 #[test]
3671 fn arcee_splits_waf_trigger_without_changing_decoded_system_prompt() {
3672 let system = "Run calculations with `python -c 'print(1)'` when a tool is available.";
3673 let request = request_with_system(system);
3674
3675 let messages = build_chat_messages_for_request_and_provider(&request, ApiProvider::Arcee);
3676 let content = &messages[0]["content"];
3677
3678 assert!(
3679 content.is_array(),
3680 "Arcee system content with a WAF trigger should be encoded as text parts"
3681 );
3682 assert_eq!(decoded_content(content), system);
3683 let serialized = serde_json::to_string(&messages).expect("serialize messages");
3684 assert!(
3685 !serialized.contains("python -c"),
3686 "wire JSON should not contain the Cloudflare trigger contiguously: {serialized}"
3687 );
3688 }
3689
3690 #[test]
3691 fn non_arcee_providers_keep_system_prompt_as_string() {
3692 let system = "Run calculations with `python -c 'print(1)'` when a tool is available.";
3693 let request = request_with_system(system);
3694
3695 let messages = build_chat_messages_for_request_and_provider(&request, ApiProvider::Openai);
3696
3697 assert_eq!(messages[0]["content"].as_str(), Some(system));
3698 }
3699
3700 #[test]
3701 fn arcee_keeps_non_triggering_system_prompt_as_string() {
3702 let system = "Use read-only tools to inspect files before reporting results.";
3703 let request = request_with_system(system);
3704
3705 let messages = build_chat_messages_for_request_and_provider(&request, ApiProvider::Arcee);
3706
3707 assert_eq!(messages[0]["content"].as_str(), Some(system));
3708 }
3709 }
3710
3711 #[cfg(test)]
3712 mod minimax_reasoning_replay_tests {
3713 use super::{
3714 build_chat_messages_for_request_and_provider,
3715 build_chat_messages_for_request_and_provider_and_route,
3716 };
3717 use crate::config::{
3718 ApiProvider, DEFAULT_KIMI_CODE_BASE_URL, DEFAULT_MINIMAX_MODEL, DEFAULT_MOONSHOT_BASE_URL,
3719 KIMI_CODE_K3_MODEL,
3720 };
3721 use crate::models::{ContentBlock, Message, MessageRequest};
3722
3723 fn request_with_assistant_thinking() -> MessageRequest {
3724 MessageRequest {
3725 model: DEFAULT_MINIMAX_MODEL.to_string(),
3726 messages: vec![Message {
3727 role: "assistant".to_string(),
3728 content: vec![
3729 ContentBlock::Thinking {
3730 thinking: "Inspect tool state".to_string(),
3731 signature: None,
3732 },
3733 ContentBlock::Text {
3734 text: "Done.".to_string(),
3735 cache_control: None,
3736 },
3737 ],
3738 }],
3739 max_tokens: 16,
3740 system: None,
3741 tools: None,
3742 tool_choice: None,
3743 metadata: None,
3744 thinking: None,
3745 reasoning_effort: None,
3746 stream: None,
3747 temperature: None,
3748 top_p: None,
3749 }
3750 }
3751
3752 #[test]
3753 fn minimax_history_replays_thinking_as_reasoning_details() {
3754 let request = request_with_assistant_thinking();
3755
3756 let messages = build_chat_messages_for_request_and_provider(&request, ApiProvider::Minimax);
3757 let assistant = &messages[0];
3758
3759 assert_eq!(
3760 assistant
3761 .get("reasoning_content")
3762 .and_then(|value| value.as_str()),
3763 Some("Inspect tool state")
3764 );
3765 assert_eq!(
3766 assistant
3767 .pointer("/reasoning_details/0/type")
3768 .and_then(|value| value.as_str()),
3769 Some("text")
3770 );
3771 assert_eq!(
3772 assistant
3773 .pointer("/reasoning_details/0/text")
3774 .and_then(|value| value.as_str()),
3775 Some("Inspect tool state")
3776 );
3777 }
3778
3779 #[test]
3780 fn kimi_code_k3_replays_thinking_only_on_the_exact_membership_route() {
3781 let mut request = request_with_assistant_thinking();
3782 request.model = KIMI_CODE_K3_MODEL.to_string();
3783
3784 let exact = build_chat_messages_for_request_and_provider_and_route(
3785 &request,
3786 ApiProvider::Moonshot,
3787 DEFAULT_KIMI_CODE_BASE_URL,
3788 );
3789 assert_eq!(
3790 exact[0]
3791 .get("reasoning_content")
3792 .and_then(serde_json::Value::as_str),
3793 Some("Inspect tool state")
3794 );
3795
3796 let neighbor = build_chat_messages_for_request_and_provider_and_route(
3797 &request,
3798 ApiProvider::Moonshot,
3799 DEFAULT_MOONSHOT_BASE_URL,
3800 );
3801 assert!(
3802 neighbor[0].get("reasoning_content").is_none(),
3803 "a generic Moonshot k3 identifier must not inherit Kimi Code replay"
3804 );
3805 }
3806 }
3807
3808 // === #103 Phase 4: SSE decoder behavior on canned chunk sequences ============
3809
3810 #[cfg(test)]
3811 mod stream_decoder_tests {
3812 //! Drive `parse_sse_chunk` (the in-place SSE event extractor) over canned
3813 //! chunk sequences. The full `handle_chat_completion_stream` path needs a
3814 //! live `reqwest::Response` so it isn't unit-testable without a mock HTTP
3815 //! harness (issue #69 tracks that). For #103 we exercise the chunk decoder
3816 //! directly to verify each "class of stream failure" the engine relies on.
3817 use super::*;
3818 use crate::models::{ContentBlockStart, Delta, StreamEvent};
3819
3820 /// Decode a raw SSE-data JSON chunk into our internal events, mirroring
3821 /// the per-event call shape used by `handle_chat_completion_stream`.
3822 fn decode_chunk(json_text: &str) -> Vec<StreamEvent> {
3823 decode_chunk_with_reasoning(json_text, true)
3824 }
3825
3826 fn decode_chunk_with_reasoning(json_text: &str, is_reasoning_model: bool) -> Vec<StreamEvent> {
3827 let chunk: Value = serde_json::from_str(json_text).expect("valid SSE JSON");
3828 let mut content_index = 0u32;
3829 let mut text_started = false;
3830 let mut thinking_started = false;
3831 let mut tool_indices = std::collections::HashMap::new();
3832 let mut reasoning_detail_buffers = std::collections::HashMap::new();
3833 parse_sse_chunk(
3834 &chunk,
3835 &mut content_index,
3836 &mut text_started,
3837 &mut thinking_started,
3838 &mut tool_indices,
3839 &mut reasoning_detail_buffers,
3840 is_reasoning_model,
3841 )
3842 }
3843
3844 fn decode_chunks_with_style(
3845 chunks: &[&str],
3846 reasoning_stream_style: ReasoningStreamStyle,
3847 ) -> Vec<StreamEvent> {
3848 let mut content_index = 0u32;
3849 let mut text_started = false;
3850 let mut thinking_started = false;
3851 let mut tool_indices = std::collections::HashMap::new();
3852 let mut reasoning_detail_buffers = std::collections::HashMap::new();
3853 let mut inline_reasoning_tags = InlineReasoningTagState::default();
3854 let mut events = Vec::new();
3855
3856 for chunk in chunks {
3857 let value: Value = serde_json::from_str(chunk).expect("valid SSE JSON");
3858 events.extend(parse_sse_chunk_with_reasoning_style(
3859 &value,
3860 &mut content_index,
3861 &mut text_started,
3862 &mut thinking_started,
3863 &mut tool_indices,
3864 &mut reasoning_detail_buffers,
3865 &mut inline_reasoning_tags,
3866 reasoning_stream_style,
3867 ));
3868 }
3869 events
3870 }
3871
3872 fn text_delta_text(events: &[StreamEvent]) -> String {
3873 events
3874 .iter()
3875 .filter_map(|event| match event {
3876 StreamEvent::ContentBlockDelta {
3877 delta: Delta::TextDelta { text },
3878 ..
3879 } => Some(text.as_str()),
3880 _ => None,
3881 })
3882 .collect()
3883 }
3884
3885 fn thinking_delta_text(events: &[StreamEvent]) -> String {
3886 events
3887 .iter()
3888 .filter_map(|event| match event {
3889 StreamEvent::ContentBlockDelta {
3890 delta: Delta::ThinkingDelta { thinking },
3891 ..
3892 } => Some(thinking.as_str()),
3893 _ => None,
3894 })
3895 .collect()
3896 }
3897
3898 #[test]
3899 fn decoder_emits_text_delta_for_content_chunk() {
3900 // The "happy" first chunk: a normal content delta. The engine treats
3901 // this as `any_content_received = true` and would NOT transparently
3902 // retry on a subsequent error.
3903 let events = decode_chunk(r#"{"choices":[{"delta":{"content":"hello"}}]}"#);
3904 assert!(
3905 matches!(
3906 events.first(),
3907 Some(StreamEvent::ContentBlockStart {
3908 content_block: ContentBlockStart::Text { .. },
3909 ..
3910 })
3911 ),
3912 "first event should open a text block; got {events:?}"
3913 );
3914 assert!(
3915 events
3916 .iter()
3917 .any(|e| matches!(e, StreamEvent::ContentBlockDelta {
3918 delta: Delta::TextDelta { text },
3919 ..
3920 } if text == "hello")),
3921 "should yield a TextDelta carrying 'hello'; got {events:?}"
3922 );
3923 }
3924
3925 #[test]
3926 fn decoder_emits_thinking_delta_for_reasoning_chunk() {
3927 // V4 thinking models surface reasoning_content first — the engine
3928 // also counts these as content received (so a subsequent stream error
3929 // surfaces rather than retrying transparently).
3930 let events = decode_chunk(r#"{"choices":[{"delta":{"reasoning_content":"plan..."}}]}"#);
3931 assert!(
3932 matches!(
3933 events.first(),
3934 Some(StreamEvent::ContentBlockStart {
3935 content_block: ContentBlockStart::Thinking { .. },
3936 ..
3937 })
3938 ),
3939 "first event should open a thinking block; got {events:?}"
3940 );
3941 assert!(
3942 events
3943 .iter()
3944 .any(|e| matches!(e, StreamEvent::ContentBlockDelta {
3945 delta: Delta::ThinkingDelta { thinking },
3946 ..
3947 } if thinking == "plan...")),
3948 "should yield a ThinkingDelta carrying 'plan...'; got {events:?}"
3949 );
3950 }
3951
3952 #[test]
3953 fn decoder_streams_moonshot_multi_chunk_reasoning_as_thinking() {
3954 // #3016: recorded shape from Moonshot's native endpoint — kimi-k2.6
3955 // streams `reasoning_content` deltas before the answer text. The
3956 // thinking deltas must accumulate into ONE thinking block and the
3957 // answer must arrive as text, not be glued into the trace.
3958 let chunks = [
3959 r#"{"id":"cmpl-kimi","model":"kimi-k2.6","choices":[{"index":0,"delta":{"role":"assistant","reasoning_content":"Let me check"}}]}"#,
3960 r#"{"id":"cmpl-kimi","model":"kimi-k2.6","choices":[{"index":0,"delta":{"reasoning_content":" the config."}}]}"#,
3961 r#"{"id":"cmpl-kimi","model":"kimi-k2.6","choices":[{"index":0,"delta":{"content":"The answer is 42."}}]}"#,
3962 ];
3963
3964 let is_reasoning =
3965 is_reasoning_model_for_stream(crate::config::ApiProvider::Moonshot, "kimi-k2.6");
3966 let mut content_index = 0u32;
3967 let mut text_started = false;
3968 let mut thinking_started = false;
3969 let mut tool_indices = std::collections::HashMap::new();
3970 let mut reasoning_detail_buffers = std::collections::HashMap::new();
3971 let mut events = Vec::new();
3972 for chunk in chunks {
3973 let value: Value = serde_json::from_str(chunk).expect("valid SSE JSON");
3974 events.extend(parse_sse_chunk(
3975 &value,
3976 &mut content_index,
3977 &mut text_started,
3978 &mut thinking_started,
3979 &mut tool_indices,
3980 &mut reasoning_detail_buffers,
3981 is_reasoning,
3982 ));
3983 }
3984
3985 let thinking: String = events
3986 .iter()
3987 .filter_map(|event| match event {
3988 StreamEvent::ContentBlockDelta {
3989 delta: Delta::ThinkingDelta { thinking },
3990 ..
3991 } => Some(thinking.as_str()),
3992 _ => None,
3993 })
3994 .collect();
3995 assert_eq!(thinking, "Let me check the config.");
3996
3997 let thinking_starts = events
3998 .iter()
3999 .filter(|event| {
4000 matches!(
4001 event,
4002 StreamEvent::ContentBlockStart {
4003 content_block: ContentBlockStart::Thinking { .. },
4004 ..
4005 }
4006 )
4007 })
4008 .count();
4009 assert_eq!(thinking_starts, 1, "one thinking block: {events:?}");
4010
4011 let text: String = events
4012 .iter()
4013 .filter_map(|event| match event {
4014 StreamEvent::ContentBlockDelta {
4015 delta: Delta::TextDelta { text },
4016 ..
4017 } => Some(text.as_str()),
4018 _ => None,
4019 })
4020 .collect();
4021 assert_eq!(text, "The answer is 42.");
4022 }
4023
4024 #[test]
4025 fn decoder_accepts_openrouter_reasoning_delta_with_extra_fields() {
4026 let events = decode_chunk(
4027 r#"{"id":"or-1","choices":[{"delta":{"reasoning":"openrouter thought","reasoning_details":[{"type":"summary","text":"extra"}],"native_finish_reason":null}}],"usage":{"completion_tokens_details":{"reasoning_tokens":3}}}"#,
4028 );
4029
4030 assert!(
4031 events.iter().any(|e| matches!(
4032 e,
4033 StreamEvent::ContentBlockDelta {
4034 delta: Delta::ThinkingDelta { thinking },
4035 ..
4036 } if thinking == "openrouter thought"
4037 )),
4038 "OpenRouter-style reasoning deltas with extra fields should not crash decoding; got {events:?}"
4039 );
4040 }
4041
4042 #[test]
4043 fn decoder_streams_minimax_reasoning_details_as_incremental_thinking() {
4044 // MiniMax's reasoning_split stream reports reasoning_details text as
4045 // a cumulative buffer. Emit only the suffix so the Thinking cell does
4046 // not duplicate earlier reasoning chunks.
4047 let chunks = [
4048 r#"{"id":"minimax-1","choices":[{"index":0,"delta":{"reasoning_details":[{"type":"text","text":"Inspect"}]}}]}"#,
4049 r#"{"id":"minimax-1","choices":[{"index":0,"delta":{"reasoning_details":[{"type":"text","text":"Inspect config"}]}}]}"#,
4050 r#"{"id":"minimax-1","choices":[{"index":0,"delta":{"content":"Done."}}]}"#,
4051 ];
4052
4053 let is_reasoning = is_reasoning_model_for_stream(ApiProvider::Minimax, "MiniMax-M3");
4054 let mut content_index = 0u32;
4055 let mut text_started = false;
4056 let mut thinking_started = false;
4057 let mut tool_indices = std::collections::HashMap::new();
4058 let mut reasoning_detail_buffers = std::collections::HashMap::new();
4059 let mut events = Vec::new();
4060 for chunk in chunks {
4061 let value: Value = serde_json::from_str(chunk).expect("valid SSE JSON");
4062 events.extend(parse_sse_chunk(
4063 &value,
4064 &mut content_index,
4065 &mut text_started,
4066 &mut thinking_started,
4067 &mut tool_indices,
4068 &mut reasoning_detail_buffers,
4069 is_reasoning,
4070 ));
4071 }
4072
4073 let thinking: String = events
4074 .iter()
4075 .filter_map(|event| match event {
4076 StreamEvent::ContentBlockDelta {
4077 delta: Delta::ThinkingDelta { thinking },
4078 ..
4079 } => Some(thinking.as_str()),
4080 _ => None,
4081 })
4082 .collect();
4083 assert_eq!(thinking, "Inspect config");
4084
4085 assert!(!events.iter().any(|event| matches!(
4086 event,
4087 StreamEvent::ContentBlockDelta {
4088 delta: Delta::TextDelta { text },
4089 ..
4090 } if text == "Inspect" || text == "Inspect config"
4091 )));
4092 }
4093
4094 #[test]
4095 fn modelstudio_streams_reasoning_content_as_thinking() {
4096 // Recorded-style DashScope OpenAI-compatible frames (shape lifted from
4097 // Model Studio's deep-thinking docs): reasoning streams in
4098 // `delta.reasoning_content`, the answer in `delta.content`, and a
4099 // trailing usage-only chunk closes the stream.
4100 let chunks = [
4101 r#"{"choices":[{"delta":{"content":null,"role":"assistant","reasoning_content":""},"index":0,"logprobs":null,"finish_reason":null}],"object":"chat.completion.chunk","usage":null,"model":"qwen3.8-max","id":"chatcmpl-ms-1"}"#,
4102 r#"{"choices":[{"delta":{"reasoning_content":"Let me think"},"index":0}],"object":"chat.completion.chunk","model":"qwen3.8-max","id":"chatcmpl-ms-1"}"#,
4103 r#"{"choices":[{"delta":{"reasoning_content":" about this."},"index":0}],"object":"chat.completion.chunk","model":"qwen3.8-max","id":"chatcmpl-ms-1"}"#,
4104 r#"{"choices":[{"delta":{"content":"The answer."},"index":0}],"object":"chat.completion.chunk","model":"qwen3.8-max","id":"chatcmpl-ms-1"}"#,
4105 r#"{"choices":[{"finish_reason":"stop","delta":{"content":"","reasoning_content":null},"index":0}],"object":"chat.completion.chunk","model":"qwen3.8-max","id":"chatcmpl-ms-1"}"#,
4106 r#"{"choices":[],"object":"chat.completion.chunk","usage":{"prompt_tokens":10,"completion_tokens":30,"total_tokens":40,"completion_tokens_details":{"reasoning_tokens":20}},"model":"qwen3.8-max","id":"chatcmpl-ms-1"}"#,
4107 ];
4108
4109 // Both OpenAI-dialect plans classify their reasoning catalog.
4110 for (provider, base_url, model) in [
4111 (
4112 ApiProvider::ModelstudioTokenPlan,
4113 crate::config::DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL,
4114 "qwen3.8-max",
4115 ),
4116 (
4117 ApiProvider::ModelstudioCodingPlan,
4118 crate::config::DEFAULT_MODELSTUDIO_CODING_PLAN_BASE_URL,
4119 "qwen3.7-plus",
4120 ),
4121 ] {
4122 let style = reasoning_stream_style_for_route(provider, base_url, model, None);
4123 assert_eq!(style, ReasoningStreamStyle::SeparateField, "{provider:?}");
4124
4125 let mut content_index = 0u32;
4126 let mut text_started = false;
4127 let mut thinking_started = false;
4128 let mut tool_indices = std::collections::HashMap::new();
4129 let mut reasoning_detail_buffers = std::collections::HashMap::new();
4130 let mut inline_reasoning_tags = InlineReasoningTagState::default();
4131 let mut events = Vec::new();
4132 for chunk in chunks {
4133 let value: Value = serde_json::from_str(chunk).expect("valid SSE JSON");
4134 events.extend(parse_sse_chunk_with_reasoning_style(
4135 &value,
4136 &mut content_index,
4137 &mut text_started,
4138 &mut thinking_started,
4139 &mut tool_indices,
4140 &mut reasoning_detail_buffers,
4141 &mut inline_reasoning_tags,
4142 style,
4143 ));
4144 }
4145
4146 let thinking: String = events
4147 .iter()
4148 .filter_map(|event| match event {
4149 StreamEvent::ContentBlockDelta {
4150 delta: Delta::ThinkingDelta { thinking },
4151 ..
4152 } => Some(thinking.as_str()),
4153 _ => None,
4154 })
4155 .collect();
4156 assert_eq!(thinking, "Let me think about this.", "{provider:?}");
4157
4158 let text: String = events
4159 .iter()
4160 .filter_map(|event| match event {
4161 StreamEvent::ContentBlockDelta {
4162 delta: Delta::TextDelta { text },
4163 ..
4164 } => Some(text.as_str()),
4165 _ => None,
4166 })
4167 .collect();
4168 assert_eq!(text, "The answer.", "{provider:?}");
4169
4170 // The trailing usage chunk still surfaces token accounting.
4171 assert!(
4172 events.iter().any(|event| matches!(
4173 event,
4174 StreamEvent::MessageDelta { usage: Some(usage), .. }
4175 if usage.output_tokens == 30
4176 )),
4177 "{provider:?}: {events:?}"
4178 );
4179 }
4180
4181 // A non-reasoning model id on the same route keeps the old
4182 // pass-through semantics (no fabricated Thinking surface).
4183 let style = reasoning_stream_style_for_route(
4184 ApiProvider::ModelstudioTokenPlan,
4185 crate::config::DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL,
4186 "qwen3.8-max-lite-unknown",
4187 None,
4188 );
4189 assert_eq!(style, ReasoningStreamStyle::None);
4190 }
4191
4192 #[test]
4193 fn decoder_does_not_render_reasoning_as_text_for_known_provider_models() {
4194 let mut content_index = 0u32;
4195 let mut text_started = false;
4196 let mut thinking_started = false;
4197 let mut tool_indices = std::collections::HashMap::new();
4198 let mut reasoning_detail_buffers = std::collections::HashMap::new();
4199 let is_reasoning_model =
4200 is_reasoning_model_for_stream(ApiProvider::XiaomiMimo, "mimo-v2.5-pro");
4201 let events = parse_sse_chunk(
4202 &serde_json::json!({
4203 "choices": [{
4204 "delta": {
4205 "reasoning_content": "private plan"
4206 }
4207 }]
4208 }),
4209 &mut content_index,
4210 &mut text_started,
4211 &mut thinking_started,
4212 &mut tool_indices,
4213 &mut reasoning_detail_buffers,
4214 is_reasoning_model,
4215 );
4216
4217 assert!(events.iter().any(|event| matches!(
4218 event,
4219 StreamEvent::ContentBlockDelta {
4220 delta: Delta::ThinkingDelta { thinking },
4221 ..
4222 } if thinking == "private plan"
4223 )));
4224 assert!(!events.iter().any(|event| matches!(
4225 event,
4226 StreamEvent::ContentBlockDelta {
4227 delta: Delta::TextDelta { text },
4228 ..
4229 } if text == "private plan"
4230 )));
4231 }
4232
4233 #[test]
4234 fn decoder_treats_reasoning_content_as_text_when_provider_does_not_support_reasoning() {
4235 let events = decode_chunk_with_reasoning(
4236 r#"{"choices":[{"delta":{"reasoning_content":"hello"}}]}"#,
4237 false,
4238 );
4239
4240 assert!(
4241 matches!(
4242 events.first(),
4243 Some(StreamEvent::ContentBlockStart {
4244 content_block: ContentBlockStart::Text { .. },
4245 ..
4246 })
4247 ),
4248 "first event should open a text block; got {events:?}"
4249 );
4250 assert!(
4251 events.iter().any(|e| matches!(
4252 e,
4253 StreamEvent::ContentBlockDelta {
4254 delta: Delta::TextDelta { text },
4255 ..
4256 } if text == "hello"
4257 )),
4258 "should yield a TextDelta carrying 'hello'; got {events:?}"
4259 );
4260 assert!(
4261 !events.iter().any(|e| matches!(
4262 e,
4263 StreamEvent::ContentBlockDelta {
4264 delta: Delta::ThinkingDelta { .. },
4265 ..
4266 }
4267 )),
4268 "should not emit thinking deltas for generic providers; got {events:?}"
4269 );
4270 }
4271
4272 #[test]
4273 fn reasoning_style_separate_field_routes_reasoning_to_thinking() {
4274 let events = decode_chunks_with_style(
4275 &[
4276 r#"{"choices":[{"delta":{"reasoning_content":"private plan"}}]}"#,
4277 r#"{"choices":[{"delta":{"content":"Public answer."}}]}"#,
4278 ],
4279 ReasoningStreamStyle::SeparateField,
4280 );
4281
4282 assert_eq!(thinking_delta_text(&events), "private plan");
4283 assert_eq!(text_delta_text(&events), "Public answer.");
4284 }
4285
4286 #[test]
4287 fn exact_kimi_code_k3_streams_reasoning_content_as_thinking() {
4288 let style = reasoning_stream_style_for_route(
4289 ApiProvider::Moonshot,
4290 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
4291 crate::config::KIMI_CODE_K3_MODEL,
4292 None,
4293 );
4294 assert_eq!(style, ReasoningStreamStyle::SeparateField);
4295
4296 let events = decode_chunks_with_style(
4297 &[r#"{"choices":[{"delta":{"reasoning_content":"private K3 plan"}}]}"#],
4298 style,
4299 );
4300 assert_eq!(thinking_delta_text(&events), "private K3 plan");
4301 assert_eq!(text_delta_text(&events), "");
4302
4303 let generic_style = reasoning_stream_style_for_route(
4304 ApiProvider::Moonshot,
4305 crate::config::DEFAULT_MOONSHOT_BASE_URL,
4306 crate::config::KIMI_CODE_K3_MODEL,
4307 None,
4308 );
4309 assert_eq!(generic_style, ReasoningStreamStyle::None);
4310 }
4311
4312 #[test]
4313 fn reasoning_style_inline_tags_routes_think_blocks_to_thinking() {
4314 let events = decode_chunks_with_style(
4315 &[
4316 r#"{"choices":[{"delta":{"content":"Before <thi"}}]}"#,
4317 r#"{"choices":[{"delta":{"content":"nk>private plan</thi"}}]}"#,
4318 r#"{"choices":[{"delta":{"content":"nk> after."}}]}"#,
4319 ],
4320 ReasoningStreamStyle::InlineTags,
4321 );
4322
4323 assert_eq!(thinking_delta_text(&events), "private plan");
4324 assert_eq!(text_delta_text(&events), "Before after.");
4325 assert!(
4326 !text_delta_text(&events).contains("<think>"),
4327 "inline reasoning tags must not leak into visible text: {events:?}"
4328 );
4329 }
4330
4331 #[test]
4332 fn reasoning_style_inline_tags_flushes_unclosed_think_at_stream_end() {
4333 let events = decode_chunks_with_style(
4334 &[
4335 r#"{"choices":[{"delta":{"content":"Before <think>partial reasoning"}}]}"#,
4336 r#"{"choices":[{"finish_reason":"stop"}]}"#,
4337 ],
4338 ReasoningStreamStyle::InlineTags,
4339 );
4340
4341 assert_eq!(thinking_delta_text(&events), "partial reasoning");
4342 assert_eq!(text_delta_text(&events), "Before ");
4343 }
4344
4345 #[test]
4346 fn reasoning_style_inline_tags_ignores_separate_reasoning_field() {
4347 let events = decode_chunks_with_style(
4348 &[
4349 r#"{"choices":[{"delta":{"reasoning_content":"metadata","content":"<think>tagged</think> answer"}}]}"#,
4350 ],
4351 ReasoningStreamStyle::InlineTags,
4352 );
4353
4354 assert_eq!(thinking_delta_text(&events), "tagged");
4355 assert_eq!(text_delta_text(&events), " answer");
4356 }
4357
4358 #[test]
4359 fn reasoning_style_none_keeps_inline_tags_visible_text() {
4360 let events = decode_chunks_with_style(
4361 &[r#"{"choices":[{"delta":{"content":"<think>visible</think> answer"}}]}"#],
4362 ReasoningStreamStyle::None,
4363 );
4364
4365 assert_eq!(thinking_delta_text(&events), "");
4366 assert_eq!(text_delta_text(&events), "<think>visible</think> answer");
4367 }
4368
4369 #[test]
4370 fn configured_reasoning_style_overrides_route_default() {
4371 assert_eq!(
4372 reasoning_stream_style_for_stream(ApiProvider::Openai, "custom-minimax", None),
4373 ReasoningStreamStyle::None
4374 );
4375 assert_eq!(
4376 reasoning_stream_style_for_stream(
4377 ApiProvider::Openai,
4378 "custom-minimax",
4379 Some("inline-tags")
4380 ),
4381 ReasoningStreamStyle::InlineTags
4382 );
4383 assert_eq!(
4384 reasoning_stream_style_for_stream(ApiProvider::XiaomiMimo, "mimo-v2.5-pro", None),
4385 ReasoningStreamStyle::SeparateField
4386 );
4387 assert_eq!(
4388 reasoning_stream_style_for_stream(
4389 ApiProvider::XiaomiMimo,
4390 "mimo-v2.5-pro",
4391 Some("none")
4392 ),
4393 ReasoningStreamStyle::None
4394 );
4395 }
4396
4397 #[test]
4398 fn decoder_yields_no_events_for_keepalive_chunk() {
4399 // DeepSeek often sends `{"choices":[]}` keepalive chunks before
4400 // emitting real content. The engine MUST treat a stream error after
4401 // these as "no content received" and be eligible for transparent
4402 // retry — assert here that the decoder yields no payload events.
4403 let events = decode_chunk(r#"{"choices":[]}"#);
4404 assert!(
4405 events.is_empty(),
4406 "empty-choices chunk must produce no events; got {events:?}"
4407 );
4408 }
4409
4410 #[test]
4411 fn decoder_treats_done_frame_as_terminal() {
4412 let mut content_index = 0u32;
4413 let mut text_started = false;
4414 let mut thinking_started = false;
4415 let mut tool_indices = std::collections::HashMap::new();
4416 let mut reasoning_detail_buffers = std::collections::HashMap::new();
4417 let mut inline_reasoning_tags = InlineReasoningTagState::default();
4418
4419 let outcome = parse_sse_data_frame(
4420 " [DONE] ",
4421 &mut content_index,
4422 &mut text_started,
4423 &mut thinking_started,
4424 &mut tool_indices,
4425 &mut reasoning_detail_buffers,
4426 &mut inline_reasoning_tags,
4427 ReasoningStreamStyle::SeparateField,
4428 );
4429
4430 assert!(
4431 matches!(outcome, SseDataFrame::Done),
4432 "`data: [DONE]` must terminate the stream instead of waiting for the HTTP connection to close"
4433 );
4434 assert_eq!(content_index, 0);
4435 assert!(!text_started);
4436 assert!(!thinking_started);
4437 assert!(tool_indices.is_empty());
4438 }
4439
4440 #[test]
4441 fn decoder_emits_tool_use_block_for_tool_call_delta() {
4442 // Tool-call deltas are content too — once one arrives, transparent
4443 // retry must be off (the model has committed to a tool invocation
4444 // path that DeepSeek has billed for).
4445 let events = decode_chunk(
4446 r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"grep_files","arguments":"{\"pattern\":\"foo\"}"}}]}}]}"#,
4447 );
4448 assert!(
4449 events.iter().any(|e| matches!(
4450 e,
4451 StreamEvent::ContentBlockStart {
4452 content_block: ContentBlockStart::ToolUse { name, .. },
4453 ..
4454 } if name == "grep_files"
4455 )),
4456 "should open a ToolUse block for grep_files; got {events:?}"
4457 );
4458 assert!(
4459 events.iter().any(|e| matches!(
4460 e,
4461 StreamEvent::ContentBlockDelta {
4462 delta: Delta::InputJsonDelta { partial_json },
4463 ..
4464 } if partial_json.contains("\"pattern\"")
4465 )),
4466 "should yield InputJsonDelta carrying the tool args; got {events:?}"
4467 );
4468 }
4469
4470 #[test]
4471 fn decoder_uses_fallback_name_for_empty_streaming_tool_name() {
4472 let events = decode_chunk(
4473 r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_empty","function":{"name":"","arguments":"{}"}}]}}]}"#,
4474 );
4475
4476 assert!(
4477 events.iter().any(|event| matches!(
4478 event,
4479 StreamEvent::ContentBlockStart {
4480 content_block: ContentBlockStart::ToolUse { name, .. },
4481 ..
4482 } if name == "unknown_tool"
4483 )),
4484 "empty upstream tool names should render as unknown_tool; got {events:?}"
4485 );
4486 }
4487
4488 #[test]
4489 fn non_streaming_response_uses_fallback_name_for_missing_tool_name() {
4490 let payload: Value = serde_json::from_str(
4491 r#"{
4492 "id": "chatcmpl_1",
4493 "model": "deepseek-v4-pro",
4494 "choices": [{
4495 "message": {
4496 "role": "assistant",
4497 "tool_calls": [{
4498 "id": "call_missing",
4499 "function": { "arguments": "{}" }
4500 }]
4501 },
4502 "finish_reason": "tool_calls"
4503 }]
4504 }"#,
4505 )
4506 .expect("valid response");
4507
4508 let parsed = parse_chat_message(&payload).expect("message parses");
4509 let tool_name = parsed.content.iter().find_map(|block| match block {
4510 ContentBlock::ToolUse { name, .. } => Some(name.as_str()),
4511 _ => None,
4512 });
4513
4514 assert_eq!(tool_name, Some("unknown_tool"));
4515 }
4516
4517 /// Regression for the parallel-tool-calls-without-id collision (audit
4518 /// Finding 8): when the upstream chunk omits the `id` field, the
4519 /// fallback used to be the literal string `"tool_call"` for every
4520 /// parallel call, so two tool calls in one delta ended up sharing an
4521 /// id. Downstream routing then matched the first call's tool_result
4522 /// twice and the second call hung. The fallback is now indexed by the
4523 /// content-block position, keeping each call unique within the
4524 /// response.
4525 #[test]
4526 fn decoder_assigns_unique_fallback_ids_to_parallel_tool_calls_missing_id() {
4527 let events = decode_chunk(
4528 r#"{"choices":[{"delta":{"tool_calls":[
4529 {"index":0,"function":{"name":"grep_files","arguments":"{\"pattern\":\"a\"}"}},
4530 {"index":1,"function":{"name":"read_file","arguments":"{\"path\":\"x\"}"}}
4531 ]}}]}"#,
4532 );
4533
4534 let ids: Vec<&str> = events
4535 .iter()
4536 .filter_map(|e| match e {
4537 StreamEvent::ContentBlockStart {
4538 content_block: ContentBlockStart::ToolUse { id, .. },
4539 ..
4540 } => Some(id.as_str()),
4541 _ => None,
4542 })
4543 .collect();
4544
4545 assert_eq!(
4546 ids.len(),
4547 2,
4548 "expected two tool-use blocks for parallel tool calls; got {events:?}"
4549 );
4550 assert_ne!(
4551 ids[0], ids[1],
4552 "parallel tool calls without upstream `id` must get distinct fallback ids; got {ids:?}"
4553 );
4554 }
4555
4556 #[test]
4557 fn decoder_preserves_upstream_tool_call_id_when_present() {
4558 // Counter-test to the fallback regression: when the upstream chunk
4559 // does include `id`, we forward it verbatim — we shouldn't quietly
4560 // rewrite ids the API gave us just because we have a fallback path.
4561 let events = decode_chunk(
4562 r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_xyz","function":{"name":"grep_files","arguments":"{}"}}]}}]}"#,
4563 );
4564 let id = events
4565 .iter()
4566 .find_map(|e| match e {
4567 StreamEvent::ContentBlockStart {
4568 content_block: ContentBlockStart::ToolUse { id, .. },
4569 ..
4570 } => Some(id.as_str()),
4571 _ => None,
4572 })
4573 .expect("tool-use block present");
4574 assert_eq!(id, "call_xyz");
4575 }
4576
4577 #[test]
4578 fn request_builder_preserves_internal_system_messages() {
4579 let messages = vec![Message {
4580 role: "system".to_string(),
4581 content: vec![ContentBlock::Text {
4582 text: "internal runtime event".to_string(),
4583 cache_control: None,
4584 }],
4585 }];
4586
4587 let built = build_chat_messages(None, &messages, "deepseek-v4-flash");
4588
4589 assert_eq!(built.len(), 1);
4590 assert_eq!(built[0]["role"], "system");
4591 assert_eq!(built[0]["content"], "internal runtime event");
4592 }
4593
4594 fn tool_use_message(id: &str, name: &str, input: Value) -> Message {
4595 Message {
4596 role: "assistant".to_string(),
4597 content: vec![ContentBlock::ToolUse {
4598 id: id.to_string(),
4599 name: name.to_string(),
4600 input,
4601 caller: None,
4602 }],
4603 }
4604 }
4605
4606 fn tool_result_message(id: &str, content: &str) -> Message {
4607 Message {
4608 role: "user".to_string(),
4609 content: vec![ContentBlock::ToolResult {
4610 tool_use_id: id.to_string(),
4611 content: content.to_string(),
4612 is_error: None,
4613 content_blocks: None,
4614 }],
4615 }
4616 }
4617
4618 fn user_message_with_turn_meta(turn_meta: &str, task: &str) -> Message {
4619 Message {
4620 role: "user".to_string(),
4621 content: vec![
4622 ContentBlock::Text {
4623 text: turn_meta.to_string(),
4624 cache_control: None,
4625 },
4626 ContentBlock::Text {
4627 text: task.to_string(),
4628 cache_control: None,
4629 },
4630 ],
4631 }
4632 }
4633
4634 fn user_message_with_tail_turn_meta(task: &str, turn_meta: &str) -> Message {
4635 Message {
4636 role: "user".to_string(),
4637 content: vec![
4638 ContentBlock::Text {
4639 text: task.to_string(),
4640 cache_control: None,
4641 },
4642 ContentBlock::Text {
4643 text: turn_meta.to_string(),
4644 cache_control: None,
4645 },
4646 ],
4647 }
4648 }
4649
4650 fn tool_message_content(messages: &[Value], index: usize) -> &str {
4651 messages
4652 .iter()
4653 .filter(|message| message.get("role").and_then(Value::as_str) == Some("tool"))
4654 .nth(index)
4655 .and_then(|message| message.get("content").and_then(Value::as_str))
4656 .expect("tool message content")
4657 }
4658
4659 fn user_message_content(messages: &[Value], index: usize) -> &str {
4660 messages
4661 .iter()
4662 .filter(|message| message.get("role").and_then(Value::as_str) == Some("user"))
4663 .nth(index)
4664 .and_then(|message| message.get("content").and_then(Value::as_str))
4665 .expect("user message content")
4666 }
4667
4668 fn with_tool_result_sha_spillover_root<T>(f: impl FnOnce() -> T) -> T {
4669 let _guard = crate::tools::truncate::TEST_SPILLOVER_GUARD
4670 .lock()
4671 .unwrap_or_else(|err| err.into_inner());
4672 let tmp = tempfile::tempdir().expect("tempdir");
4673 let prior = crate::tools::truncate::set_test_spillover_root(Some(
4674 tmp.path().join(".deepseek").join("tool_outputs"),
4675 ));
4676 struct Restore(Option<std::path::PathBuf>);
4677 impl Drop for Restore {
4678 fn drop(&mut self) {
4679 crate::tools::truncate::set_test_spillover_root(self.0.take());
4680 }
4681 }
4682 let _restore = Restore(prior);
4683 f()
4684 }
4685
4686 #[test]
4687 fn request_builder_deduplicates_consecutive_identical_turn_meta_for_wire() {
4688 let turn_meta = "<turn_meta>\nCurrent local date: 2026-05-09\n</turn_meta>";
4689 let messages = vec![
4690 user_message_with_turn_meta(turn_meta, "first task"),
4691 Message {
4692 role: "assistant".to_string(),
4693 content: vec![ContentBlock::Text {
4694 text: "first answer".to_string(),
4695 cache_control: None,
4696 }],
4697 },
4698 user_message_with_turn_meta(turn_meta, "second task"),
4699 ];
4700
4701 let built = build_chat_messages(None, &messages, "deepseek-v4-flash");
4702 let first = user_message_content(&built, 0);
4703 let second = user_message_content(&built, 1);
4704 let expected_ref = "<turn_meta_unchanged />";
4705
4706 assert!(first.starts_with(turn_meta), "got: {first}");
4707 assert!(second.starts_with(expected_ref), "got: {second}");
4708 assert!(second.ends_with("second task"), "got: {second}");
4709 assert_eq!(
4710 second,
4711 format!("{expected_ref}\nsecond task"),
4712 "ref text must stay stable"
4713 );
4714 }
4715
4716 #[test]
4717 fn request_builder_keeps_tail_turn_meta_after_user_text_for_wire() {
4718 let turn_meta = "<turn_meta>\nCurrent local date: 2026-05-09\n</turn_meta>";
4719 let messages = vec![
4720 user_message_with_tail_turn_meta("first task", turn_meta),
4721 Message {
4722 role: "assistant".to_string(),
4723 content: vec![ContentBlock::Text {
4724 text: "first answer".to_string(),
4725 cache_control: None,
4726 }],
4727 },
4728 user_message_with_tail_turn_meta("second task", turn_meta),
4729 ];
4730
4731 let built = build_chat_messages(None, &messages, "deepseek-v4-flash");
4732 let first = user_message_content(&built, 0);
4733 let second = user_message_content(&built, 1);
4734 let expected_ref = "<turn_meta_unchanged />";
4735
4736 assert_eq!(first, format!("first task\n{turn_meta}"));
4737 assert_eq!(second, format!("second task\n{expected_ref}"));
4738 }
4739
4740 #[test]
4741 fn request_builder_keeps_changed_turn_meta_full_and_updates_recent_hash() {
4742 let first_meta = "<turn_meta>\nCurrent local date: 2026-05-09\n</turn_meta>";
4743 let second_meta =
4744 "<turn_meta>\nCurrent local date: 2026-05-09\nWorking set: src/lib.rs\n</turn_meta>";
4745 let messages = vec![
4746 user_message_with_turn_meta(first_meta, "first task"),
4747 user_message_with_turn_meta(second_meta, "second task"),
4748 ];
4749
4750 let built = build_chat_messages(None, &messages, "deepseek-v4-flash");
4751 let first = user_message_content(&built, 0);
4752 let second = user_message_content(&built, 1);
4753
4754 assert!(first.starts_with(first_meta), "got: {first}");
4755 assert!(second.starts_with(second_meta), "got: {second}");
4756 assert!(!second.contains("<TURN_META_REF"), "got: {second}");
4757 }
4758
4759 #[test]
4760 fn turn_meta_dedup_is_wire_only_and_does_not_mutate_session_message() {
4761 let turn_meta = "<turn_meta>\nCurrent local date: 2026-05-09\n</turn_meta>";
4762 let messages = vec![
4763 user_message_with_turn_meta(turn_meta, "first task"),
4764 user_message_with_turn_meta(turn_meta, "second task"),
4765 ];
4766
4767 let built = build_chat_messages(None, &messages, "deepseek-v4-flash");
4768 assert!(
4769 user_message_content(&built, 1).starts_with("<turn_meta_unchanged />"),
4770 "got: {}",
4771 user_message_content(&built, 1)
4772 );
4773
4774 match &messages[1].content[0] {
4775 ContentBlock::Text { text, .. } => assert_eq!(text, turn_meta),
4776 other => panic!("expected text block, got {other:?}"),
4777 }
4778 }
4779
4780 #[test]
4781 fn cache_inspect_reports_turn_meta_dedup_metadata() {
4782 let turn_meta = format!(
4783 "<turn_meta>\nCurrent local date: 2026-05-09\n{}\n</turn_meta>",
4784 "Working set: src/lib.rs\n".repeat(20)
4785 );
4786 let request = MessageRequest {
4787 model: "deepseek-v4-flash".to_string(),
4788 messages: vec![
4789 user_message_with_turn_meta(&turn_meta, "first task"),
4790 user_message_with_turn_meta(&turn_meta, "second task"),
4791 ],
4792 max_tokens: 0,
4793 system: None,
4794 tools: None,
4795 tool_choice: None,
4796 metadata: None,
4797 thinking: None,
4798 reasoning_effort: None,
4799 stream: None,
4800 temperature: None,
4801 top_p: None,
4802 };
4803
4804 let inspection = inspect_prompt_for_request(&request);
4805 let turn_meta_layers: Vec<_> = inspection
4806 .layers
4807 .iter()
4808 .filter_map(|layer| layer.turn_meta.as_ref())
4809 .collect();
4810
4811 assert_eq!(turn_meta_layers.len(), 2);
4812 assert_eq!(
4813 turn_meta_layers[0].original_chars,
4814 turn_meta.chars().count()
4815 );
4816 assert_eq!(turn_meta_layers[0].sent_chars, turn_meta.chars().count());
4817 assert!(!turn_meta_layers[0].deduplicated);
4818 assert_eq!(turn_meta_layers[0].sha256, sha256_hex(turn_meta.as_bytes()));
4819 assert_eq!(
4820 turn_meta_layers[1].original_chars,
4821 turn_meta.chars().count()
4822 );
4823 assert!(turn_meta_layers[1].sent_chars < turn_meta_layers[1].original_chars);
4824 assert!(turn_meta_layers[1].deduplicated);
4825 assert_eq!(turn_meta_layers[1].sha256, turn_meta_layers[0].sha256);
4826 }
4827
4828 #[test]
4829 fn request_builder_truncates_large_tool_result_for_wire() {
4830 let long_output = format!("{}{}", "A".repeat(7_000), "Z".repeat(7_000));
4831 let messages = vec![
4832 tool_use_message(
4833 "tool-long",
4834 "shell_command",
4835 json!({"command": "cargo test"}),
4836 ),
4837 tool_result_message("tool-long", &long_output),
4838 ];
4839
4840 let built = build_chat_messages(None, &messages, "deepseek-v4-flash");
4841 let sent = tool_message_content(&built, 0);
4842
4843 assert!(sent.contains("[TOOL_RESULT_TRUNCATED]"), "got: {sent}");
4844 assert!(sent.contains("tool_name: shell_command"), "got: {sent}");
4845 assert!(sent.contains("command_or_query: cargo test"), "got: {sent}");
4846 assert!(sent.contains("original_chars: 14000"), "got: {sent}");
4847 assert!(sent.contains("sha256:"), "got: {sent}");
4848 assert!(
4849 sent.contains("exact_detail: unavailable; no session-owned artifact was recorded"),
4850 "got: {sent}"
4851 );
4852 assert!(!sent.contains("retrieve_tool_result"), "got: {sent}");
4853 assert!(sent.contains(&"A".repeat(4_000)), "got: {sent}");
4854 assert!(sent.contains(&"Z".repeat(4_000)), "got: {sent}");
4855 assert!(
4856 sent.contains("truncated 6000 chars from middle"),
4857 "got: {sent}"
4858 );
4859 assert_ne!(sent, long_output);
4860 }
4861
4862 #[test]
4863 fn request_builder_keeps_unowned_extreme_tool_output_bounded_without_false_hint() {
4864 with_tool_result_sha_spillover_root(|| {
4865 let huge_output = format!(
4866 "{}{}{}",
4867 "DIFF_HEAD\n".repeat(10_000),
4868 "MIDDLE_POISON\n".repeat(10_000),
4869 "DIFF_TAIL\n".repeat(10_000)
4870 );
4871 let sha = sha256_hex(huge_output.as_bytes());
4872 let messages = vec![
4873 tool_use_message("tool-huge", "exec_shell", json!({"command": "git diff"})),
4874 tool_result_message("tool-huge", &huge_output),
4875 ];
4876
4877 let built = build_chat_messages(None, &messages, "deepseek-v4-flash");
4878 let sent = tool_message_content(&built, 0);
4879
4880 assert!(sent.contains("[TOOL_RESULT_TRUNCATED]"), "got: {sent}");
4881 assert!(sent.contains("tool_name: exec_shell"), "got: {sent}");
4882 assert!(sent.contains("command_or_query: git diff"), "got: {sent}");
4883 assert!(sent.contains(&format!("sha256: {sha}")), "got: {sent}");
4884 assert!(sent.contains("exact_detail: unavailable"), "got: {sent}");
4885 assert!(!sent.contains("retrieve_tool_result"), "got: {sent}");
4886 assert!(
4887 sent.chars().count() <= TOOL_RESULT_SENT_CHAR_BUDGET,
4888 "truncated result should stay bounded, sent {} chars",
4889 sent.chars().count()
4890 );
4891 assert!(
4892 !sent.contains("MIDDLE_POISON"),
4893 "omitted middle should not be sent to the next model turn"
4894 );
4895 assert_ne!(sent, huge_output);
4896 });
4897 }
4898
4899 #[test]
4900 fn request_builder_does_not_dedup_short_tool_results_for_wire() {
4901 let output = "same tool output";
4902 let messages = vec![
4903 tool_use_message("tool-1", "read_file", json!({"path": "README.md"})),
4904 tool_result_message("tool-1", output),
4905 tool_use_message("tool-2", "read_file", json!({"path": "README.md"})),
4906 tool_result_message("tool-2", output),
4907 ];
4908
4909 let built = build_chat_messages(None, &messages, "deepseek-v4-flash");
4910 let first = tool_message_content(&built, 0);
4911 let second = tool_message_content(&built, 1);
4912
4913 assert_eq!(first, output);
4914 assert_eq!(second, output);
4915 assert!(!second.contains("<TOOL_RESULT_REF"), "got: {second}");
4916 }
4917
4918 #[test]
4919 fn request_builder_deduplicates_medium_identical_tool_results_to_earlier_message() {
4920 with_tool_result_sha_spillover_root(|| {
4921 // 2,000 chars is intentionally above TOOL_RESULT_DEDUP_MIN_CHARS
4922 // (1,024) but below TOOL_RESULT_SENT_CHAR_BUDGET (12,000). This
4923 // verifies the cache-saving path for repeated medium outputs that
4924 // do not otherwise need truncation.
4925 let output = "A".repeat(2_000);
4926 let messages = vec![
4927 tool_use_message("tool-1", "read_file", json!({"path": "README.md"})),
4928 tool_result_message("tool-1", &output),
4929 tool_use_message("tool-2", "read_file", json!({"path": "README.md"})),
4930 tool_result_message("tool-2", &output),
4931 ];
4932
4933 let built = build_chat_messages(None, &messages, "deepseek-v4-flash");
4934 let first = tool_message_content(&built, 0);
4935 let second = tool_message_content(&built, 1);
4936
4937 assert_eq!(first, output);
4938 assert!(!first.contains("[TOOL_RESULT_TRUNCATED]"), "got: {first}");
4939 assert!(
4940 second.starts_with("<TOOL_RESULT_REF sha=\""),
4941 "got: {second}"
4942 );
4943 assert!(
4944 second.contains("original_message=\"Message #1\""),
4945 "got: {second}"
4946 );
4947 assert!(second.contains("chars=\"2000\""), "got: {second}");
4948 assert!(
4949 second
4950 .contains("source: full content appears in Message #1 earlier in this request"),
4951 "got: {second}"
4952 );
4953 assert!(!second.contains("retrieve_tool_result"), "got: {second}");
4954 });
4955 }
4956
4957 #[test]
4958 fn request_builder_never_dedups_large_identical_write_file_confirmations() {
4959 with_tool_result_sha_spillover_root(|| {
4960 // A `write_file` result embeds the unified diff + summary; it is a
4961 // confirmation, not retrievable data. Two identical >1024-char
4962 // write_file results must BOTH stay inline — collapsing the second
4963 // to a SHA ref makes the model lose write-success context and
4964 // report the file as missing (#1695).
4965 let output = "A".repeat(2_000);
4966 let messages = vec![
4967 tool_use_message("tool-1", "write_file", json!({"path": "big.txt"})),
4968 tool_result_message("tool-1", &output),
4969 tool_use_message("tool-2", "write_file", json!({"path": "big.txt"})),
4970 tool_result_message("tool-2", &output),
4971 ];
4972
4973 let built = build_chat_messages(None, &messages, "deepseek-v4-flash");
4974 let first = tool_message_content(&built, 0);
4975 let second = tool_message_content(&built, 1);
4976
4977 assert_eq!(first, output);
4978 assert_eq!(second, output);
4979 assert!(!second.contains("<TOOL_RESULT_REF"), "got: {second}");
4980
4981 // Non-mutation tools still dedup: an identical medium read_file
4982 // result points back to the first full message in this request.
4983 let read_messages = vec![
4984 tool_use_message("read-1", "read_file", json!({"path": "README.md"})),
4985 tool_result_message("read-1", &output),
4986 tool_use_message("read-2", "read_file", json!({"path": "README.md"})),
4987 tool_result_message("read-2", &output),
4988 ];
4989 let read_built = build_chat_messages(None, &read_messages, "deepseek-v4-flash");
4990 let read_first = tool_message_content(&read_built, 0);
4991 let read_second = tool_message_content(&read_built, 1);
4992 assert_eq!(read_first, output);
4993 assert!(
4994 read_second.starts_with("<TOOL_RESULT_REF sha=\""),
4995 "got: {read_second}"
4996 );
4997 assert!(read_second.contains("source: full content appears in Message #1"));
4998 assert!(!read_second.contains("retrieve_tool_result"));
4999 });
5000 }
5001
5002 #[test]
5003 fn large_unowned_results_stay_bounded_without_false_retrieval_handles() {
5004 // The adaptive router normally replaces a large result with a
5005 // session-owned artifact receipt before this provider-wire fallback.
5006 // If legacy/raw history reaches here, it may be excerpted but must not
5007 // advertise the process-wide SHA store as retrievable.
5008 let big_diff = "D".repeat(20_000);
5009 let sha = sha256_hex(big_diff.as_bytes());
5010
5011 let messages = vec![
5012 tool_use_message("w-1", "write_file", json!({"path": "huge.rs"})),
5013 tool_result_message("w-1", &big_diff),
5014 tool_use_message("w-2", "write_file", json!({"path": "huge.rs"})),
5015 tool_result_message("w-2", &big_diff),
5016 ];
5017 let built = build_chat_messages(None, &messages, "deepseek-v4-flash");
5018 let first = tool_message_content(&built, 0);
5019 let second = tool_message_content(&built, 1);
5020
5021 // Mutation confirmations are independently excerpted, never deduped.
5022 assert!(
5023 first.contains("[TOOL_RESULT_TRUNCATED]"),
5024 "first should be truncated, got: {first}"
5025 );
5026 assert!(
5027 !first.contains("<TOOL_RESULT_REF"),
5028 "first must not be a dedup ref, got: {first}"
5029 );
5030 assert!(
5031 !second.contains("<TOOL_RESULT_REF"),
5032 "second identical write_file must stay inline (#1695), got: {second}"
5033 );
5034 assert!(
5035 second.contains("[TOOL_RESULT_TRUNCATED]"),
5036 "second should also be inline-truncated, got: {second}"
5037 );
5038 assert!(
5039 first.contains(&format!("sha256: {sha}")),
5040 "truncation block should retain an integrity digest, got: {first}"
5041 );
5042 assert!(first.contains("exact_detail: unavailable"));
5043 assert!(!first.contains("retrieve_tool_result"));
5044
5045 // A huge non-mutation result cannot refer to an earlier *full* message,
5046 // because both wire messages are excerpts. It therefore stays a
5047 // truthful bounded excerpt too.
5048 let read_messages = vec![
5049 tool_use_message("r-1", "read_file", json!({"path": "huge.rs"})),
5050 tool_result_message("r-1", &big_diff),
5051 tool_use_message("r-2", "read_file", json!({"path": "huge.rs"})),
5052 tool_result_message("r-2", &big_diff),
5053 ];
5054 let read_built = build_chat_messages(None, &read_messages, "deepseek-v4-flash");
5055 let read_second = tool_message_content(&read_built, 1);
5056 assert!(read_second.contains("[TOOL_RESULT_TRUNCATED]"));
5057 assert!(!read_second.contains("<TOOL_RESULT_REF"));
5058 assert!(!read_second.contains("retrieve_tool_result"));
5059 }
5060
5061 #[test]
5062 fn tool_result_budget_is_wire_only_and_does_not_mutate_session_message() {
5063 let long_output = format!("{}{}", "A".repeat(7_000), "Z".repeat(7_000));
5064 let messages = vec![
5065 tool_use_message(
5066 "tool-long",
5067 "shell_command",
5068 json!({"command": "cargo test"}),
5069 ),
5070 tool_result_message("tool-long", &long_output),
5071 ];
5072
5073 let built = build_chat_messages(None, &messages, "deepseek-v4-flash");
5074 let sent = tool_message_content(&built, 0);
5075 assert_ne!(sent, long_output);
5076
5077 match &messages[1].content[0] {
5078 ContentBlock::ToolResult { content, .. } => assert_eq!(content, &long_output),
5079 other => panic!("expected tool result, got {other:?}"),
5080 }
5081 }
5082
5083 #[test]
5084 fn cache_inspect_reports_bounded_unowned_tool_result_metadata() {
5085 let long_output = format!("{}{}", "A".repeat(7_000), "Z".repeat(7_000));
5086 let request = MessageRequest {
5087 model: "deepseek-v4-flash".to_string(),
5088 messages: vec![
5089 tool_use_message("tool-1", "shell_command", json!({"command": "cargo test"})),
5090 tool_result_message("tool-1", &long_output),
5091 tool_use_message("tool-2", "shell_command", json!({"command": "cargo test"})),
5092 tool_result_message("tool-2", &long_output),
5093 ],
5094 max_tokens: 0,
5095 system: None,
5096 tools: None,
5097 tool_choice: None,
5098 metadata: None,
5099 thinking: None,
5100 reasoning_effort: None,
5101 stream: None,
5102 temperature: None,
5103 top_p: None,
5104 };
5105
5106 let inspection = inspect_prompt_for_request(&request);
5107 let tool_layers: Vec<_> = inspection
5108 .layers
5109 .iter()
5110 .filter_map(|layer| layer.tool_result.as_ref())
5111 .collect();
5112
5113 assert_eq!(tool_layers.len(), 2);
5114 for layer in tool_layers {
5115 assert_eq!(layer.original_chars, 14_000);
5116 assert!(layer.sent_chars < layer.original_chars);
5117 assert!(layer.truncated);
5118 assert!(!layer.deduplicated);
5119 }
5120 }
5121 }
5122
5123 #[cfg(test)]
5124 mod alias_thinking_detection_tests {
5125 //! Regression coverage for the DeepSeek public model aliases.
5126 //!
5127 //! `deepseek-chat` and `deepseek-reasoner` are the canonical alias names
5128 //! published in DeepSeek's API docs. Server-side they resolve to V4-flash
5129 //! and V4-pro respectively, both of which have thinking mode enabled by
5130 //! default. If the TUI does not classify those aliases as reasoning
5131 //! models, the sanitizer skips replaying `reasoning_content` on tool-call
5132 //! assistant messages and DeepSeek returns a 400 ("the `reasoning_content`
5133 //! in the thinking mode must be passed back to the API") on the second
5134 //! turn. See upstream API docs:
5135 //! https://api-docs.deepseek.com/guides/thinking_mode
5136 use super::{
5137 ReasoningStreamStyle, apply_direct_moonshot_k3_fixed_sampling,
5138 apply_inkling_reasoning_effort, apply_kimi_code_k3_reasoning_effort,
5139 apply_openai_reasoning_effort, apply_provider_token_limit, apply_route_reasoning_controls,
5140 is_reasoning_model_for_stream, is_reasoning_model_for_stream_on_route,
5141 provider_accepts_reasoning_content, reasoning_stream_style_for_route,
5142 requires_reasoning_content, should_replay_reasoning_content,
5143 should_replay_reasoning_content_for_provider,
5144 should_replay_reasoning_content_for_provider_on_route,
5145 };
5146 use crate::config::ApiProvider;
5147 use serde_json::json;
5148
5149 #[test]
5150 fn aliases_routed_to_v4_require_reasoning_content() {
5151 // Documented public aliases.
5152 assert!(requires_reasoning_content("deepseek-chat"));
5153 assert!(requires_reasoning_content("deepseek-reasoner"));
5154 // Case-insensitive: users sometimes copy/paste with capitalisation.
5155 assert!(requires_reasoning_content("DeepSeek-Chat"));
5156 assert!(requires_reasoning_content("DEEPSEEK-REASONER"));
5157 }
5158
5159 #[test]
5160 fn explicit_v4_ids_still_require_reasoning_content() {
5161 // Direct V4 IDs continue to match (regression guard for the existing
5162 // `lower.contains("deepseek-v4")` branch).
5163 assert!(requires_reasoning_content("deepseek-v4-flash"));
5164 assert!(requires_reasoning_content("deepseek-v4-pro"));
5165 }
5166
5167 #[test]
5168 fn non_thinking_aliases_remain_excluded() {
5169 // Legacy non-thinking IDs and unrelated provider models must not be
5170 // misclassified, otherwise we would force a placeholder
5171 // `reasoning_content` on providers that reject the field.
5172 assert!(!requires_reasoning_content("deepseek-v3"));
5173 assert!(!requires_reasoning_content("deepseek-coder"));
5174 assert!(!requires_reasoning_content("qwen3-coder"));
5175 assert!(!requires_reasoning_content("claude-sonnet-4-6"));
5176 }
5177
5178 #[test]
5179 fn alias_prefix_handles_suffixed_variants() {
5180 // OpenRouter / proxy deployments occasionally suffix the canonical
5181 // alias (e.g. `deepseek-chat:free`). Those routes still hit V4
5182 // server-side, so they must continue to require reasoning_content.
5183 assert!(requires_reasoning_content("deepseek-chat:free"));
5184 assert!(requires_reasoning_content("deepseek-reasoner-2025-05"));
5185 }
5186
5187 #[test]
5188 fn explicit_reasoning_off_overrides_alias_detection() {
5189 // `reasoning_effort = "off"` is the documented escape hatch: even when
5190 // the model is in the thinking family, the user can opt out and the
5191 // sanitizer must respect that choice.
5192 assert!(!should_replay_reasoning_content(
5193 "deepseek-chat",
5194 Some("off")
5195 ));
5196 assert!(!should_replay_reasoning_content(
5197 "deepseek-reasoner",
5198 Some("disabled")
5199 ));
5200 // Without an explicit override, alias models still trigger replay.
5201 assert!(should_replay_reasoning_content("deepseek-chat", None));
5202 assert!(should_replay_reasoning_content(
5203 "deepseek-reasoner",
5204 Some("medium")
5205 ));
5206 }
5207
5208 #[test]
5209 fn generic_openai_provider_does_not_accept_reasoning_content_semantics() {
5210 assert!(!provider_accepts_reasoning_content(ApiProvider::Openai));
5211 assert!(provider_accepts_reasoning_content(ApiProvider::Deepseek));
5212 assert!(provider_accepts_reasoning_content(ApiProvider::NvidiaNim));
5213 assert!(provider_accepts_reasoning_content(ApiProvider::XiaomiMimo));
5214 assert!(provider_accepts_reasoning_content(ApiProvider::Arcee));
5215 assert!(provider_accepts_reasoning_content(ApiProvider::Minimax));
5216 assert!(provider_accepts_reasoning_content(ApiProvider::Zai));
5217 // #3016: Moonshot's native endpoint streams Kimi thinking as
5218 // reasoning_content.
5219 assert!(provider_accepts_reasoning_content(ApiProvider::Moonshot));
5220 }
5221
5222 /// Alibaba's classic pay-as-you-go DashScope endpoints are genuine
5223 /// Alibaba Chat Completions hosts serving the same models; before
5224 /// 2026-08-04 they were missing from the verifier allowlist, so every
5225 /// reasoning control was silently stripped there (fail-closed feature
5226 /// loss, not a leak). The intl spelling matches provider_defaults.
5227 #[test]
5228 fn classic_dashscope_hosts_are_verified_modelstudio_chat_routes() {
5229 for base_url in [
5230 "https://dashscope.aliyuncs.com/compatible-mode/v1",
5231 "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
5232 "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/",
5233 ] {
5234 assert!(
5235 super::is_exact_modelstudio_chat_route(ApiProvider::ModelstudioTokenPlan, base_url),
5236 "{base_url}"
5237 );
5238 let mut body = json!({});
5239 apply_route_reasoning_controls(
5240 &mut body,
5241 ApiProvider::ModelstudioTokenPlan,
5242 base_url,
5243 "qwen3.7-plus",
5244 Some("off"),
5245 );
5246 assert_eq!(body["enable_thinking"], json!(false), "{base_url}: {body}");
5247 }
5248 // Lookalike hosts stay unverified — fail closed.
5249 for base_url in [
5250 "https://dashscope.aliyuncs.com.evil.example/compatible-mode/v1",
5251 "https://notdashscope.aliyuncs.com/compatible-mode/v1",
5252 "https://dashscope.aliyuncs.com/other-path/v1",
5253 ] {
5254 assert!(
5255 !super::is_exact_modelstudio_chat_route(
5256 ApiProvider::ModelstudioTokenPlan,
5257 base_url
5258 ),
5259 "{base_url}"
5260 );
5261 }
5262 }
5263
5264 #[test]
5265 fn modelstudio_hybrid_routes_send_documented_thinking_controls() {
5266 let base_url = crate::config::DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL;
5267 for (effort, enabled) in [
5268 (None, true),
5269 (Some("low"), true),
5270 (Some("high"), true),
5271 (Some("xhigh"), true),
5272 (Some("off"), false),
5273 ] {
5274 let mut body = json!({});
5275 apply_route_reasoning_controls(
5276 &mut body,
5277 ApiProvider::ModelstudioTokenPlan,
5278 base_url,
5279 "qwen3.7-plus",
5280 effort,
5281 );
5282
5283 assert_eq!(body["enable_thinking"], json!(enabled), "{effort:?}");
5284 assert_eq!(body["preserve_thinking"], json!(enabled), "{effort:?}");
5285 assert!(body.get("thinking").is_none(), "{effort:?}: {body}");
5286 assert!(body.get("reasoning_effort").is_none(), "{effort:?}: {body}");
5287 }
5288 }
5289
5290 #[test]
5291 fn modelstudio_deepseek_v4_maps_effort_to_documented_values() {
5292 let base_url = crate::config::DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL;
5293 for (requested, expected) in [("low", "high"), ("high", "high"), ("xhigh", "max")] {
5294 let mut body = json!({});
5295 apply_route_reasoning_controls(
5296 &mut body,
5297 ApiProvider::ModelstudioTokenPlan,
5298 base_url,
5299 "deepseek-v4-pro",
5300 Some(requested),
5301 );
5302
5303 assert_eq!(body["enable_thinking"], json!(true), "{requested}");
5304 assert_eq!(body["reasoning_effort"], json!(expected), "{requested}");
5305 }
5306 }
5307
5308 #[test]
5309 fn modelstudio_reasoning_controls_fail_closed_on_custom_gateways() {
5310 let mut body = json!({
5311 "enable_thinking": true,
5312 "preserve_thinking": true,
5313 "reasoning_effort": "high",
5314 });
5315 apply_route_reasoning_controls(
5316 &mut body,
5317 ApiProvider::ModelstudioTokenPlan,
5318 "https://proxy.example/v1",
5319 "qwen3.7-plus",
5320 Some("high"),
5321 );
5322
5323 assert!(body.get("enable_thinking").is_none());
5324 assert!(body.get("preserve_thinking").is_none());
5325 assert!(body.get("reasoning_effort").is_none());
5326 }
5327
5328 #[test]
5329 fn modelstudio_anthropic_identities_write_nothing_on_the_chat_path() {
5330 // The Messages adapter owns these two. If `wire = "openai"` ever routes
5331 // them through Chat Completions, the shaper must strip rather than
5332 // inherit the OpenAI-dialect fields — there is no provider-enum writer
5333 // left to re-add them.
5334 for provider in [
5335 ApiProvider::ModelstudioTokenPlanAnthropic,
5336 ApiProvider::ModelstudioCodingPlanAnthropic,
5337 ] {
5338 for base_url in [
5339 crate::config::DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL,
5340 crate::config::MODELSTUDIO_TOKEN_PLAN_ANTHROPIC_BASE_URL,
5341 ] {
5342 let mut body = json!({ "enable_thinking": true });
5343 apply_route_reasoning_controls(
5344 &mut body,
5345 provider,
5346 base_url,
5347 "qwen3.7-plus",
5348 Some("high"),
5349 );
5350 assert_eq!(body, json!({}), "{provider:?} {base_url}");
5351 }
5352 }
5353 }
5354
5355 #[test]
5356 fn modelstudio_qwen38_route_classifies_reasoning_and_replays_history() {
5357 let base_url = crate::config::DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL;
5358 for model in ["qwen3.8-max", "qwen3.8-max-preview"] {
5359 // qwen3.8 is thinking-only. Effort selection must never hide its
5360 // separate reasoning stream, including the stale `off` state
5361 // that can arrive before route normalization.
5362 assert_eq!(
5363 reasoning_stream_style_for_route(
5364 ApiProvider::ModelstudioTokenPlan,
5365 base_url,
5366 model,
5367 None,
5368 ),
5369 ReasoningStreamStyle::SeparateField,
5370 "{model}"
5371 );
5372 for effort in [None, Some("off"), Some("high"), Some("xhigh")] {
5373 assert!(
5374 should_replay_reasoning_content_for_provider_on_route(
5375 ApiProvider::ModelstudioTokenPlan,
5376 base_url,
5377 model,
5378 effort,
5379 ),
5380 "{model} {effort:?}"
5381 );
5382 }
5383 // ...and no enable/disable switch is ever sent for them.
5384 let mut body = json!({});
5385 apply_route_reasoning_controls(
5386 &mut body,
5387 ApiProvider::ModelstudioTokenPlan,
5388 base_url,
5389 model,
5390 Some("off"),
5391 );
5392 assert!(body.get("enable_thinking").is_none(), "{model}: {body}");
5393 }
5394 }
5395
5396 #[test]
5397 fn modelstudio_hybrid_route_classifies_reasoning_and_replays_history() {
5398 let base_url = crate::config::DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL;
5399 assert_eq!(
5400 reasoning_stream_style_for_route(
5401 ApiProvider::ModelstudioTokenPlan,
5402 base_url,
5403 "qwen3.7-plus",
5404 None,
5405 ),
5406 ReasoningStreamStyle::SeparateField,
5407 );
5408 assert!(should_replay_reasoning_content_for_provider_on_route(
5409 ApiProvider::ModelstudioTokenPlan,
5410 base_url,
5411 "qwen3.7-plus",
5412 None,
5413 ));
5414 assert!(!should_replay_reasoning_content_for_provider_on_route(
5415 ApiProvider::ModelstudioTokenPlan,
5416 base_url,
5417 "qwen3.7-plus",
5418 Some("off"),
5419 ));
5420 }
5421
5422 #[test]
5423 fn modelstudio_replay_stays_narrow_until_a_live_key_confirms_it() {
5424 // Deliberately narrower than PR #5233: only `preserve_thinking` models
5425 // replay. GLM and DeepSeek-V3.x on Model Studio stay stripped until
5426 // someone with a key confirms DashScope accepts `reasoning_content` in
5427 // input messages. deepseek-v4* is unaffected — it replays through
5428 // `requires_reasoning_content` on every provider.
5429 let base_url = crate::config::DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL;
5430 for model in ["glm-5.2", "deepseek-v3.2", "deepseek-v3.1"] {
5431 assert!(
5432 !should_replay_reasoning_content_for_provider_on_route(
5433 ApiProvider::ModelstudioTokenPlan,
5434 base_url,
5435 model,
5436 None,
5437 ),
5438 "{model}"
5439 );
5440 }
5441 assert!(should_replay_reasoning_content_for_provider_on_route(
5442 ApiProvider::ModelstudioTokenPlan,
5443 base_url,
5444 "deepseek-v4-pro",
5445 None,
5446 ));
5447 }
5448
5449 #[test]
5450 fn modelstudio_coding_plan_chat_route_is_classified_for_all_supported_identities() {
5451 // The picker represents Coding Plan as mode = "coding-plan" under
5452 // the primary provider id, so the chat client receives
5453 // ModelstudioTokenPlan with the Coding Plan URL. Direct configuration
5454 // also retains the legacy ModelstudioCodingPlan identity.
5455 let base_url = crate::config::DEFAULT_MODELSTUDIO_CODING_PLAN_BASE_URL;
5456 for provider in [
5457 ApiProvider::ModelstudioTokenPlan,
5458 ApiProvider::ModelstudioCodingPlan,
5459 ] {
5460 let mut body = json!({});
5461 apply_route_reasoning_controls(
5462 &mut body,
5463 provider,
5464 base_url,
5465 "qwen3.7-plus",
5466 Some("high"),
5467 );
5468
5469 assert_eq!(body["enable_thinking"], json!(true), "{provider:?}");
5470 assert_eq!(body["preserve_thinking"], json!(true), "{provider:?}");
5471 assert_eq!(
5472 reasoning_stream_style_for_route(provider, base_url, "qwen3.7-plus", None),
5473 ReasoningStreamStyle::SeparateField,
5474 "{provider:?}",
5475 );
5476 assert!(should_replay_reasoning_content_for_provider_on_route(
5477 provider,
5478 base_url,
5479 "qwen3.7-plus",
5480 None,
5481 ));
5482 }
5483 }
5484
5485 #[test]
5486 fn modelstudio_workspace_scoped_token_plan_route_is_recognized() {
5487 let workspace_url =
5488 "https://workspace-123.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1";
5489 assert_eq!(
5490 reasoning_stream_style_for_route(
5491 ApiProvider::ModelstudioTokenPlan,
5492 workspace_url,
5493 "qwen3.8-max",
5494 None,
5495 ),
5496 ReasoningStreamStyle::SeparateField,
5497 );
5498 assert!(should_replay_reasoning_content_for_provider_on_route(
5499 ApiProvider::ModelstudioTokenPlan,
5500 workspace_url,
5501 "qwen3.8-max",
5502 None,
5503 ));
5504 }
5505
5506 #[test]
5507 fn modelstudio_kimi_k27_code_is_thinking_only_and_preserves_trace() {
5508 // NOTE: unlike the qwen3.8 pair, this classification is asserted by
5509 // PR #5233 rather than corroborated by models_dev.bundled.json, which
5510 // lists kimi-k2.7-code with `reasoning: true` and no `always_on`.
5511 let base_url = crate::config::DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL;
5512 let mut body = json!({});
5513 apply_route_reasoning_controls(
5514 &mut body,
5515 ApiProvider::ModelstudioTokenPlan,
5516 base_url,
5517 "kimi-k2.7-code",
5518 Some("off"),
5519 );
5520
5521 assert!(body.get("enable_thinking").is_none());
5522 assert_eq!(body["preserve_thinking"], json!(true));
5523 assert_eq!(
5524 reasoning_stream_style_for_route(
5525 ApiProvider::ModelstudioTokenPlan,
5526 base_url,
5527 "kimi-k2.7-code",
5528 None,
5529 ),
5530 ReasoningStreamStyle::SeparateField,
5531 );
5532 assert!(should_replay_reasoning_content_for_provider_on_route(
5533 ApiProvider::ModelstudioTokenPlan,
5534 base_url,
5535 "kimi-k2.7-code",
5536 Some("off"),
5537 ));
5538 }
5539
5540 #[test]
5541 fn stream_classifies_moonshot_kimi_as_reasoning() {
5542 // #3016: without this, Kimi thinking leaked into answer text.
5543 assert!(is_reasoning_model_for_stream(
5544 ApiProvider::Moonshot,
5545 "kimi-k2.6"
5546 ));
5547 assert!(
5548 is_reasoning_model_for_stream(ApiProvider::Moonshot, "kimi-for-coding"),
5549 "Kimi Code's stable model id now maps to K2.7 Code and streams reasoning_content"
5550 );
5551 }
5552
5553 #[test]
5554 fn moonshot_and_minimax_replay_reasoning_content_for_supported_models() {
5555 assert!(should_replay_reasoning_content_for_provider(
5556 ApiProvider::Moonshot,
5557 "kimi-k2.7-code",
5558 None,
5559 ));
5560 assert!(should_replay_reasoning_content_for_provider(
5561 ApiProvider::Moonshot,
5562 "kimi-for-coding",
5563 None,
5564 ));
5565 assert!(should_replay_reasoning_content_for_provider(
5566 ApiProvider::Minimax,
5567 "MiniMax-M3",
5568 None,
5569 ));
5570 assert!(should_replay_reasoning_content_for_provider(
5571 ApiProvider::Zai,
5572 "GLM-5.2",
5573 None,
5574 ));
5575 assert!(should_replay_reasoning_content_for_provider(
5576 ApiProvider::Zai,
5577 "GLM-5.3",
5578 None,
5579 ));
5580 assert!(!should_replay_reasoning_content_for_provider(
5581 ApiProvider::Moonshot,
5582 "kimi-for-coding",
5583 Some("off"),
5584 ));
5585 }
5586
5587 #[test]
5588 fn bare_k3_reasoning_semantics_are_scoped_to_exact_kimi_code_route() {
5589 let kimi_code = crate::config::DEFAULT_KIMI_CODE_BASE_URL;
5590 let direct_moonshot = crate::config::DEFAULT_MOONSHOT_BASE_URL;
5591
5592 assert!(should_replay_reasoning_content_for_provider_on_route(
5593 ApiProvider::Moonshot,
5594 kimi_code,
5595 crate::config::KIMI_CODE_K3_MODEL,
5596 Some("high"),
5597 ));
5598 assert!(is_reasoning_model_for_stream_on_route(
5599 ApiProvider::Moonshot,
5600 kimi_code,
5601 crate::config::KIMI_CODE_K3_MODEL,
5602 ));
5603 assert_eq!(
5604 reasoning_stream_style_for_route(
5605 ApiProvider::Moonshot,
5606 kimi_code,
5607 crate::config::KIMI_CODE_K3_MODEL,
5608 None,
5609 ),
5610 ReasoningStreamStyle::SeparateField
5611 );
5612
5613 assert!(!should_replay_reasoning_content_for_provider_on_route(
5614 ApiProvider::Moonshot,
5615 direct_moonshot,
5616 crate::config::KIMI_CODE_K3_MODEL,
5617 Some("high"),
5618 ));
5619 assert!(!is_reasoning_model_for_stream_on_route(
5620 ApiProvider::Moonshot,
5621 direct_moonshot,
5622 crate::config::KIMI_CODE_K3_MODEL,
5623 ));
5624 assert_eq!(
5625 reasoning_stream_style_for_route(
5626 ApiProvider::Moonshot,
5627 direct_moonshot,
5628 crate::config::KIMI_CODE_K3_MODEL,
5629 None,
5630 ),
5631 ReasoningStreamStyle::None
5632 );
5633 assert!(
5634 should_replay_reasoning_content_for_provider_on_route(
5635 ApiProvider::Moonshot,
5636 kimi_code,
5637 crate::config::KIMI_CODE_K3_MODEL,
5638 Some("off"),
5639 ),
5640 "exact membership K3 stays always-thinking even for a stale raw Off caller"
5641 );
5642 }
5643
5644 #[test]
5645 fn direct_moonshot_k3_is_always_thinking_and_replays_reasoning() {
5646 let direct = crate::config::DEFAULT_MOONSHOT_BASE_URL;
5647 let model = crate::config::MOONSHOT_KIMI_K3_MODEL;
5648
5649 for effort in [Some("off"), Some("low"), Some("high"), Some("max"), None] {
5650 assert!(should_replay_reasoning_content_for_provider_on_route(
5651 ApiProvider::Moonshot,
5652 direct,
5653 model,
5654 effort,
5655 ));
5656 }
5657 assert_eq!(
5658 reasoning_stream_style_for_route(ApiProvider::Moonshot, direct, model, None),
5659 ReasoningStreamStyle::SeparateField
5660 );
5661 }
5662
5663 #[test]
5664 fn xiaomi_mimo_uses_max_completion_tokens_payload_key() {
5665 let mut body = json!({
5666 "model": "mimo-v2.5-pro",
5667 "messages": [],
5668 "max_tokens": 8192,
5669 });
5670
5671 apply_provider_token_limit(
5672 &mut body,
5673 ApiProvider::XiaomiMimo,
5674 "https://api.xiaomimimo.com/v1",
5675 "mimo-v2.5-pro",
5676 8192,
5677 );
5678
5679 assert!(body.get("max_tokens").is_none());
5680 assert_eq!(
5681 body.get("max_completion_tokens")
5682 .and_then(serde_json::Value::as_u64),
5683 Some(8192)
5684 );
5685 }
5686
5687 #[test]
5688 fn openai_reasoning_model_uses_completion_token_limit_and_effort_field() {
5689 let mut body = json!({
5690 "model": "gpt-5.5",
5691 "messages": [],
5692 "max_tokens": 4096,
5693 });
5694
5695 apply_provider_token_limit(
5696 &mut body,
5697 ApiProvider::Openai,
5698 "https://api.openai.com/v1",
5699 "gpt-5.5",
5700 4096,
5701 );
5702 apply_openai_reasoning_effort(&mut body, ApiProvider::Openai, "gpt-5.5", Some("high"));
5703
5704 assert!(body.get("max_tokens").is_none());
5705 assert_eq!(
5706 body.get("max_completion_tokens")
5707 .and_then(serde_json::Value::as_u64),
5708 Some(4096)
5709 );
5710 assert_eq!(
5711 body.get("reasoning_effort")
5712 .and_then(serde_json::Value::as_str),
5713 Some("high")
5714 );
5715 }
5716
5717 #[test]
5718 fn gpt_56_uses_documented_max_reasoning_effort() {
5719 let mut body = json!({
5720 "model": "gpt-5.6-sol",
5721 "messages": [],
5722 "max_tokens": 8192,
5723 });
5724
5725 apply_provider_token_limit(
5726 &mut body,
5727 ApiProvider::Openai,
5728 "https://api.openai.com/v1",
5729 "gpt-5.6-sol",
5730 8192,
5731 );
5732 apply_openai_reasoning_effort(&mut body, ApiProvider::Openai, "gpt-5.6-sol", Some("max"));
5733
5734 assert!(body.get("max_tokens").is_none());
5735 assert_eq!(body["max_completion_tokens"], json!(8192));
5736 assert_eq!(body["reasoning_effort"], json!("max"));
5737 }
5738
5739 #[test]
5740 fn inkling_uses_its_exact_reasoning_vocabulary_without_thinking_extension() {
5741 for (requested, expected) in [
5742 ("off", "none"),
5743 ("minimal", "minimal"),
5744 ("low", "low"),
5745 ("medium", "medium"),
5746 ("high", "high"),
5747 ("max", "max"),
5748 ("xhigh", "max"),
5749 ] {
5750 let mut body = json!({
5751 "thinking": { "type": "enabled" },
5752 "reasoning_effort": "xhigh",
5753 });
5754
5755 apply_inkling_reasoning_effort(
5756 &mut body,
5757 ApiProvider::Together,
5758 "thinkingmachines/inkling",
5759 Some(requested),
5760 );
5761
5762 assert_eq!(body["reasoning_effort"], json!(expected));
5763 assert!(body.get("thinking").is_none());
5764 }
5765 }
5766
5767 #[test]
5768 fn inkling_reasoning_override_is_scoped_to_the_exact_together_route() {
5769 let mut other_model = json!({ "thinking": { "type": "enabled" } });
5770 apply_inkling_reasoning_effort(
5771 &mut other_model,
5772 ApiProvider::Together,
5773 "deepseek-ai/DeepSeek-V4-Pro",
5774 Some("max"),
5775 );
5776 assert_eq!(other_model["thinking"]["type"], json!("enabled"));
5777 assert!(other_model.get("reasoning_effort").is_none());
5778
5779 let mut other_provider = json!({ "thinking": { "type": "enabled" } });
5780 apply_inkling_reasoning_effort(
5781 &mut other_provider,
5782 ApiProvider::Openrouter,
5783 "thinkingmachines/inkling",
5784 Some("max"),
5785 );
5786 assert_eq!(other_provider["thinking"]["type"], json!("enabled"));
5787 assert!(other_provider.get("reasoning_effort").is_none());
5788 }
5789
5790 #[test]
5791 fn kimi_code_k3_uses_documented_nested_thinking_effort() {
5792 for (requested, expected) in [
5793 ("low", json!({ "type": "enabled", "effort": "low" })),
5794 ("minimum", json!({ "type": "enabled", "effort": "low" })),
5795 ("light", json!({ "type": "enabled", "effort": "low" })),
5796 ("medium", json!({ "type": "enabled", "effort": "high" })),
5797 ("high", json!({ "type": "enabled", "effort": "high" })),
5798 ("xhigh", json!({ "type": "enabled", "effort": "max" })),
5799 ("ultra", json!({ "type": "enabled", "effort": "max" })),
5800 ("max", json!({ "type": "enabled", "effort": "max" })),
5801 ("none", json!({ "type": "enabled", "effort": "low" })),
5802 ("off", json!({ "type": "enabled", "effort": "low" })),
5803 ] {
5804 let mut body = json!({ "reasoning_effort": "stale" });
5805 apply_kimi_code_k3_reasoning_effort(
5806 &mut body,
5807 ApiProvider::Moonshot,
5808 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
5809 crate::config::KIMI_CODE_K3_MODEL,
5810 Some(requested),
5811 );
5812
5813 assert_eq!(body["thinking"], expected, "requested {requested}");
5814 assert!(body.get("reasoning_effort").is_none());
5815 }
5816 }
5817
5818 #[test]
5819 fn direct_moonshot_k3_uses_top_level_effort_and_never_disables_thinking() {
5820 for (requested, expected) in [
5821 ("off", "low"),
5822 ("none", "low"),
5823 ("low", "low"),
5824 ("medium", "high"),
5825 ("high", "high"),
5826 ("xhigh", "max"),
5827 ("max", "max"),
5828 ] {
5829 let mut body = json!({
5830 "model": crate::config::MOONSHOT_KIMI_K3_MODEL,
5831 "thinking": { "type": "disabled" },
5832 });
5833 apply_route_reasoning_controls(
5834 &mut body,
5835 ApiProvider::Moonshot,
5836 crate::config::DEFAULT_MOONSHOT_BASE_URL,
5837 crate::config::MOONSHOT_KIMI_K3_MODEL,
5838 Some(requested),
5839 );
5840
5841 assert_eq!(body["reasoning_effort"], json!(expected), "{requested}");
5842 assert!(body.get("thinking").is_none(), "{requested}: {body}");
5843 }
5844
5845 let mut provider_default = json!({ "thinking": { "type": "enabled" } });
5846 apply_route_reasoning_controls(
5847 &mut provider_default,
5848 ApiProvider::Moonshot,
5849 crate::config::DEFAULT_MOONSHOT_BASE_URL,
5850 crate::config::MOONSHOT_KIMI_K3_MODEL,
5851 Some("auto"),
5852 );
5853 assert!(provider_default.get("thinking").is_none());
5854 assert!(provider_default.get("reasoning_effort").is_none());
5855 }
5856
5857 #[test]
5858 fn direct_moonshot_k3_uses_modern_token_field_and_fixed_sampling_only_on_exact_route() {
5859 let mut direct = json!({
5860 "max_tokens": 64,
5861 "temperature": 0.2,
5862 "top_p": 0.9,
5863 });
5864 apply_provider_token_limit(
5865 &mut direct,
5866 ApiProvider::Moonshot,
5867 crate::config::DEFAULT_MOONSHOT_BASE_URL,
5868 crate::config::MOONSHOT_KIMI_K3_MODEL,
5869 64,
5870 );
5871 apply_direct_moonshot_k3_fixed_sampling(
5872 &mut direct,
5873 ApiProvider::Moonshot,
5874 crate::config::DEFAULT_MOONSHOT_BASE_URL,
5875 crate::config::MOONSHOT_KIMI_K3_MODEL,
5876 );
5877 assert_eq!(direct["max_completion_tokens"], json!(64));
5878 assert!(direct.get("max_tokens").is_none());
5879 assert!(direct.get("temperature").is_none());
5880 assert!(direct.get("top_p").is_none());
5881
5882 let mut neighbor = json!({
5883 "max_tokens": 64,
5884 "temperature": 0.2,
5885 "top_p": 0.9,
5886 });
5887 apply_provider_token_limit(
5888 &mut neighbor,
5889 ApiProvider::Moonshot,
5890 "https://proxy.example/v1",
5891 crate::config::MOONSHOT_KIMI_K3_MODEL,
5892 64,
5893 );
5894 apply_direct_moonshot_k3_fixed_sampling(
5895 &mut neighbor,
5896 ApiProvider::Moonshot,
5897 "https://proxy.example/v1",
5898 crate::config::MOONSHOT_KIMI_K3_MODEL,
5899 );
5900 assert_eq!(neighbor["max_tokens"], json!(64));
5901 assert!(neighbor.get("max_completion_tokens").is_none());
5902 assert_eq!(neighbor["temperature"], json!(0.2));
5903 assert_eq!(neighbor["top_p"], json!(0.9));
5904 }
5905
5906 #[test]
5907 fn direct_and_membership_k3_reasoning_dialects_do_not_cross_routes() {
5908 let mut membership = json!({});
5909 apply_route_reasoning_controls(
5910 &mut membership,
5911 ApiProvider::Moonshot,
5912 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
5913 crate::config::KIMI_CODE_K3_MODEL,
5914 Some("max"),
5915 );
5916 assert_eq!(
5917 membership["thinking"],
5918 json!({ "type": "enabled", "effort": "max" })
5919 );
5920 assert!(membership.get("reasoning_effort").is_none());
5921
5922 for (base_url, model) in [
5923 (
5924 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
5925 crate::config::MOONSHOT_KIMI_K3_MODEL,
5926 ),
5927 (
5928 crate::config::DEFAULT_MOONSHOT_BASE_URL,
5929 crate::config::KIMI_CODE_K3_MODEL,
5930 ),
5931 (
5932 "https://proxy.example/v1",
5933 crate::config::MOONSHOT_KIMI_K3_MODEL,
5934 ),
5935 ] {
5936 let mut neighbor = json!({});
5937 apply_route_reasoning_controls(
5938 &mut neighbor,
5939 ApiProvider::Moonshot,
5940 base_url,
5941 model,
5942 Some("max"),
5943 );
5944 assert_eq!(
5945 neighbor["thinking"],
5946 json!({ "type": "enabled" }),
5947 "{base_url} / {model}"
5948 );
5949 assert!(neighbor.get("reasoning_effort").is_none());
5950 assert!(neighbor.pointer("/thinking/effort").is_none());
5951 }
5952 }
5953
5954 #[test]
5955 fn kimi_code_k3_effort_override_never_leaks_to_neighbor_routes() {
5956 for (base_url, model) in [
5957 (crate::config::DEFAULT_KIMI_CODE_BASE_URL, "kimi-k3"),
5958 (
5959 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
5960 crate::config::DEFAULT_KIMI_CODE_MODEL,
5961 ),
5962 (crate::config::DEFAULT_MOONSHOT_BASE_URL, "k3"),
5963 ] {
5964 let mut body = json!({ "thinking": { "type": "enabled" } });
5965 apply_kimi_code_k3_reasoning_effort(
5966 &mut body,
5967 ApiProvider::Moonshot,
5968 base_url,
5969 model,
5970 Some("max"),
5971 );
5972
5973 assert_eq!(body["thinking"], json!({ "type": "enabled" }));
5974 assert!(
5975 body.pointer("/thinking/effort").is_none(),
5976 "{base_url} / {model}"
5977 );
5978 assert!(body.get("reasoning_effort").is_none());
5979 }
5980 }
5981
5982 #[test]
5983 fn muse_spark_uses_meta_reasoning_effort_without_openai_token_rewrite() {
5984 let mut body = json!({
5985 "model": "muse-spark-1.1",
5986 "messages": [],
5987 "max_tokens": 8192,
5988 });
5989
5990 apply_provider_token_limit(
5991 &mut body,
5992 ApiProvider::Meta,
5993 "https://api.meta.ai/v1",
5994 "muse-spark-1.1",
5995 8192,
5996 );
5997 apply_openai_reasoning_effort(&mut body, ApiProvider::Meta, "muse-spark-1.1", Some("max"));
5998
5999 assert_eq!(body["max_tokens"], json!(8192));
6000 assert!(body.get("max_completion_tokens").is_none());
6001 assert_eq!(body["reasoning_effort"], json!("xhigh"));
6002 }
6003
6004 #[test]
6005 fn openai_non_reasoning_model_omits_reasoning_only_fields() {
6006 let mut body = json!({
6007 "model": "gpt-4o",
6008 "messages": [],
6009 "max_tokens": 4096,
6010 });
6011
6012 apply_provider_token_limit(
6013 &mut body,
6014 ApiProvider::Openai,
6015 "https://api.openai.com/v1",
6016 "gpt-4o",
6017 4096,
6018 );
6019 apply_openai_reasoning_effort(&mut body, ApiProvider::Openai, "gpt-4o", Some("high"));
6020
6021 assert_eq!(
6022 body.get("max_tokens").and_then(serde_json::Value::as_u64),
6023 Some(4096)
6024 );
6025 assert!(body.get("max_completion_tokens").is_none());
6026 assert!(body.get("reasoning_effort").is_none());
6027 }
6028
6029 #[test]
6030 fn openai_provider_deepseek_compatible_model_keeps_chat_token_field() {
6031 let mut body = json!({
6032 "model": "deepseek-v4-pro",
6033 "messages": [],
6034 "max_tokens": 4096,
6035 });
6036
6037 apply_provider_token_limit(
6038 &mut body,
6039 ApiProvider::Openai,
6040 "https://api.openai.com/v1",
6041 "deepseek-v4-pro",
6042 4096,
6043 );
6044 apply_openai_reasoning_effort(
6045 &mut body,
6046 ApiProvider::Openai,
6047 "deepseek-v4-pro",
6048 Some("high"),
6049 );
6050
6051 assert_eq!(
6052 body.get("max_tokens").and_then(serde_json::Value::as_u64),
6053 Some(4096)
6054 );
6055 assert!(body.get("max_completion_tokens").is_none());
6056 assert!(body.get("reasoning_effort").is_none());
6057 }
6058
6059 #[test]
6060 fn deepseek_model_on_openai_provider_still_replays_reasoning_content() {
6061 // #1739 / #1694: a DeepSeek thinking model pointed at a
6062 // DeepSeek-compatible endpoint via the generic `openai` provider must
6063 // still replay reasoning_content, even though the provider itself does
6064 // not accept the field. Otherwise the thinking-mode API returns 400.
6065 assert!(should_replay_reasoning_content_for_provider(
6066 ApiProvider::Openai,
6067 "deepseek-v4-flash",
6068 None,
6069 ));
6070 assert!(should_replay_reasoning_content_for_provider(
6071 ApiProvider::Openai,
6072 "deepseek-v4-pro",
6073 None,
6074 ));
6075 assert!(should_replay_reasoning_content_for_provider(
6076 ApiProvider::Openai,
6077 "deepseek-reasoner",
6078 Some("medium"),
6079 ));
6080 // The documented escape hatch still wins over model detection.
6081 assert!(!should_replay_reasoning_content_for_provider(
6082 ApiProvider::Openai,
6083 "deepseek-v4-flash",
6084 Some("off"),
6085 ));
6086 }
6087
6088 #[test]
6089 fn generic_model_on_openai_provider_still_strips_reasoning_content() {
6090 // #1542 no-regression guard: a genuine non-DeepSeek model on the
6091 // openai provider must continue to have reasoning_content stripped.
6092 assert!(!should_replay_reasoning_content_for_provider(
6093 ApiProvider::Openai,
6094 "qwen3-coder",
6095 None,
6096 ));
6097 assert!(!should_replay_reasoning_content_for_provider(
6098 ApiProvider::Openai,
6099 "claude-sonnet-4-6",
6100 None,
6101 ));
6102 }
6103
6104 #[test]
6105 fn stream_classifies_deepseek_model_on_openai_provider_as_reasoning() {
6106 // #1739: the SSE parser must treat a DeepSeek thinking model on the
6107 // generic `openai` provider (DeepSeek-compatible endpoint) as a
6108 // reasoning model, or incoming `reasoning_content` tokens are stored
6109 // as answer text and the subsequent replay still 400s.
6110 assert!(is_reasoning_model_for_stream(
6111 ApiProvider::Openai,
6112 "deepseek-v4-flash"
6113 ));
6114 assert!(is_reasoning_model_for_stream(
6115 ApiProvider::Openai,
6116 "deepseek-v4-pro"
6117 ));
6118 assert!(is_reasoning_model_for_stream(
6119 ApiProvider::Openai,
6120 "deepseek-reasoner"
6121 ));
6122 // Native DeepSeek provider was already correct; stays correct.
6123 assert!(is_reasoning_model_for_stream(
6124 ApiProvider::Deepseek,
6125 "deepseek-v4-pro"
6126 ));
6127 }
6128
6129 #[test]
6130 fn zai_tiered_effort_applies_to_glm_5_2_and_glm_5_3_but_not_5_1() {
6131 let zai = crate::config::DEFAULT_ZAI_BASE_URL;
6132 // GLM-5.3 inherits GLM-5.2's reasoning_options (effort high/max), so it
6133 // must take the same tiered wire path — not the generic toggle.
6134 for model in [
6135 crate::config::ZAI_GLM_5_2_MODEL,
6136 crate::config::ZAI_GLM_5_3_MODEL,
6137 ] {
6138 let mut body = json!({});
6139 apply_route_reasoning_controls(&mut body, ApiProvider::Zai, zai, model, Some("max"));
6140 assert_eq!(body["reasoning_effort"], json!("max"), "{model} at max");
6141
6142 let mut body = json!({});
6143 apply_route_reasoning_controls(&mut body, ApiProvider::Zai, zai, model, Some("high"));
6144 assert_eq!(body["reasoning_effort"], json!("high"), "{model} at high");
6145 }
6146
6147 // GLM-5.1 and GLM-5-Turbo keep only the generic thinking control.
6148 for model in [
6149 crate::config::ZAI_GLM_5_1_MODEL,
6150 crate::config::ZAI_GLM_5_TURBO_MODEL,
6151 ] {
6152 let mut body = json!({});
6153 apply_route_reasoning_controls(&mut body, ApiProvider::Zai, zai, model, Some("max"));
6154 assert!(
6155 body.get("reasoning_effort").is_none(),
6156 "{model} must not receive tiered effort"
6157 );
6158 }
6159
6160 // A compatible gateway is not evidence of the Z.ai dialect, for 5.3
6161 // exactly as for 5.2.
6162 let mut body = json!({"thinking": {"type": "enabled"}});
6163 apply_route_reasoning_controls(
6164 &mut body,
6165 ApiProvider::Zai,
6166 "https://gateway.example.com/v1",
6167 crate::config::ZAI_GLM_5_3_MODEL,
6168 Some("max"),
6169 );
6170 assert!(body.get("reasoning_effort").is_none());
6171 assert!(body.get("thinking").is_none());
6172 }
6173
6174 #[test]
6175 fn stream_classifies_known_large_reasoning_models_as_reasoning() {
6176 // Xiaomi MiMo and OpenRouter/Qwen/Trinity can stream private reasoning through a
6177 // `reasoning` delta without using a DeepSeek-looking model name. The
6178 // renderer must still route that field into Thinking cells instead
6179 // of plain assistant prose.
6180 assert!(
6181 is_reasoning_model_for_stream(ApiProvider::XiaomiMimo, "mimo-v2.5-pro"),
6182 "mimo-v2.5-pro should stream reasoning as thinking on Xiaomi MiMo"
6183 );
6184 assert!(
6185 is_reasoning_model_for_stream(ApiProvider::Arcee, "trinity-large-thinking"),
6186 "trinity-large-thinking should stream reasoning as thinking on direct Arcee"
6187 );
6188 assert!(
6189 is_reasoning_model_for_stream(ApiProvider::Zai, "GLM-5.2"),
6190 "GLM-5.2 should stream reasoning_content as thinking on direct Z.ai"
6191 );
6192 assert!(
6193 is_reasoning_model_for_stream(ApiProvider::Zai, "GLM-5.3"),
6194 "GLM-5.3 inherits GLM-5.2's reasoning capability on direct Z.ai"
6195 );
6196 for model in [
6197 "arcee-ai/trinity-large-thinking",
6198 "minimax/minimax-m3",
6199 "xiaomi/mimo-v2.5-pro",
6200 ] {
6201 assert!(
6202 is_reasoning_model_for_stream(ApiProvider::Openrouter, model),
6203 "{model} should stream reasoning as thinking on OpenRouter"
6204 );
6205 }
6206 }
6207
6208 #[test]
6209 fn stream_does_not_classify_generic_model_as_reasoning() {
6210 // #1542 no-regression guard: a genuine non-DeepSeek model on the
6211 // openai provider must NOT be treated as a reasoning model, so the
6212 // parser keeps inlining any `reasoning_content` it emits as text.
6213 assert!(!is_reasoning_model_for_stream(
6214 ApiProvider::Openai,
6215 "qwen3-coder"
6216 ));
6217 assert!(!is_reasoning_model_for_stream(
6218 ApiProvider::Openai,
6219 "claude-sonnet-4-6"
6220 ));
6221 // Non-DeepSeek model on a reasoning-aware provider is also unchanged.
6222 assert!(!is_reasoning_model_for_stream(
6223 ApiProvider::Deepseek,
6224 "qwen3-coder"
6225 ));
6226 }
6227
6228 #[test]
6229 fn stream_classification_matches_replay_predicate() {
6230 // The streaming classifier and the replay predicate must agree on
6231 // model identity, or stream parsing and message sanitisation disagree
6232 // about where reasoning tokens live. Effort=None isolates the
6233 // model/provider dimension shared by both.
6234 for model in ["deepseek-v4-pro", "deepseek-reasoner", "qwen3-coder"] {
6235 for provider in [ApiProvider::Openai, ApiProvider::Deepseek] {
6236 assert_eq!(
6237 is_reasoning_model_for_stream(provider, model),
6238 should_replay_reasoning_content_for_provider(provider, model, None),
6239 "stream vs replay disagree for {model} on {provider:?}"
6240 );
6241 }
6242 }
6243 }
6244 }
6245
6246 #[cfg(test)]
6247 mod image_block_wire_tests {
6248 //! The OpenAI-compatible projection of [`ContentBlock::ImageUrl`].
6249 //!
6250 //! Chat Completions is the wire format behind the large majority of
6251 //! CodeWhale's provider routes, so a regression here is a regression for
6252 //! most of them at once. The shape is fixed by OpenAI's spec: a `user`
6253 //! message whose `content` is an array of parts, with the image as
6254 //! `{"type":"image_url","image_url":{"url":…}}`.
6255 use super::{ApiProvider, build_chat_wire_body};
6256 use crate::models::{ContentBlock, ImageUrlContent, Message, MessageRequest};
6257
6258 const DATA_URL: &str = "data:image/png;base64,QUJD";
6259
6260 fn request_with_image() -> MessageRequest {
6261 MessageRequest {
6262 model: "gpt-4o".to_string(),
6263 messages: vec![Message {
6264 role: "user".to_string(),
6265 content: vec![
6266 ContentBlock::Text {
6267 text: "what is in this screenshot?".to_string(),
6268 cache_control: None,
6269 },
6270 ContentBlock::ImageUrl {
6271 image_url: ImageUrlContent {
6272 url: DATA_URL.to_string(),
6273 },
6274 },
6275 ],
6276 }],
6277 max_tokens: 128,
6278 system: None,
6279 tools: None,
6280 tool_choice: None,
6281 metadata: None,
6282 thinking: None,
6283 reasoning_effort: None,
6284 stream: None,
6285 temperature: None,
6286 top_p: None,
6287 }
6288 }
6289
6290 #[test]
6291 fn user_image_becomes_a_multimodal_parts_array() {
6292 let body = build_chat_wire_body(
6293 &request_with_image(),
6294 ApiProvider::Openai,
6295 "https://api.openai.com/v1",
6296 false,
6297 )
6298 .expect("wire body");
6299
6300 let messages = body.body["messages"].as_array().expect("messages");
6301 let user = messages
6302 .iter()
6303 .find(|message| message["role"] == "user")
6304 .expect("a user message");
6305 let parts = user["content"]
6306 .as_array()
6307 .expect("content must be a parts array once an image is present, not a bare string");
6308
6309 let image = parts
6310 .iter()
6311 .find(|part| part["type"] == "image_url")
6312 .expect("an image_url part");
6313 assert_eq!(image["image_url"]["url"], DATA_URL);
6314
6315 let text = parts
6316 .iter()
6317 .find(|part| part["type"] == "text")
6318 .expect("the accompanying text part");
6319 assert!(
6320 text["text"]
6321 .as_str()
6322 .expect("text")
6323 .contains("what is in this screenshot?"),
6324 "the question must survive alongside the image: {user}"
6325 );
6326 }
6327
6328 #[test]
6329 fn a_message_with_no_image_keeps_its_plain_string_content() {
6330 // Promoting every user turn to a parts array would change the request
6331 // bytes for every text-only route, and with them the prompt-cache
6332 // prefix. Images must be the only thing that triggers the array form.
6333 let mut request = request_with_image();
6334 request.messages[0]
6335 .content
6336 .retain(|block| !matches!(block, ContentBlock::ImageUrl { .. }));
6337
6338 let body = build_chat_wire_body(
6339 &request,
6340 ApiProvider::Openai,
6341 "https://api.openai.com/v1",
6342 false,
6343 )
6344 .expect("wire body");
6345
6346 let messages = body.body["messages"].as_array().expect("messages");
6347 let user = messages
6348 .iter()
6349 .find(|message| message["role"] == "user")
6350 .expect("a user message");
6351 assert!(
6352 user["content"].is_string(),
6353 "text-only turns must stay a plain string: {user}"
6354 );
6355 }
6356 }
6357
6357 lines RUST