返回 CodeWhale
test_cases_04.rs
根目录 / crates / tui / src / client / test_cases_04.rs
1
2 #[test]
3 fn default_headers_scenario() {
4 // Scenario consolidation of: default_headers_include_custom_headers_when_configured, default_headers_ignore_blank_custom_headers
5 // from default_headers_include_custom_headers_when_configured
6 {
7 let mut extra = HashMap::new();
8 extra.insert("X-Model-Provider-Id".to_string(), "tongyi".to_string());
9 let headers = CodewhaleClient::default_headers("sk-test", &extra).expect("headers");
10 assert_eq!(
11 headers
12 .get("x-model-provider-id")
13 .and_then(|value| value.to_str().ok()),
14 Some("tongyi")
15 );
16 }
17 // from default_headers_ignore_blank_custom_headers
18 {
19 let mut extra = HashMap::new();
20 extra.insert("X-Blank".to_string(), " ".to_string());
21 let headers = CodewhaleClient::default_headers("sk-test", &extra).expect("headers");
22 assert!(headers.get("x-blank").is_none());
23 }
24 }
25
26 #[test]
27 fn disabled_auth_strips_every_auth_header_dialect_at_client_sink() {
28 let mut extra = HashMap::new();
29 extra.insert(
30 "aUtHoRiZaTiOn".to_string(),
31 "Bearer configured-secret".to_string(),
32 );
33 extra.insert("X-API-Key".to_string(), "configured-x-key".to_string());
34 extra.insert("Api-Key".to_string(), "configured-key".to_string());
35 extra.insert(
36 "Proxy-Authorization".to_string(),
37 "Basic configured-proxy-secret".to_string(),
38 );
39 extra.insert(
40 "X-Auth-Token".to_string(),
41 "configured-auth-token".to_string(),
42 );
43 extra.insert(
44 "X-Access-Token".to_string(),
45 "configured-access-token".to_string(),
46 );
47 extra.insert(
48 "X-Goog-Api-Key".to_string(),
49 "configured-google-key".to_string(),
50 );
51 extra.insert("Cookie".to_string(), "session=secret".to_string());
52 extra.insert("X-Route-Metadata".to_string(), "safe".to_string());
53
54 let headers = CodewhaleClient::default_headers_for_provider_with_auth_disabled(
55 "generated-secret",
56 &extra,
57 ProviderKind::Deepseek,
58 crate::config::DEFAULT_DEEPSEEK_BASE_URL,
59 )
60 .expect("headers");
61
62 for name in [
63 "authorization",
64 "x-api-key",
65 "api-key",
66 "proxy-authorization",
67 "x-auth-token",
68 "x-access-token",
69 "x-goog-api-key",
70 "cookie",
71 ] {
72 assert!(headers.get(name).is_none(), "disabled auth leaked {name}");
73 }
74 assert_eq!(
75 headers
76 .get("x-route-metadata")
77 .and_then(|value| value.to_str().ok()),
78 Some("safe")
79 );
80 }
81
82 #[test]
83 fn build_http_client_accepts_default_tls_verification() {
84 let client = CodewhaleClient::build_http_client(
85 "sk-test",
86 &HashMap::new(),
87 ProviderKind::Deepseek,
88 crate::config::DEFAULT_DEEPSEEK_BASE_URL,
89 );
90
91 assert!(client.is_ok());
92 }
93
94 #[test]
95 fn client_new_rejects_provider_scoped_tls_skip_verify() {
96 let mut providers = crate::config::ProvidersConfig::default();
97 providers.openai.api_key = Some("sk-test".to_string());
98 providers.openai.base_url = Some(crate::config::DEFAULT_OPENAI_BASE_URL.to_string());
99 providers.openai.insecure_skip_tls_verify = Some(true);
100 let config = Config {
101 provider: Some("openai".to_string()),
102 providers: Some(providers),
103 ..Config::default()
104 };
105 assert!(config.insecure_skip_tls_verify());
106
107 let err = match CodewhaleClient::new(&config) {
108 Ok(_) => panic!("tls skip verify should be rejected"),
109 Err(err) => err,
110 };
111 let message = err.to_string();
112 assert!(message.contains("cannot be disabled"));
113 assert!(message.contains("SSL_CERT_FILE"));
114 }
115
116 #[test]
117 fn client_stream_idle_timeout_uses_tui_config() {
118 let client = CodewhaleClient::new(
119 &Config {
120 tui: Some(crate::config::TuiConfig {
121 stream_chunk_timeout_secs: Some(777),
122 max_model_steps: None,
123 turn_wall_clock_secs: None,
124 stream_max_content_mb: None,
125 stream_max_duration_secs: None,
126 ..crate::config::TuiConfig::default()
127 }),
128 ..Config::default()
129 }
130 .with_legacy_root(Some("sk-test".to_string()), None),
131 )
132 .expect("client");
133
134 assert_eq!(client.stream_idle_timeout, Duration::from_secs(777));
135 }
136
137 #[test]
138 fn xiaomi_mimo_scenario() {
139 // Scenario consolidation of: xiaomi_mimo_token_plan_endpoint_uses_api_key_header, xiaomi_mimo_tp_key_uses_api_key_header_with_custom_base_url, xiaomi_mimo_pay_as_you_go_endpoint_keeps_bearer_header
140 // from xiaomi_mimo_token_plan_endpoint_uses_api_key_header
141 {
142 let headers = CodewhaleClient::default_headers_for_provider(
143 "tp-test",
144 &HashMap::new(),
145 ProviderKind::XiaomiMimo,
146 crate::config::DEFAULT_XIAOMI_MIMO_BASE_URL,
147 )
148 .expect("headers");
149
150 assert_eq!(
151 headers.get("api-key").and_then(|value| value.to_str().ok()),
152 Some("tp-test")
153 );
154 assert!(
155 headers.get(AUTHORIZATION).is_none(),
156 "Token Plan requires api-key instead of Authorization Bearer"
157 );
158 }
159 // from xiaomi_mimo_tp_key_uses_api_key_header_with_custom_base_url
160 {
161 let mut extra = HashMap::new();
162 extra.insert("api-key".to_string(), "wrong".to_string());
163 extra.insert("Authorization".to_string(), "Bearer wrong".to_string());
164 let headers = CodewhaleClient::default_headers_for_provider(
165 "tp-custom",
166 &extra,
167 ProviderKind::XiaomiMimo,
168 "https://proxy.example.test/mimo/v1",
169 )
170 .expect("headers");
171
172 assert_eq!(
173 headers.get("api-key").and_then(|value| value.to_str().ok()),
174 Some("tp-custom")
175 );
176 assert!(
177 headers.get(AUTHORIZATION).is_none(),
178 "tp-* Token Plan keys should use api-key auth even through custom gateways"
179 );
180 }
181 // from xiaomi_mimo_pay_as_you_go_endpoint_keeps_bearer_header
182 {
183 let headers = CodewhaleClient::default_headers_for_provider(
184 "sk-test",
185 &HashMap::new(),
186 ProviderKind::XiaomiMimo,
187 crate::config::XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL,
188 )
189 .expect("headers");
190
191 assert_eq!(
192 headers
193 .get(AUTHORIZATION)
194 .and_then(|value| value.to_str().ok()),
195 Some("Bearer sk-test")
196 );
197 assert!(headers.get("api-key").is_none());
198 }
199 }
200
201 #[test]
202 fn openrouter_uses_bearer_header_after_mimo_token_plan_context() {
203 let mut extra = HashMap::new();
204 extra.insert("api-key".to_string(), "wrong".to_string());
205 let headers = CodewhaleClient::default_headers_for_provider(
206 "sk-or-test",
207 &extra,
208 ProviderKind::Openrouter,
209 crate::config::DEFAULT_OPENROUTER_BASE_URL,
210 )
211 .expect("headers");
212
213 assert_eq!(
214 headers
215 .get(AUTHORIZATION)
216 .and_then(|value| value.to_str().ok()),
217 Some("Bearer sk-or-test")
218 );
219 assert!(
220 headers.get("api-key").is_none(),
221 "OpenRouter must not inherit Xiaomi MiMo's api-key header dialect"
222 );
223 }
224
225 #[test]
226 fn siliconflow_cn_uses_bearer_header_and_pins_content_type() {
227 let mut extra = HashMap::new();
228 extra.insert("Authorization".to_string(), "Bearer wrong".to_string());
229 extra.insert("Content-Type".to_string(), "text/plain".to_string());
230 let headers = CodewhaleClient::default_headers_for_provider(
231 "sf-cn-test",
232 &extra,
233 ProviderKind::SiliconflowCN,
234 crate::config::DEFAULT_SILICONFLOW_CN_BASE_URL,
235 )
236 .expect("headers");
237
238 assert_eq!(
239 headers
240 .get(AUTHORIZATION)
241 .and_then(|value| value.to_str().ok()),
242 Some("Bearer sf-cn-test")
243 );
244 assert_eq!(
245 headers
246 .get(CONTENT_TYPE)
247 .and_then(|value| value.to_str().ok()),
248 Some("application/json")
249 );
250 assert!(headers.get("api-key").is_none());
251 }
252
253 #[test]
254 fn opencode_go_and_zen_requests_carry_stable_session_header() {
255 for api_provider in [ProviderKind::OpencodeGo, ProviderKind::OpencodeZen] {
256 let headers = CodewhaleClient::default_headers_for_provider(
257 "configured-key",
258 &HashMap::new(),
259 api_provider,
260 "https://opencode.ai/zen/go/v1",
261 )
262 .expect("headers");
263 let session = headers
264 .get("x-opencode-session")
265 .expect("x-opencode-session must be present for OpenCode gateways")
266 .to_str()
267 .expect("session id must be valid utf-8");
268 assert!(!session.is_empty(), "session id must be non-empty");
269
270 // The gateway requires one stable ID per conversation: a second
271 // request from the same process must reuse the same value.
272 let headers2 = CodewhaleClient::default_headers_for_provider(
273 "configured-key",
274 &HashMap::new(),
275 api_provider,
276 "https://opencode.ai/zen/go/v1",
277 )
278 .expect("headers2");
279 assert_eq!(
280 headers2
281 .get("x-opencode-session")
282 .and_then(|value| value.to_str().ok()),
283 Some(session),
284 "session id must be stable within a process"
285 );
286 }
287 }
288
289 #[test]
290 fn user_configured_opencode_session_header_wins() {
291 let mut extra = HashMap::new();
292 extra.insert(
293 "x-opencode-session".to_string(),
294 "user-configured-id".to_string(),
295 );
296 let headers = CodewhaleClient::default_headers_for_provider(
297 "configured-key",
298 &extra,
299 ProviderKind::OpencodeGo,
300 "https://opencode.ai/zen/go/v1",
301 )
302 .expect("headers");
303 assert_eq!(
304 headers
305 .get("x-opencode-session")
306 .and_then(|value| value.to_str().ok()),
307 Some("user-configured-id"),
308 "a user-configured x-opencode-session must override the default"
309 );
310 }
311
312 #[test]
313 fn non_opencode_providers_do_not_carry_session_header() {
314 for api_provider in [
315 ProviderKind::Deepseek,
316 ProviderKind::Anthropic,
317 ProviderKind::Openai,
318 ] {
319 let headers = CodewhaleClient::default_headers_for_provider(
320 "configured-key",
321 &HashMap::new(),
322 api_provider,
323 "https://example.invalid/v1",
324 )
325 .expect("headers");
326 assert!(
327 headers.get("x-opencode-session").is_none(),
328 "non-OpenCode provider {api_provider:?} must not send the session header"
329 );
330 }
331 }
332
333 #[test]
334 fn tokenhub_openai_compatible_route_uses_bearer_header() {
335 let mut extra = HashMap::new();
336 extra.insert("api-key".to_string(), "wrong".to_string());
337 extra.insert("x-api-key".to_string(), "wrong".to_string());
338 let headers = CodewhaleClient::default_headers_for_provider(
339 "tokenhub-test",
340 &extra,
341 ProviderKind::Openai,
342 "https://tokenhub.tencentmaas.com/v1",
343 )
344 .expect("headers");
345
346 assert_eq!(
347 headers
348 .get(AUTHORIZATION)
349 .and_then(|value| value.to_str().ok()),
350 Some("Bearer tokenhub-test")
351 );
352 assert!(headers.get("api-key").is_none());
353 assert!(headers.get("x-api-key").is_none());
354 }
355
356 #[test]
357 fn codewhale_authenticates_every_protocol_with_bearer_never_x_api_key() {
358 // The Codewhale API is a passthrough: it authenticates the account key
359 // with `Authorization: Bearer` on the Anthropic Messages route too, so
360 // the usual Messages `x-api-key` default must not apply here.
361 for wire in [
362 WireFormat::ChatCompletions,
363 WireFormat::AnthropicMessages,
364 WireFormat::Responses,
365 ] {
366 let headers = build_default_headers(
367 "cwc_key_test",
368 &HashMap::new(),
369 ProviderKind::Codewhale,
370 "https://api.codewhale.net/v1",
371 wire,
372 false,
373 )
374 .expect("headers");
375 assert_eq!(
376 headers
377 .get(AUTHORIZATION)
378 .and_then(|value| value.to_str().ok()),
379 Some("Bearer cwc_key_test"),
380 "{wire:?}"
381 );
382 assert!(headers.get("x-api-key").is_none(), "{wire:?}");
383 }
384 }
385
386 #[test]
387 fn deepseek_anthropic_uses_anthropic_header_dialect() {
388 let mut extra = HashMap::new();
389 extra.insert("Authorization".to_string(), "Bearer wrong".to_string());
390 extra.insert("api-key".to_string(), "wrong".to_string());
391 let headers = CodewhaleClient::default_headers_for_provider(
392 "ds-test",
393 &extra,
394 ProviderKind::DeepseekAnthropic,
395 crate::config::DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL,
396 )
397 .expect("headers");
398
399 assert_eq!(
400 headers
401 .get("x-api-key")
402 .and_then(|value| value.to_str().ok()),
403 Some("ds-test")
404 );
405 assert_eq!(
406 headers
407 .get("anthropic-version")
408 .and_then(|value| value.to_str().ok()),
409 Some("2023-06-01")
410 );
411 assert!(
412 headers.get(AUTHORIZATION).is_none(),
413 "Anthropic-compatible DeepSeek route must not use Bearer auth"
414 );
415 assert!(
416 headers.get("api-key").is_none(),
417 "Anthropic-compatible DeepSeek route must not inherit MiMo auth headers"
418 );
419 }
420
421 #[test]
422 fn minimax_anthropic_uses_anthropic_header_dialect() {
423 let headers = CodewhaleClient::default_headers_for_provider(
424 "minimax-test",
425 &HashMap::new(),
426 ProviderKind::MinimaxAnthropic,
427 crate::config::DEFAULT_MINIMAX_ANTHROPIC_BASE_URL,
428 )
429 .expect("headers");
430
431 assert_eq!(
432 headers
433 .get("x-api-key")
434 .and_then(|value| value.to_str().ok()),
435 Some("minimax-test")
436 );
437 assert_eq!(
438 headers
439 .get("anthropic-version")
440 .and_then(|value| value.to_str().ok()),
441 Some("2023-06-01")
442 );
443 assert!(headers.get(AUTHORIZATION).is_none());
444 }
445
446 #[test]
447 fn openmodel_uses_bearer_auth_with_anthropic_version() {
448 let mut extra = HashMap::new();
449 extra.insert("Authorization".to_string(), "Bearer wrong".to_string());
450 extra.insert("api-key".to_string(), "wrong".to_string());
451 extra.insert("x-api-key".to_string(), "wrong".to_string());
452 let headers = CodewhaleClient::default_headers_for_provider(
453 "om-test",
454 &extra,
455 ProviderKind::Openmodel,
456 crate::config::DEFAULT_OPENMODEL_BASE_URL,
457 )
458 .expect("headers");
459
460 assert_eq!(
461 headers
462 .get(AUTHORIZATION)
463 .and_then(|value| value.to_str().ok()),
464 Some("Bearer om-test")
465 );
466 assert_eq!(
467 headers
468 .get("anthropic-version")
469 .and_then(|value| value.to_str().ok()),
470 Some("2023-06-01")
471 );
472 assert!(
473 headers.get("x-api-key").is_none(),
474 "OpenModel uses Bearer auth so /v1/models and /v1/messages share one client"
475 );
476 assert!(
477 headers.get("api-key").is_none(),
478 "OpenModel Messages route must not inherit MiMo auth headers"
479 );
480 }
481
482 #[tokio::test]
483 async fn deepseek_anthropic_translate_uses_messages_endpoint() {
484 // `translate` resolves `max_tokens` when building the request and this
485 // test recomputes the same route allowance when asserting. That value
486 // reads `CODEWHALE_MAX_OUTPUT_TOKENS`/`DEEPSEEK_MAX_OUTPUT_TOKENS` from
487 // the process environment, so a concurrent test that redirects either
488 // variable between the two reads flips one side and fails the
489 // assertion (#5929). Hold the test env barrier for the whole request
490 // so both reads observe one stable environment.
491 let _env_lock = crate::test_support::lock_test_env();
492 let server = MockServer::start().await;
493 Mock::given(method("POST"))
494 .and(path("/v1/messages"))
495 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
496 "id": "msg_1",
497 "type": "message",
498 "role": "assistant",
499 "content": [{"type": "text", "text": "Hola"}],
500 "model": "deepseek-chat",
501 "stop_reason": "end_turn",
502 "stop_sequence": null,
503 "usage": {"input_tokens": 3, "output_tokens": 1}
504 })))
505 .expect(1)
506 .mount(&server)
507 .await;
508
509 let client = deepseek_anthropic_client(&server);
510 let translated = client
511 .translate("Hello", "deepseek-chat", "Spanish")
512 .await
513 .expect("translation succeeds");
514
515 assert_eq!(translated, "Hola");
516 let requests = server.received_requests().await.expect("recorded requests");
517 assert_eq!(requests.len(), 1);
518 let body: Value = serde_json::from_slice(&requests[0].body).expect("json body");
519 assert_eq!(
520 body.get("model").and_then(Value::as_str),
521 Some("deepseek-chat"),
522 "custom Messages endpoints own their model ids: {body}"
523 );
524 assert_eq!(
525 body.pointer("/messages/0/role").and_then(Value::as_str),
526 Some("user")
527 );
528 assert_eq!(
529 body.pointer("/messages/0/content/0/text")
530 .and_then(Value::as_str),
531 Some("Hello")
532 );
533 assert!(
534 body.get("thinking").is_none(),
535 "translation disables thinking: {body}"
536 );
537 assert!(
538 body.get("temperature").is_none() && body.get("top_p").is_none(),
539 "translation must not inject sampling controls: {body}"
540 );
541 assert_eq!(
542 body.get("max_tokens").and_then(Value::as_u64),
543 Some(u64::from(
544 crate::route_budget::effective_max_output_tokens_for_route(
545 ProviderKind::DeepseekAnthropic,
546 "deepseek-chat",
547 None,
548 )
549 )),
550 "translation must inherit its resolved route allowance: {body}"
551 );
552 assert!(
553 body.get("system")
554 .and_then(Value::as_str)
555 .is_some_and(|system| system.contains("Spanish")),
556 "target language should be in system prompt: {body}"
557 );
558 }
559
560 #[tokio::test]
561 async fn deepseek_anthropic_scenario() {
562 // Scenario consolidation of: deepseek_anthropic_health_check_skips_models_probe, deepseek_anthropic_fim_fails_without_http_request
563 // from deepseek_anthropic_health_check_skips_models_probe
564 {
565 let server = MockServer::start().await;
566 let client = deepseek_anthropic_client(&server);
567
568 assert!(client.health_check().await.expect("health check"));
569 assert!(!provider_api_key_verification_is_observed(
570 ProviderKind::DeepseekAnthropic
571 ));
572 let requests = server.received_requests().await.expect("recorded requests");
573 assert!(
574 requests.is_empty(),
575 "DeepSeek Anthropic-compatible route must not probe /models"
576 );
577 }
578 // from deepseek_anthropic_fim_fails_without_http_request
579 {
580 let server = MockServer::start().await;
581 let client = deepseek_anthropic_client(&server);
582
583 let err = client
584 .fim_completion("deepseek-chat", "fn main() {", "}", 16)
585 .await
586 .expect_err("FIM is unsupported");
587 let message = err.to_string();
588 assert!(
589 message.contains("FIM completion is not supported"),
590 "{message}"
591 );
592 assert!(message.contains("no proven FIM wire contract"), "{message}");
593 let requests = server.received_requests().await.expect("recorded requests");
594 assert!(
595 requests.is_empty(),
596 "unsupported FIM should fail locally before any HTTP call"
597 );
598 }
599 }
600
601 #[tokio::test]
602 async fn minimax_anthropic_health_check_uses_models_endpoint() {
603 let server = MockServer::start().await;
604 Mock::given(method("GET"))
605 .and(path("/anthropic/v1/models"))
606 .and(header("x-api-key", "minimax-test"))
607 .and(header("anthropic-version", "2023-06-01"))
608 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"data": []})))
609 .expect(1)
610 .mount(&server)
611 .await;
612 let client = minimax_anthropic_client_with_base_url(format!("{}/anthropic", server.uri()));
613
614 assert!(client.health_check().await.expect("health check"));
615 }
616
617 #[tokio::test]
618 async fn minimax_anthropic_request_uses_messages_endpoint() {
619 let server = MockServer::start().await;
620 Mock::given(method("POST"))
621 .and(path("/anthropic/v1/messages"))
622 .and(header("x-api-key", "minimax-test"))
623 .and(header("anthropic-version", "2023-06-01"))
624 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
625 "id": "msg_1",
626 "type": "message",
627 "role": "assistant",
628 "content": [{"type": "text", "text": "ok"}],
629 "model": "MiniMax-M3",
630 "stop_reason": "end_turn",
631 "stop_sequence": null,
632 "usage": {"input_tokens": 3, "output_tokens": 1}
633 })))
634 .expect(1)
635 .mount(&server)
636 .await;
637
638 let mut client = minimax_anthropic_client_with_base_url(
639 crate::config::DEFAULT_MINIMAX_ANTHROPIC_BASE_URL.to_string(),
640 );
641 client.test_messages_transport_base_url = Some(format!("{}/anthropic", server.uri()));
642 let response = client
643 .create_message(MessageRequest {
644 model: "MiniMax-M3".to_string(),
645 messages: vec![Message {
646 role: Role::User,
647 content: vec![ContentBlock::Text {
648 text: "hello".to_string(),
649 cache_control: None,
650 }],
651 }],
652 max_tokens: 32,
653 system: None,
654 tools: None,
655 tool_choice: None,
656 metadata: None,
657 thinking: None,
658 reasoning_effort: Some("off".to_string()),
659 stream: Some(false),
660 temperature: None,
661 top_p: None,
662 })
663 .await
664 .expect("message succeeds");
665
666 assert_eq!(response.content.len(), 1);
667 let requests = server.received_requests().await.expect("recorded requests");
668 let body: Value = serde_json::from_slice(&requests[0].body).expect("request JSON");
669 assert_eq!(
670 body.pointer("/thinking/type").and_then(Value::as_str),
671 Some("disabled")
672 );
673 assert!(body.get("output_config").is_none(), "{body}");
674 }
675
676 #[test]
677 fn custom_api_key_header_is_allowed_without_primary_provider_key() {
678 let mut extra = HashMap::new();
679 extra.insert("api-key".to_string(), "gateway-key".to_string());
680 let headers = CodewhaleClient::default_headers_for_provider(
681 "",
682 &extra,
683 ProviderKind::Openai,
684 "https://gateway.example.test/v1",
685 )
686 .expect("headers");
687
688 assert_eq!(
689 headers.get("api-key").and_then(|value| value.to_str().ok()),
690 Some("gateway-key")
691 );
692 assert!(headers.get(AUTHORIZATION).is_none());
693 }
694
695 #[test]
696 fn chat_messages_keep_current_turn_reasoning_content() {
697 let message = Message {
698 role: Role::Assistant,
699 content: vec![
700 ContentBlock::Thinking {
701 signature: None,
702 state: None,
703 thinking: "plan".to_string(),
704 },
705 ContentBlock::Text {
706 text: "done".to_string(),
707 cache_control: None,
708 },
709 ],
710 };
711 let out = build_chat_messages(None, &[message], "deepseek-v4-pro");
712 let assistant = out
713 .iter()
714 .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant"))
715 .expect("assistant message");
716 assert_eq!(
717 assistant.get("content").and_then(Value::as_str),
718 Some("done")
719 );
720 assert_eq!(
721 assistant.get("reasoning_content").and_then(Value::as_str),
722 Some("plan"),
723 "thinking-mode models keep reasoning_content while still in the current turn"
724 );
725 }
726
727 #[test]
728 fn generic_openai_provider_drops_reasoning_content_for_non_deepseek_models() {
729 // #1542 intent (narrowed by #1739/#1694): a *genuine non-DeepSeek*
730 // model on the generic openai provider must not carry DeepSeek-only
731 // `reasoning_content`. A DeepSeek reasoning model on the openai
732 // provider (DeepSeek-compatible endpoint) is now covered separately
733 // and DOES replay reasoning_content — see
734 // `deepseek_model_on_openai_provider_still_replays_reasoning_content`.
735 let request = MessageRequest {
736 model: "qwen3-coder".to_string(),
737 messages: vec![Message {
738 role: Role::Assistant,
739 content: vec![
740 ContentBlock::Thinking {
741 signature: None,
742 state: None,
743 thinking: "plan".to_string(),
744 },
745 ContentBlock::Text {
746 text: "done".to_string(),
747 cache_control: None,
748 },
749 ],
750 }],
751 max_tokens: 16,
752 system: None,
753 tools: None,
754 tool_choice: None,
755 metadata: None,
756 thinking: None,
757 reasoning_effort: Some("max".to_string()),
758 stream: None,
759 temperature: None,
760 top_p: None,
761 };
762
763 let openai = build_chat_messages_for_request_and_provider(&request, ProviderKind::Openai);
764 let generic_assistant = openai
765 .iter()
766 .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant"))
767 .expect("assistant message");
768 assert_eq!(
769 generic_assistant.get("content").and_then(Value::as_str),
770 Some("done")
771 );
772 assert!(
773 generic_assistant.get("reasoning_content").is_none(),
774 "generic OpenAI-compatible providers reject DeepSeek-only reasoning_content (#1542)"
775 );
776 }
777
778 #[test]
779 fn chat_messages_replay_tool_round_reasoning_before_new_user_turn() {
780 let messages = vec![
781 Message {
782 role: Role::User,
783 content: vec![ContentBlock::Text {
784 text: "Need the date".to_string(),
785 cache_control: None,
786 }],
787 },
788 Message {
789 role: Role::Assistant,
790 content: vec![
791 ContentBlock::Thinking {
792 signature: None,
793 state: None,
794 thinking: "Need to call a tool".to_string(),
795 },
796 ContentBlock::ToolUse {
797 execution_id: None,
798 id: "tool-1".to_string(),
799 name: "get_date".to_string(),
800 input: json!({}),
801 caller: None,
802 thought_signature: None,
803 },
804 ],
805 },
806 Message {
807 role: Role::User,
808 content: vec![ContentBlock::ToolResult {
809 execution_id: None,
810 tool_use_id: "tool-1".to_string(),
811 content: "2026-04-23".to_string(),
812 is_error: None,
813 content_blocks: None,
814 }],
815 },
816 ];
817 let out = build_chat_messages(None, &messages, "deepseek-v4-pro");
818 let tool_assistant = out
819 .iter()
820 .find(|value| {
821 value.get("role").and_then(Value::as_str) == Some("assistant")
822 && value.get("tool_calls").is_some()
823 })
824 .expect("tool-call assistant message");
825 assert_eq!(
826 tool_assistant
827 .get("reasoning_content")
828 .and_then(Value::as_str),
829 Some("Need to call a tool"),
830 "thinking-mode tool sub-turns must replay reasoning_content until the tool chain finishes"
831 );
832 }
833
834 #[test]
835 fn chat_messages_replay_prior_tool_round_reasoning_after_new_user_turn() {
836 let messages = vec![
837 Message {
838 role: Role::User,
839 content: vec![ContentBlock::Text {
840 text: "Need the date".to_string(),
841 cache_control: None,
842 }],
843 },
844 Message {
845 role: Role::Assistant,
846 content: vec![
847 ContentBlock::Thinking {
848 signature: None,
849 state: None,
850 thinking: "Need to call a tool".to_string(),
851 },
852 ContentBlock::ToolUse {
853 execution_id: None,
854 id: "tool-1".to_string(),
855 name: "get_date".to_string(),
856 input: json!({}),
857 caller: None,
858 thought_signature: None,
859 },
860 ],
861 },
862 Message {
863 role: Role::User,
864 content: vec![ContentBlock::ToolResult {
865 execution_id: None,
866 tool_use_id: "tool-1".to_string(),
867 content: "2026-04-23".to_string(),
868 is_error: None,
869 content_blocks: None,
870 }],
871 },
872 Message {
873 role: Role::Assistant,
874 content: vec![ContentBlock::Text {
875 text: "It is 2026-04-23.".to_string(),
876 cache_control: None,
877 }],
878 },
879 Message {
880 role: Role::User,
881 content: vec![ContentBlock::Text {
882 text: "Thanks. Next question.".to_string(),
883 cache_control: None,
884 }],
885 },
886 ];
887 let out = build_chat_messages(None, &messages, "deepseek-v4-pro");
888 let tool_assistant = out
889 .iter()
890 .find(|value| {
891 value.get("role").and_then(Value::as_str) == Some("assistant")
892 && value.get("tool_calls").is_some()
893 })
894 .expect("tool-call assistant message");
895 assert_eq!(
896 tool_assistant
897 .get("reasoning_content")
898 .and_then(Value::as_str),
899 Some("Need to call a tool"),
900 "tool-call reasoning_content must be replayed across later user turns"
901 );
902 }
903
904 #[test]
905 fn chat_messages_keep_prior_non_tool_reasoning_after_new_user_turn() {
906 // The serialized JSON for a stored assistant message MUST be a pure
907 // function of that message — never of what comes after it. DeepSeek's
908 // prompt cache hashes the leading bytes of every request; flipping
909 // `reasoning_content` on/off across turns rewrites historical bytes
910 // and busts the prefix cache from that message onwards. (#583)
911 let messages = vec![
912 Message {
913 role: Role::User,
914 content: vec![ContentBlock::Text {
915 text: "Explain it".to_string(),
916 cache_control: None,
917 }],
918 },
919 Message {
920 role: Role::Assistant,
921 content: vec![
922 ContentBlock::Thinking {
923 signature: None,
924 state: None,
925 thinking: "Internal explanation plan".to_string(),
926 },
927 ContentBlock::Text {
928 text: "Final answer".to_string(),
929 cache_control: None,
930 },
931 ],
932 },
933 Message {
934 role: Role::User,
935 content: vec![ContentBlock::Text {
936 text: "Next question".to_string(),
937 cache_control: None,
938 }],
939 },
940 ];
941
942 let out = build_chat_messages(None, &messages, "deepseek-v4-pro");
943 let assistant = out
944 .iter()
945 .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant"))
946 .expect("assistant message");
947
948 assert_eq!(
949 assistant.get("content").and_then(Value::as_str),
950 Some("Final answer")
951 );
952 assert_eq!(
953 assistant.get("reasoning_content").and_then(Value::as_str),
954 Some("Internal explanation plan"),
955 "reasoning_content must be preserved across follow-up user turns to keep DeepSeek's prefix cache warm"
956 );
957 }
958
959 #[test]
960 fn chat_messages_assistant_json_is_byte_stable_across_follow_up_user_turn() {
961 // Direct prefix-cache regression: the JSON for the assistant message
962 // built on turn N must equal the JSON for the same assistant message
963 // built on turn N+1, after a new user message has been appended.
964 let assistant = Message {
965 role: Role::Assistant,
966 content: vec![
967 ContentBlock::Thinking {
968 signature: None,
969 state: None,
970 thinking: "I should explain step by step.".to_string(),
971 },
972 ContentBlock::Text {
973 text: "Here is the explanation.".to_string(),
974 cache_control: None,
975 },
976 ],
977 };
978 let user_initial = Message {
979 role: Role::User,
980 content: vec![ContentBlock::Text {
981 text: "Explain it".to_string(),
982 cache_control: None,
983 }],
984 };
985 let user_follow_up = Message {
986 role: Role::User,
987 content: vec![ContentBlock::Text {
988 text: "Next question".to_string(),
989 cache_control: None,
990 }],
991 };
992
993 let turn_n = build_chat_messages(
994 None,
995 &[user_initial.clone(), assistant.clone()],
996 "deepseek-v4-pro",
997 );
998 let turn_n_plus_1 = build_chat_messages(
999 None,
1000 &[user_initial, assistant, user_follow_up],
1001 "deepseek-v4-pro",
1002 );
1003
1004 let assistant_n = turn_n
1005 .iter()
1006 .find(|v| v.get("role").and_then(Value::as_str) == Some("assistant"))
1007 .expect("assistant present in turn N");
1008 let assistant_n1 = turn_n_plus_1
1009 .iter()
1010 .find(|v| v.get("role").and_then(Value::as_str) == Some("assistant"))
1011 .expect("assistant present in turn N+1");
1012
1013 assert_eq!(
1014 assistant_n, assistant_n1,
1015 "assistant message JSON must be byte-identical across turns or DeepSeek's prefix cache breaks"
1016 );
1017 }
1018
1019 #[test]
1020 fn chat_messages_allow_tool_round_without_reasoning_when_thinking_disabled() {
1021 let request = MessageRequest {
1022 model: "deepseek-v4-pro".to_string(),
1023 messages: vec![
1024 Message {
1025 role: Role::Assistant,
1026 content: vec![ContentBlock::ToolUse {
1027 execution_id: None,
1028 id: "call-no-thinking".to_string(),
1029 name: "read_file".to_string(),
1030 input: json!({"path": "Cargo.toml"}),
1031 caller: None,
1032 thought_signature: None,
1033 }],
1034 },
1035 Message {
1036 role: Role::User,
1037 content: vec![ContentBlock::ToolResult {
1038 execution_id: None,
1039 tool_use_id: "call-no-thinking".to_string(),
1040 content: "workspace manifest".to_string(),
1041 is_error: None,
1042 content_blocks: None,
1043 }],
1044 },
1045 ],
1046 max_tokens: 1024,
1047 system: None,
1048 tools: None,
1049 tool_choice: None,
1050 metadata: None,
1051 thinking: None,
1052 reasoning_effort: Some("off".to_string()),
1053 stream: None,
1054 temperature: None,
1055 top_p: None,
1056 };
1057
1058 let out = build_chat_messages_for_request(&request);
1059 assert!(
1060 out.iter().any(
1061 |value| value.get("role").and_then(Value::as_str) == Some("assistant")
1062 && value.get("tool_calls").is_some()
1063 ),
1064 "tool calls remain valid when thinking mode is disabled"
1065 );
1066 assert!(
1067 out.iter()
1068 .any(|value| value.get("role").and_then(Value::as_str) == Some("tool")),
1069 "matching tool result should remain"
1070 );
1071 }
1072
1073 #[test]
1074 fn prompt_builder_keeps_system_first_and_current_user_input_last() {
1075 let request = MessageRequest {
1076 model: "deepseek-v4-pro".to_string(),
1077 messages: vec![
1078 Message {
1079 role: Role::Assistant,
1080 content: vec![ContentBlock::Text {
1081 text: "Previous answer".to_string(),
1082 cache_control: None,
1083 }],
1084 },
1085 Message {
1086 role: Role::User,
1087 content: vec![
1088 ContentBlock::Text {
1089 text: "<turn_meta>\nCurrent local date: 2026-05-08\n</turn_meta>"
1090 .to_string(),
1091 cache_control: None,
1092 },
1093 ContentBlock::Text {
1094 text: "Current user question".to_string(),
1095 cache_control: None,
1096 },
1097 ],
1098 },
1099 ],
1100 max_tokens: 1024,
1101 system: Some(SystemPrompt::Text(
1102 "Stable mode, project rules, and tool policy".to_string(),
1103 )),
1104 tools: None,
1105 tool_choice: None,
1106 metadata: None,
1107 thinking: None,
1108 reasoning_effort: Some("max".to_string()),
1109 stream: None,
1110 temperature: None,
1111 top_p: None,
1112 };
1113
1114 let out = build_chat_messages_for_request(&request);
1115
1116 assert_eq!(out[0].get("role").and_then(Value::as_str), Some("system"));
1117 assert_eq!(
1118 out[0].get("content").and_then(Value::as_str),
1119 Some("Stable mode, project rules, and tool policy")
1120 );
1121 let last = out.last().expect("latest user message");
1122 assert_eq!(last.get("role").and_then(Value::as_str), Some("user"));
1123 assert!(
1124 last.get("content")
1125 .and_then(Value::as_str)
1126 .is_some_and(|content| content.ends_with("Current user question")),
1127 "current-turn user input must be at the tail of the wire prompt: {last:?}"
1128 );
1129 }
1130
1131 #[test]
1132 fn prompt_inspect_reports_stable_layers_and_dynamic_user_task() {
1133 let request = MessageRequest {
1134 model: "deepseek-v4-pro".to_string(),
1135 messages: vec![
1136 Message {
1137 role: Role::Assistant,
1138 content: vec![ContentBlock::Text {
1139 text: "Prior answer".to_string(),
1140 cache_control: None,
1141 }],
1142 },
1143 Message {
1144 role: Role::User,
1145 content: vec![ContentBlock::Text {
1146 text: "Current task".to_string(),
1147 cache_control: None,
1148 }],
1149 },
1150 ],
1151 max_tokens: 1024,
1152 system: Some(SystemPrompt::Text(
1153 "Base policy\n\n<project_instructions source=\"AGENTS.md\">\nRules\n</project_instructions>\n\n## Project Context Pack\n\n<project_context_pack>\n{}\n</project_context_pack>\n\n## Environment\n\n- lang: en"
1154 .to_string(),
1155 )),
1156 tools: None,
1157 tool_choice: None,
1158 metadata: None,
1159 thinking: None,
1160 reasoning_effort: Some("max".to_string()),
1161 stream: None,
1162 temperature: None,
1163 top_p: None,
1164 };
1165
1166 let inspection = inspect_prompt_for_request(&request);
1167
1168 assert_eq!(inspection.base_static_prefix_hash.len(), 64);
1169 assert_eq!(inspection.full_request_prefix_hash.len(), 64);
1170 assert!(inspection.layers.iter().any(|layer| {
1171 layer.name == "Global system prefix"
1172 && layer.stability.label() == "static"
1173 && layer.char_len == "Base policy".chars().count()
1174 && layer.sha256.len() == 64
1175 }));
1176 assert!(
1177 inspection.layers.iter().any(|layer| {
1178 layer.name == "Project context" && layer.stability.label() == "static"
1179 })
1180 );
1181 assert!(inspection.layers.iter().any(|layer| {
1182 layer.name == "Project context pack" && layer.stability.label() == "static"
1183 }));
1184 assert!(inspection.layers.iter().any(|layer| {
1185 layer.name == "Message #1 assistant" && layer.stability.label() == "history"
1186 }));
1187 assert!(
1188 inspection
1189 .layers
1190 .last()
1191 .is_some_and(|layer| layer.name == "User task" && layer.stability.label() == "dynamic")
1192 );
1193 }
1194
1195 #[test]
1196 fn prompt_inspect_keeps_static_base_hash_across_different_user_tasks() {
1197 fn request_with_user_task(task: &str) -> MessageRequest {
1198 MessageRequest {
1199 model: "deepseek-v4-pro".to_string(),
1200 messages: vec![
1201 Message {
1202 role: Role::Assistant,
1203 content: vec![ContentBlock::Text {
1204 text: "Prior answer".to_string(),
1205 cache_control: None,
1206 }],
1207 },
1208 Message {
1209 role: Role::User,
1210 content: vec![ContentBlock::Text {
1211 text: task.to_string(),
1212 cache_control: None,
1213 }],
1214 },
1215 ],
1216 max_tokens: 1024,
1217 system: Some(SystemPrompt::Text(
1218 "Base policy\n\n## Environment\n\n- shell: powershell\n\n## Skills\n\n- rust\n\n## Context Management\n\nKeep concise\n\n## Compact\n\nTemplate"
1219 .to_string(),
1220 )),
1221 tools: None,
1222 tool_choice: None,
1223 metadata: None,
1224 thinking: None,
1225 reasoning_effort: Some("max".to_string()),
1226 stream: None,
1227 temperature: None,
1228 top_p: None,
1229 }
1230 }
1231
1232 let first = inspect_prompt_for_request(&request_with_user_task("First task"));
1233 let second = inspect_prompt_for_request(&request_with_user_task("Second task"));
1234 let mut changed_history_request = request_with_user_task("Second task");
1235 changed_history_request.messages[0] = Message {
1236 role: Role::Assistant,
1237 content: vec![ContentBlock::Text {
1238 text: "Different prior answer".to_string(),
1239 cache_control: None,
1240 }],
1241 };
1242 let changed_history = inspect_prompt_for_request(&changed_history_request);
1243
1244 assert_eq!(
1245 first.base_static_prefix_hash,
1246 second.base_static_prefix_hash
1247 );
1248 assert_eq!(
1249 first.full_request_prefix_hash, second.full_request_prefix_hash,
1250 "full request prefix excludes the final dynamic user task"
1251 );
1252 assert_ne!(
1253 second.full_request_prefix_hash, changed_history.full_request_prefix_hash,
1254 "full request prefix can change when session history changes"
1255 );
1256 assert!(
1257 second
1258 .layers
1259 .last()
1260 .is_some_and(|layer| layer.name == "User task" && layer.stability.label() == "dynamic"),
1261 "current user task must remain the final layer"
1262 );
1263 assert!(second.layers.iter().any(|layer| {
1264 layer.name == "Message #1 assistant" && layer.stability.label() == "history"
1265 }));
1266 assert!(!second.layers.iter().any(
1267 |layer| layer.name.starts_with("Message #") && layer.stability.label() == "static"
1268 ));
1269 }
1270
1271 #[test]
1272 fn prompt_inspect_tracks_tool_catalog_in_static_prefix_hash() {
1273 let request = MessageRequest {
1274 model: "deepseek-v4-pro".to_string(),
1275 messages: vec![Message {
1276 role: Role::User,
1277 content: vec![ContentBlock::Text {
1278 text: "Current task".to_string(),
1279 cache_control: None,
1280 }],
1281 }],
1282 max_tokens: 1024,
1283 system: Some(SystemPrompt::Text("Base policy".to_string())),
1284 tools: Some(vec![test_tool("read_file")]),
1285 tool_choice: None,
1286 metadata: None,
1287 thinking: None,
1288 reasoning_effort: Some("max".to_string()),
1289 stream: None,
1290 temperature: None,
1291 top_p: None,
1292 };
1293
1294 let first = inspect_prompt_for_request(&request);
1295 let mut changed_tools = request.clone();
1296 changed_tools.tools = Some(vec![test_tool("read_file"), test_tool("grep_files")]);
1297 let second = inspect_prompt_for_request(&changed_tools);
1298
1299 assert!(
1300 first
1301 .layers
1302 .iter()
1303 .any(|layer| { layer.name == "Tool catalog" && layer.stability.label() == "static" })
1304 );
1305 assert_ne!(
1306 first.base_static_prefix_hash, second.base_static_prefix_hash,
1307 "tool schema changes must be visible to cache-inspect base prefix diagnostics"
1308 );
1309 assert_ne!(
1310 first.full_request_prefix_hash, second.full_request_prefix_hash,
1311 "tool schema changes must be visible to full reusable-prefix diagnostics"
1312 );
1313 }
1314
1315 #[test]
1316 fn cache_warmup_request_reuses_stable_prefix_and_fixed_user_tail() {
1317 let request = MessageRequest {
1318 model: "deepseek-v4-pro".to_string(),
1319 messages: vec![
1320 Message {
1321 role: Role::Assistant,
1322 content: vec![ContentBlock::Text {
1323 text: "Stable prior answer".to_string(),
1324 cache_control: None,
1325 }],
1326 },
1327 Message {
1328 role: Role::User,
1329 content: vec![ContentBlock::Text {
1330 text: "Dynamic latest user task".to_string(),
1331 cache_control: None,
1332 }],
1333 },
1334 ],
1335 max_tokens: 1024,
1336 system: Some(SystemPrompt::Text(
1337 "Base policy\n\n<project_instructions source=\"AGENTS.md\">\nStable project rules\n</project_instructions>\n\n## Previous Session Relay\n\nDynamic relay"
1338 .to_string(),
1339 )),
1340 tools: Some(vec![test_tool("read_file")]),
1341 tool_choice: None,
1342 metadata: None,
1343 thinking: None,
1344 reasoning_effort: Some("max".to_string()),
1345 stream: Some(true),
1346 temperature: Some(0.7),
1347 top_p: None,
1348 };
1349
1350 let warmup = build_cache_warmup_request(&request);
1351
1352 assert_eq!(warmup.max_tokens, 8);
1353 assert_eq!(warmup.temperature, None);
1354 assert_eq!(warmup.top_p, None);
1355 assert_eq!(warmup.reasoning_effort.as_deref(), Some("off"));
1356 assert_eq!(warmup.tools.as_ref().map(Vec::len), Some(1));
1357 assert_eq!(warmup.tool_choice, Some(json!("none")));
1358 assert_eq!(warmup.messages.len(), 2);
1359 assert_eq!(warmup.messages[0].role, "assistant");
1360 assert_eq!(warmup.messages[1].role, "user");
1361 assert_eq!(
1362 warmup.messages[1].content,
1363 vec![ContentBlock::Text {
1364 text: "请只回复 OK".to_string(),
1365 cache_control: None,
1366 }]
1367 );
1368
1369 let wire = build_chat_messages_for_request(&warmup);
1370 let system = wire
1371 .first()
1372 .and_then(|value| value.get("content"))
1373 .and_then(Value::as_str)
1374 .expect("warmup system prompt");
1375 assert!(system.contains("Stable project rules"));
1376 assert!(!system.contains("Dynamic relay"));
1377 assert!(
1378 !wire
1379 .iter()
1380 .any(|value| value.to_string().contains("Dynamic latest user task")),
1381 "warmup must not include the dynamic latest user task"
1382 );
1383 }
1384
1385 #[test]
1386 fn reasoning_effort_scenario() {
1387 // Scenario consolidation of: reasoning_effort_uses_deepseek_top_level_thinking_parameter, reasoning_effort_off_disables_top_level_thinking, reasoning_effort_off_is_omitted_for_strict_openai_like_providers, reasoning_effort_atlascloud_speaks_deepseek_dialect, reasoning_effort_modelstudio_writes_nothing_without_a_verified_route, reasoning_effort_moonshot_toggles_thinking, reasoning_effort_edenai_does_not_guess_a_model_dialect, reasoning_effort_ollama_toggles_think_flag
1388 // from reasoning_effort_uses_deepseek_top_level_thinking_parameter
1389 {
1390 let mut body = json!({});
1391 apply_reasoning_effort(&mut body, Some("max"), ProviderKind::Deepseek);
1392
1393 assert_eq!(
1394 body.get("reasoning_effort").and_then(Value::as_str),
1395 Some("max")
1396 );
1397 assert_eq!(
1398 body.pointer("/thinking/type").and_then(Value::as_str),
1399 Some("enabled")
1400 );
1401 assert!(body.get("extra_body").is_none());
1402 }
1403 // from reasoning_effort_off_disables_top_level_thinking
1404 {
1405 let mut body = json!({});
1406 apply_reasoning_effort(&mut body, Some("off"), ProviderKind::Deepseek);
1407
1408 assert_eq!(
1409 body.pointer("/thinking/type").and_then(Value::as_str),
1410 Some("disabled")
1411 );
1412 assert!(body.get("reasoning_effort").is_none());
1413 assert!(body.get("extra_body").is_none());
1414 }
1415 // from reasoning_effort_off_is_omitted_for_strict_openai_like_providers
1416 {
1417 for provider in [
1418 ProviderKind::Openai,
1419 ProviderKind::WanjieArk,
1420 ProviderKind::Qianfan,
1421 ProviderKind::Arcee,
1422 ProviderKind::Huggingface,
1423 ProviderKind::Fireworks,
1424 ] {
1425 let mut body = json!({});
1426 apply_reasoning_effort(&mut body, Some("off"), provider);
1427
1428 assert_eq!(
1429 body,
1430 json!({}),
1431 "provider {provider:?} should not receive unsupported reasoning-off fields"
1432 );
1433 }
1434 }
1435 // from reasoning_effort_atlascloud_speaks_deepseek_dialect
1436 {
1437 let mut body = json!({});
1438 apply_reasoning_effort(&mut body, Some("high"), ProviderKind::Atlascloud);
1439 assert_eq!(
1440 body,
1441 json!({ "reasoning_effort": "high", "thinking": { "type": "enabled" } })
1442 );
1443
1444 let mut body = json!({});
1445 apply_reasoning_effort(&mut body, Some("max"), ProviderKind::Atlascloud);
1446 assert_eq!(
1447 body,
1448 json!({ "reasoning_effort": "max", "thinking": { "type": "enabled" } })
1449 );
1450
1451 let mut body = json!({});
1452 apply_reasoning_effort(&mut body, Some("off"), ProviderKind::Atlascloud);
1453 assert_eq!(body, json!({ "thinking": { "type": "disabled" } }));
1454 }
1455 // from reasoning_effort_modelstudio_writes_nothing_without_a_verified_route
1456 {
1457 // The provider enum cannot decide DashScope's controls: `enable_thinking`
1458 // is wrong for the thinking-only models, `reasoning_effort` is only
1459 // valid for DeepSeek-V4/GLM, and a custom `base_url` on any of these
1460 // identities is an arbitrary gateway. All four variants must therefore
1461 // leave the body untouched here — the route shaper in client::chat is
1462 // the sole writer.
1463 for provider in [
1464 ProviderKind::ModelstudioTokenPlan,
1465 ProviderKind::ModelstudioTokenPlanAnthropic,
1466 ProviderKind::ModelstudioCodingPlan,
1467 ProviderKind::ModelstudioCodingPlanAnthropic,
1468 ] {
1469 for effort in [None, Some("off"), Some("low"), Some("high"), Some("max")] {
1470 let mut body = json!({});
1471 apply_reasoning_effort(&mut body, effort, provider);
1472 assert_eq!(body, json!({}), "{provider:?} {effort:?}");
1473 }
1474 }
1475 }
1476 // from reasoning_effort_moonshot_toggles_thinking
1477 {
1478 let mut body = json!({});
1479 apply_reasoning_effort(&mut body, Some("high"), ProviderKind::Moonshot);
1480 assert_eq!(body, json!({ "thinking": { "type": "enabled" } }));
1481
1482 let mut body = json!({});
1483 apply_reasoning_effort(&mut body, Some("off"), ProviderKind::Moonshot);
1484 assert_eq!(body, json!({ "thinking": { "type": "disabled" } }));
1485 }
1486 // from reasoning_effort_edenai_does_not_guess_a_model_dialect
1487 {
1488 for effort in ["off", "low", "medium", "high", "max", "xhigh"] {
1489 let mut body = json!({});
1490 apply_reasoning_effort(&mut body, Some(effort), ProviderKind::Edenai);
1491 assert_eq!(body, json!({}), "unexpected Eden AI fields for {effort}");
1492 }
1493 }
1494 // from reasoning_effort_ollama_toggles_think_flag
1495 {
1496 let mut body = json!({});
1497 apply_reasoning_effort(&mut body, Some("high"), ProviderKind::Ollama);
1498 assert_eq!(body, json!({ "think": true }));
1499
1500 let mut body = json!({});
1501 apply_reasoning_effort(&mut body, Some("off"), ProviderKind::Ollama);
1502 assert_eq!(body, json!({ "think": false }));
1503 }
1504 }
1505
1506 /// First-party DeepSeek routes document `reasoning_effort` low/high/max on
1507 /// the wire (no medium): low is a real cheaper tier, medium rounds up to
1508 /// high (#52). Hosted DeepSeek-compatible routes keep the historic
1509 /// low/medium → high collapse because their own wire contracts are not
1510 /// verified here.
1511 #[test]
1512 fn stepfun_reasoning_effort_respects_each_model_wire_contract() {
1513 for (model, effort, expected) in [
1514 ("step-5-preview", "low", Some("low")),
1515 ("step-5-preview", "medium", Some("medium")),
1516 ("step-5-preview", "max", Some("high")),
1517 ("step-3.7-flash", "medium", Some("medium")),
1518 ("step-3.5-flash-2603", "medium", Some("high")),
1519 ("step-3.5-flash-2603", "low", Some("low")),
1520 ("step-3.5-flash", "high", None),
1521 ("step-5-preview", "off", None),
1522 ("step-5-preview", "auto", None),
1523 ("step-audio-2", "high", None),
1524 ] {
1525 let mut body = json!({"model": model});
1526 apply_reasoning_effort(&mut body, Some(effort), ProviderKind::Stepfun);
1527 assert_eq!(
1528 body.get("reasoning_effort").and_then(Value::as_str),
1529 expected,
1530 "{model}/{effort}"
1531 );
1532 assert!(body.get("thinking").is_none());
1533 }
1534 }
1535
1536 #[test]
1537 fn stepfun_discovery_excludes_non_coding_and_retired_models() {
1538 let models = parse_models_response(
1539 r#"{"data":[
1540 {"id":"step-5-preview"},{"id":"step-3.7-flash"},
1541 {"id":"step-3.5-flash"},{"id":"step-3.5-flash-2603"},
1542 {"id":"step-audio-2"},{"id":"step-tts-2"},
1543 {"id":"step-image-edit-2"},{"id":"step-2x-large"},{"id":"step-3"}
1544 ]}"#,
1545 )
1546 .unwrap();
1547 let filtered = apply_provider_model_cutline(ProviderKind::Stepfun, models);
1548 assert_eq!(
1549 filtered
1550 .iter()
1551 .map(|model| model.id.as_str())
1552 .collect::<Vec<_>>(),
1553 [
1554 "step-3.5-flash",
1555 "step-3.5-flash-2603",
1556 "step-3.7-flash",
1557 "step-5-preview"
1558 ]
1559 );
1560 }
1561
1562 #[test]
1563 fn reasoning_effort_deepseek_maps_the_documented_wire_ladder() {
1564 let mut body = json!({});
1565 apply_reasoning_effort(&mut body, Some("low"), ProviderKind::Deepseek);
1566 assert_eq!(
1567 body,
1568 json!({ "reasoning_effort": "low", "thinking": { "type": "enabled" } })
1569 );
1570
1571 let mut body = json!({});
1572 apply_reasoning_effort(&mut body, Some("medium"), ProviderKind::Deepseek);
1573 assert_eq!(
1574 body,
1575 json!({ "reasoning_effort": "high", "thinking": { "type": "enabled" } })
1576 );
1577
1578 for provider in [ProviderKind::Deepseek, ProviderKind::Deepseek] {
1579 let mut body = json!({});
1580 apply_reasoning_effort(&mut body, Some("high"), provider);
1581 assert_eq!(
1582 body,
1583 json!({ "reasoning_effort": "high", "thinking": { "type": "enabled" } }),
1584 "provider {provider:?}"
1585 );
1586 }
1587
1588 for provider in [ProviderKind::Siliconflow, ProviderKind::Deepinfra] {
1589 let mut body = json!({});
1590 apply_reasoning_effort(&mut body, Some("low"), provider);
1591 assert_eq!(
1592 body,
1593 json!({ "reasoning_effort": "high", "thinking": { "type": "enabled" } }),
1594 "hosted route {provider:?} keeps the collapse"
1595 );
1596 }
1597 }
1598
1598 lines RUST