返回 CodeWhale
test_cases_02.rs
根目录 / crates / tui / src / client / test_cases_02.rs
1
2 async fn assert_zai_request_truth(streaming: bool) {
3 for base_url in [
4 crate::config::DEFAULT_ZAI_BASE_URL,
5 "https://api.z.ai/api/paas/v4",
6 ] {
7 let (high_path, high) = capture_zai_chat_request(
8 base_url,
9 crate::config::ZAI_GLM_5_2_MODEL,
10 Some("high"),
11 streaming,
12 )
13 .await;
14 let (max_path, max) = capture_zai_chat_request(
15 base_url,
16 crate::config::ZAI_GLM_5_2_MODEL,
17 Some("max"),
18 streaming,
19 )
20 .await;
21 assert_eq!(high_path, "/v1/chat/completions");
22 assert_eq!(max_path, "/v1/chat/completions");
23 assert_eq!(high["reasoning_effort"], "high", "{base_url}: {high}");
24 assert_eq!(max["reasoning_effort"], "max", "{base_url}: {max}");
25 for body in [&high, &max] {
26 assert_eq!(
27 body["thinking"],
28 json!({"type": "enabled", "clear_thinking": false}),
29 "{base_url}: {body}"
30 );
31 assert_eq!(body["model"], crate::config::ZAI_GLM_5_2_MODEL);
32 }
33 let mut high_without_effort = high.clone();
34 let mut max_without_effort = max.clone();
35 high_without_effort
36 .as_object_mut()
37 .expect("object")
38 .remove("reasoning_effort");
39 max_without_effort
40 .as_object_mut()
41 .expect("object")
42 .remove("reasoning_effort");
43 assert_eq!(high_without_effort, max_without_effort);
44
45 for model in [
46 crate::config::ZAI_GLM_5_1_MODEL,
47 crate::config::ZAI_GLM_5_TURBO_MODEL,
48 ] {
49 for requested in ["high", "max"] {
50 let (_, toggle_only) =
51 capture_zai_chat_request(base_url, model, Some(requested), streaming).await;
52 assert!(
53 toggle_only.get("reasoning_effort").is_none(),
54 "{model}: {toggle_only}"
55 );
56 assert_eq!(
57 toggle_only["thinking"],
58 json!({"type": "enabled", "clear_thinking": false}),
59 "{model}: {toggle_only}"
60 );
61 }
62 }
63
64 let (_, unknown) =
65 capture_zai_chat_request(base_url, "glm-future-unknown", Some("max"), streaming).await;
66 assert!(unknown.get("reasoning_effort").is_none(), "{unknown}");
67 assert!(unknown.get("thinking").is_none(), "{unknown}");
68 }
69
70 let (_, gateway) = capture_zai_chat_request(
71 "https://gateway.example/v1",
72 crate::config::ZAI_GLM_5_2_MODEL,
73 Some("max"),
74 streaming,
75 )
76 .await;
77 assert!(gateway.get("reasoning_effort").is_none(), "{gateway}");
78 assert!(gateway.get("thinking").is_none(), "{gateway}");
79
80 let (_, gateway_turbo) = capture_zai_chat_request(
81 "https://gateway.example/v1",
82 crate::config::ZAI_GLM_5_TURBO_MODEL,
83 Some("max"),
84 streaming,
85 )
86 .await;
87 assert!(
88 gateway_turbo.get("reasoning_effort").is_none(),
89 "{gateway_turbo}"
90 );
91 assert!(gateway_turbo.get("thinking").is_none(), "{gateway_turbo}");
92 }
93
94 async fn assert_minimax_request_truth(streaming: bool) {
95 for base_url in [
96 crate::config::DEFAULT_MINIMAX_BASE_URL,
97 "https://api.minimaxi.com/v1",
98 ] {
99 for (effort, expected_thinking) in [
100 ("off", json!({"type": "disabled"})),
101 ("high", json!({"type": "adaptive"})),
102 ("max", json!({"type": "adaptive"})),
103 ] {
104 let (_, body) = capture_minimax_chat_request(
105 base_url,
106 crate::config::DEFAULT_MINIMAX_MODEL,
107 Some(effort),
108 streaming,
109 )
110 .await;
111 assert_eq!(
112 body["max_completion_tokens"], 64,
113 "{base_url} {effort}: {body}"
114 );
115 assert!(
116 body.get("max_tokens").is_none(),
117 "{base_url} {effort}: {body}"
118 );
119 assert_eq!(body["reasoning_split"], true, "{base_url}: {body}");
120 assert_eq!(
121 body["thinking"], expected_thinking,
122 "{base_url} {effort}: {body}"
123 );
124 }
125 }
126
127 for (base_url, model) in [
128 (crate::config::DEFAULT_MINIMAX_BASE_URL, "MiniMax-M2"),
129 (
130 "https://gateway.example/v1",
131 crate::config::DEFAULT_MINIMAX_MODEL,
132 ),
133 ] {
134 for effort in ["off", "high", "max"] {
135 let (_, body) =
136 capture_minimax_chat_request(base_url, model, Some(effort), streaming).await;
137 assert_eq!(
138 body["max_tokens"], 64,
139 "{base_url} {model} {effort}: {body}"
140 );
141 assert!(
142 body.get("max_completion_tokens").is_none(),
143 "{base_url} {model} {effort}: {body}"
144 );
145 assert!(
146 body.get("reasoning_split").is_none(),
147 "{base_url} {model} {effort}: {body}"
148 );
149 assert!(
150 body.get("thinking").is_none(),
151 "{base_url} {model} {effort}: {body}"
152 );
153 }
154 }
155 }
156
157 async fn assert_k3_request_json_route_boundaries(streaming: bool) {
158 for (requested, expected) in [("off", "low"), ("high", "high"), ("max", "max")] {
159 let body = capture_moonshot_chat_request(
160 crate::config::DEFAULT_MOONSHOT_BASE_URL,
161 crate::config::MOONSHOT_KIMI_K3_MODEL,
162 Some(requested),
163 streaming,
164 )
165 .await;
166 assert_eq!(body["reasoning_effort"], json!(expected), "{body}");
167 assert!(body.get("thinking").is_none(), "{body}");
168 assert_eq!(body["max_completion_tokens"], json!(64), "{body}");
169 assert!(body.get("max_tokens").is_none(), "{body}");
170 assert!(body.get("temperature").is_none(), "{body}");
171 assert!(body.get("top_p").is_none(), "{body}");
172 assert_eq!(
173 body.get("stream").and_then(Value::as_bool),
174 streaming.then_some(true)
175 );
176 }
177
178 for (requested, expected) in [
179 ("off", Some(json!({"type": "enabled", "effort": "low"}))),
180 ("max", Some(json!({"type": "enabled", "effort": "max"}))),
181 ] {
182 let membership = capture_moonshot_chat_request(
183 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
184 crate::config::KIMI_CODE_K3_MODEL,
185 Some(requested),
186 streaming,
187 )
188 .await;
189 match expected {
190 Some(thinking) => assert_eq!(membership["thinking"], thinking, "{membership}"),
191 None => assert!(membership.get("thinking").is_none(), "{membership}"),
192 }
193 assert!(membership.get("reasoning_effort").is_none(), "{membership}");
194 assert_eq!(membership["max_tokens"], json!(64), "{membership}");
195 assert!(
196 membership.get("max_completion_tokens").is_none(),
197 "{membership}"
198 );
199 // Kimi Code's documented membership models own their sampling
200 // behavior: the exact first-party membership route strips generic
201 // controls (apply_kimi_code_fixed_sampling).
202 assert!(membership.get("temperature").is_none(), "{membership}");
203 assert!(membership.get("top_p").is_none(), "{membership}");
204 }
205
206 let provider_default = capture_moonshot_chat_request(
207 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
208 crate::config::KIMI_CODE_K3_MODEL,
209 None,
210 streaming,
211 )
212 .await;
213 assert!(
214 provider_default.get("thinking").is_none(),
215 "only a genuinely omitted effort leaves the provider default in control: {provider_default}"
216 );
217 assert!(provider_default.get("reasoning_effort").is_none());
218
219 let neighbor = capture_moonshot_chat_request(
220 "https://proxy.example/v1",
221 crate::config::MOONSHOT_KIMI_K3_MODEL,
222 Some("max"),
223 streaming,
224 )
225 .await;
226 assert_eq!(
227 neighbor["thinking"],
228 json!({"type": "enabled"}),
229 "{neighbor}"
230 );
231 assert!(neighbor.get("reasoning_effort").is_none(), "{neighbor}");
232 assert!(neighbor.pointer("/thinking/effort").is_none(), "{neighbor}");
233 assert_eq!(neighbor["max_tokens"], json!(64), "{neighbor}");
234 assert!(
235 neighbor.get("max_completion_tokens").is_none(),
236 "{neighbor}"
237 );
238 assert_eq!(neighbor["temperature"], json!(0.25), "{neighbor}");
239 assert_eq!(neighbor["top_p"], json!(0.75), "{neighbor}");
240 }
241
242 async fn assert_kimi_code_raw_off_replays_tool_history(streaming: bool) {
243 let mut request = k3_request_fixture(crate::config::KIMI_CODE_K3_MODEL, Some("off"), streaming);
244 request.messages = vec![
245 Message {
246 role: Role::Assistant,
247 content: vec![
248 ContentBlock::Thinking {
249 thinking: "Inspect the saved tool state".to_string(),
250 signature: None,
251 state: None,
252 },
253 ContentBlock::ToolUse {
254 execution_id: None,
255 id: "call-k3-replay".to_string(),
256 name: "read_file".to_string(),
257 input: json!({"path": "src/lib.rs"}),
258 caller: None,
259 thought_signature: None,
260 },
261 ],
262 },
263 Message {
264 role: Role::User,
265 content: vec![ContentBlock::ToolResult {
266 execution_id: None,
267 tool_use_id: "call-k3-replay".to_string(),
268 content: "file contents".to_string(),
269 is_error: None,
270 content_blocks: None,
271 }],
272 },
273 ];
274
275 let body = capture_moonshot_chat_request_body(
276 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
277 crate::config::KIMI_CODE_K3_MODEL,
278 request,
279 )
280 .await;
281 assert_eq!(
282 body["thinking"],
283 json!({"type": "enabled", "effort": "low"}),
284 "raw Off must still normalize to K3's always-thinking low tier: {body}"
285 );
286 let assistant = body["messages"]
287 .as_array()
288 .and_then(|messages| {
289 messages
290 .iter()
291 .find(|message| message["role"] == "assistant")
292 })
293 .expect("captured assistant tool-call history");
294 assert_eq!(
295 assistant["reasoning_content"],
296 json!("Inspect the saved tool state"),
297 "exact membership K3 must replay reasoning even for a stale raw Off caller: {body}"
298 );
299 assert!(assistant["tool_calls"].is_array(), "{assistant}");
300 }
301
302 async fn assert_kimi_code_apply_patch_schema_is_mfjs_compatible(streaming: bool) {
303 let mut request = k3_request_fixture(crate::config::KIMI_CODE_K3_MODEL, Some("low"), streaming);
304 request.tools = Some(vec![apply_patch_request_tool()]);
305
306 let body = capture_moonshot_chat_request_body(
307 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
308 crate::config::KIMI_CODE_K3_MODEL,
309 request,
310 )
311 .await;
312 let function = &body["tools"][0]["function"];
313 let parameters = &function["parameters"];
314 assert_eq!(parameters["type"], "object", "{parameters}");
315 assert!(parameters.get("oneOf").is_none(), "{parameters}");
316 assert!(parameters.get("anyOf").is_none(), "{parameters}");
317 assert!(parameters.get("allOf").is_none(), "{parameters}");
318 assert_eq!(parameters["properties"]["patch"]["type"], "string");
319 assert_eq!(parameters["properties"]["replace"]["type"], "array");
320 assert_eq!(parameters["properties"]["changes"]["type"], "array");
321 assert!(
322 function["description"]
323 .as_str()
324 .is_some_and(|description| description
325 .contains("Exactly one of these parameter groups must be provided")),
326 "the relaxed wire schema must preserve the runtime constraint in its description: {function}"
327 );
328 }
329
330 // Per-tool degradation regression: the old behavior failed the whole
331 // request before transport when any tool's parameters failed MFJS
332 // validation; now only the incompatible tool is dropped from the wire
333 // body and the request still sends, so one bad MCP server cannot sink
334 // every Moonshot-routed turn.
335 async fn assert_kimi_code_invalid_root_ref_drops_only_that_tool(streaming: bool) {
336 let mut request = k3_request_fixture(crate::config::KIMI_CODE_K3_MODEL, Some("low"), streaming);
337 let mut tool = test_tool("private_schema_tool");
338 tool.input_schema = json!({
339 "$ref": "#/$defs/private-root-name-3158",
340 "$defs": {}
341 });
342 request.tools = Some(vec![tool]);
343 request.tool_choice = Some(json!("auto"));
344
345 let body = capture_moonshot_chat_request_body(
346 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
347 crate::config::KIMI_CODE_K3_MODEL,
348 request,
349 )
350 .await;
351 assert!(
352 body.get("tools").is_none(),
353 "the only tool was dropped, so the wire body must omit tools entirely: {body}"
354 );
355 assert!(
356 body.get("tool_choice").is_none(),
357 "tool_choice must not be sent when every tool was dropped: {body}"
358 );
359 assert!(
360 !body.to_string().contains("private-root-name-3158"),
361 "the wire body must not leak the private $ref value: {body}"
362 );
363 }
364
365 async fn assert_kimi_code_untyped_default_drops_only_that_tool(streaming: bool) {
366 let mut request = k3_request_fixture(crate::config::KIMI_CODE_K3_MODEL, Some("low"), streaming);
367 let mut tool = test_tool("private_default_tool");
368 tool.input_schema = json!({
369 "type": "object",
370 "properties": {
371 "private-field-4401": {
372 "default": "private-default-value-4402"
373 }
374 }
375 });
376 request.tools = Some(vec![tool]);
377
378 let body = capture_moonshot_chat_request_body(
379 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
380 crate::config::KIMI_CODE_K3_MODEL,
381 request,
382 )
383 .await;
384 assert!(
385 body.get("tools").is_none(),
386 "the only tool was dropped, so the wire body must omit tools entirely: {body}"
387 );
388 assert!(
389 body.get("tool_choice").is_none(),
390 "tool_choice must not be sent when every tool was dropped: {body}"
391 );
392 assert!(!body.to_string().contains("private-field-4401"));
393 assert!(!body.to_string().contains("private-default-value-4402"));
394 }
395
396 async fn assert_kimi_code_streams_mfjs_safe_deferred_dynamic_tool() {
397 let tool = deferred_dynamic_request_tool();
398 assert_eq!(tool.defer_loading, Some(true));
399 assert_eq!(
400 tool.input_schema["properties"]["query"]["nullable"], true,
401 "ToolRegistry must exercise the provider-neutral nullable collapse"
402 );
403 assert!(
404 tool.input_schema["properties"]["query"]
405 .get("anyOf")
406 .is_none()
407 );
408 assert_eq!(tool.input_schema["properties"]["mode"]["const"], "fast");
409
410 let mut request = k3_request_fixture(crate::config::KIMI_CODE_K3_MODEL, Some("low"), true);
411 request.tools = Some(vec![tool]);
412 let body = capture_moonshot_chat_request_body(
413 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
414 crate::config::KIMI_CODE_K3_MODEL,
415 request,
416 )
417 .await;
418
419 assert_eq!(
420 body["stream"], true,
421 "this must exercise the SSE path: {body}"
422 );
423 let parameters = &captured_function(&body, "deferred_lookup")["parameters"];
424 assert_eq!(parameters["properties"]["mode"]["enum"], json!(["fast"]));
425 assert!(
426 parameters["properties"]["mode"].get("const").is_none(),
427 "{parameters}"
428 );
429 assert_eq!(
430 parameters["properties"]["query"]["anyOf"],
431 json!([{"type": "string"}, {"type": "null"}])
432 );
433 assert!(
434 parameters["properties"]["query"].get("nullable").is_none(),
435 "{parameters}"
436 );
437 crate::tools::schema_sanitize::validate_mfjs_parameters(parameters).unwrap();
438 }
439
440 async fn assert_kimi_code_captures_exact_general_child_catalog() {
441 let tools = crate::tools::subagent::kimi_general_child_request_tools_fixture();
442 let source_len = tools.len();
443 let source_names = tools
444 .iter()
445 .map(|tool| tool.name.clone())
446 .collect::<std::collections::BTreeSet<_>>();
447 let expected_names = crate::core::engine::default_active_native_tool_names()
448 .iter()
449 .copied()
450 // Children can inspect the shared goal, but only its owning
451 // session can create it or change its completion state.
452 .filter(|name| !matches!(*name, "create_goal" | "update_goal"))
453 .chain([crate::core::engine::tool_catalog::TOOL_SEARCH_NAME])
454 .map(str::to_string)
455 .collect();
456 assert_eq!(source_names, expected_names);
457 assert!(source_names.contains("get_goal"));
458 assert!(!source_names.contains("create_goal"));
459 assert!(!source_names.contains("update_goal"));
460
461 // Name the offending first-party tool in test-only diagnostics while
462 // production errors remain fixed and non-secret.
463 for tool in &tools {
464 let mut parameters = tool.input_schema.clone();
465 crate::tools::schema_sanitize::sanitize_for_kimi_parameters(&mut parameters)
466 .unwrap_or_else(|error| panic!("General child tool {}: {error}", tool.name));
467 }
468
469 let mut request = k3_request_fixture(crate::config::KIMI_CODE_K3_MODEL, Some("low"), false);
470 request.tools = Some(tools);
471 let body = capture_moonshot_chat_request_body(
472 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
473 crate::config::KIMI_CODE_K3_MODEL,
474 request,
475 )
476 .await;
477
478 let captured = body["tools"].as_array().expect("captured tool catalog");
479 assert_eq!(captured.len(), source_len);
480 for required in &source_names {
481 assert!(
482 captured_function(&body, required).is_object(),
483 "{required} must reach the Kimi Code wire"
484 );
485 }
486 assert!(
487 captured
488 .iter()
489 .all(|tool| tool["function"]["name"] != "create_goal")
490 );
491 assert!(
492 captured
493 .iter()
494 .all(|tool| tool["function"]["name"] != "update_goal")
495 );
496
497 for tool in captured {
498 let parameters = &tool["function"]["parameters"];
499 for unsupported in ["const", "nullable", "oneOf", "allOf"] {
500 assert!(
501 !value_contains_key(parameters, unsupported),
502 "captured {} still contains {unsupported}: {parameters}",
503 tool["function"]["name"]
504 );
505 }
506 crate::tools::schema_sanitize::validate_mfjs_parameters(parameters).unwrap();
507 }
508 }
509
510 #[tokio::test]
511 async fn create_message_scenario() {
512 // Scenario consolidation of: create_message_request_json_honors_exact_k3_route_boundaries, create_message_stream_request_json_honors_exact_k3_route_boundaries, create_message_request_json_keeps_zai_effort_route_exact, create_message_stream_request_json_keeps_zai_effort_route_exact, create_message_request_json_keeps_minimax_token_dialect_exact, create_message_stream_request_json_keeps_minimax_token_dialect_exact, create_message_request_json_keeps_modelstudio_enable_thinking_exact, create_message_stream_request_json_keeps_modelstudio_enable_thinking_exact
513 // from create_message_request_json_honors_exact_k3_route_boundaries
514 {
515 assert_k3_request_json_route_boundaries(false).await;
516 }
517 // from create_message_stream_request_json_honors_exact_k3_route_boundaries
518 {
519 assert_k3_request_json_route_boundaries(true).await;
520 }
521 // from create_message_request_json_keeps_zai_effort_route_exact
522 {
523 assert_zai_request_truth(false).await;
524 }
525 // from create_message_stream_request_json_keeps_zai_effort_route_exact
526 {
527 assert_zai_request_truth(true).await;
528 }
529 // from create_message_request_json_keeps_minimax_token_dialect_exact
530 {
531 assert_minimax_request_truth(false).await;
532 }
533 // from create_message_stream_request_json_keeps_minimax_token_dialect_exact
534 {
535 assert_minimax_request_truth(true).await;
536 }
537 // from create_message_request_json_keeps_modelstudio_enable_thinking_exact
538 {
539 assert_modelstudio_request_truth(false).await;
540 }
541 // from create_message_stream_request_json_keeps_modelstudio_enable_thinking_exact
542 {
543 assert_modelstudio_request_truth(true).await;
544 }
545 }
546
547 #[tokio::test]
548 async fn kimi_code_compaction_shape_omits_sampling_parameters_on_wire() {
549 for model in crate::config::KIMI_CODE_MEMBERSHIP_MODELS {
550 let mut request = k3_request_fixture(model, None, /*stream*/ false);
551 request.temperature = Some(0.3);
552 request.top_p = Some(0.8);
553 let body = capture_moonshot_chat_request_body(
554 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
555 model,
556 request,
557 )
558 .await;
559
560 assert_eq!(body["model"], model);
561 assert!(body.get("temperature").is_none(), "{model}: {body}");
562 assert!(body.get("top_p").is_none(), "{model}: {body}");
563 }
564 }
565
566 /// v0.9.1 kimi-k3 dogfood report: the id the user selects has to be the id on the wire. A
567 /// dogfood user selecting `kimi-k3` was served `kimi-k2.7-code`, so this
568 /// asserts the wire `model` field for each K3 product on its own endpoint,
569 /// and that neither one's request carries the other's id.
570 #[tokio::test]
571 async fn selected_moonshot_k3_model_is_the_model_on_the_wire() {
572 let platform = capture_moonshot_chat_request(
573 crate::config::DEFAULT_MOONSHOT_BASE_URL,
574 crate::config::MOONSHOT_KIMI_K3_MODEL,
575 Some("high"),
576 false,
577 )
578 .await;
579 assert_eq!(
580 platform["model"],
581 json!(crate::config::MOONSHOT_KIMI_K3_MODEL),
582 "the direct platform route must send the id the user named: {platform}"
583 );
584 assert_ne!(
585 platform["model"],
586 json!(crate::config::DEFAULT_MOONSHOT_MODEL),
587 "an explicit selection is never replaced by the provider default: {platform}"
588 );
589 assert_ne!(
590 platform["model"],
591 json!(crate::config::KIMI_CODE_K3_MODEL),
592 "the coding-plan id must not leak onto the platform route: {platform}"
593 );
594
595 let membership = capture_moonshot_chat_request(
596 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
597 crate::config::KIMI_CODE_K3_MODEL,
598 Some("high"),
599 false,
600 )
601 .await;
602 assert_eq!(
603 membership["model"],
604 json!(crate::config::KIMI_CODE_K3_MODEL),
605 "the Kimi Code membership route must send bare `k3`: {membership}"
606 );
607 assert_ne!(
608 membership["model"],
609 json!(crate::config::MOONSHOT_KIMI_K3_MODEL),
610 "the platform id must not leak onto the coding-plan route: {membership}"
611 );
612 }
613
614 #[tokio::test]
615 async fn create_message_scenario_2() {
616 // Scenario consolidation of: create_message_routes_only_strict_deepseek_tools_to_beta, create_message_stream_routes_only_strict_deepseek_tools_to_beta, create_message_request_replays_kimi_code_history_for_raw_off, create_message_stream_replays_kimi_code_history_for_raw_off, create_message_request_sends_mfjs_compatible_apply_patch_schema, create_message_stream_sends_mfjs_compatible_apply_patch_schema, create_message_request_drops_invalid_kimi_root_ref_tool, create_message_stream_drops_invalid_kimi_root_ref_tool
617 // from create_message_routes_only_strict_deepseek_tools_to_beta
618 {
619 assert_deepseek_strict_request_route_boundary(false).await;
620 }
621 // from create_message_stream_routes_only_strict_deepseek_tools_to_beta
622 {
623 assert_deepseek_strict_request_route_boundary(true).await;
624 }
625 // from create_message_request_replays_kimi_code_history_for_raw_off
626 {
627 assert_kimi_code_raw_off_replays_tool_history(false).await;
628 }
629 // from create_message_stream_replays_kimi_code_history_for_raw_off
630 {
631 assert_kimi_code_raw_off_replays_tool_history(true).await;
632 }
633 // from create_message_request_sends_mfjs_compatible_apply_patch_schema
634 {
635 assert_kimi_code_apply_patch_schema_is_mfjs_compatible(false).await;
636 }
637 // from create_message_stream_sends_mfjs_compatible_apply_patch_schema
638 {
639 assert_kimi_code_apply_patch_schema_is_mfjs_compatible(true).await;
640 }
641 // from create_message_request_drops_invalid_kimi_root_ref_tool
642 {
643 assert_kimi_code_invalid_root_ref_drops_only_that_tool(false).await;
644 }
645 // from create_message_stream_drops_invalid_kimi_root_ref_tool
646 {
647 assert_kimi_code_invalid_root_ref_drops_only_that_tool(true).await;
648 }
649 }
650
651 #[tokio::test]
652 async fn create_message_scenario_3() {
653 // Scenario consolidation of: create_message_request_drops_untyped_kimi_default_tool, create_message_stream_drops_untyped_kimi_default_tool, create_message_stream_sends_mfjs_safe_deferred_dynamic_tool, create_message_captures_exact_mfjs_safe_general_child_catalog
654 // from create_message_request_drops_untyped_kimi_default_tool
655 {
656 assert_kimi_code_untyped_default_drops_only_that_tool(false).await;
657 }
658 // from create_message_stream_drops_untyped_kimi_default_tool
659 {
660 assert_kimi_code_untyped_default_drops_only_that_tool(true).await;
661 }
662 // from create_message_stream_sends_mfjs_safe_deferred_dynamic_tool
663 {
664 assert_kimi_code_streams_mfjs_safe_deferred_dynamic_tool().await;
665 }
666 // from create_message_captures_exact_mfjs_safe_general_child_catalog
667 {
668 assert_kimi_code_captures_exact_general_child_catalog().await;
669 }
670 }
671
672 #[tokio::test]
673 async fn moonshot_drops_only_incompatible_tool() {
674 for streaming in [false, true] {
675 let mut request =
676 k3_request_fixture(crate::config::KIMI_CODE_K3_MODEL, Some("low"), streaming);
677 let good = test_tool("compatible_lookup");
678 let mut bad = test_tool("mcp_pattern_tool");
679 bad.input_schema = json!({
680 "type": "object",
681 "properties": {
682 "query": {"type": "string", "pattern": "^private-regex-7701$"}
683 }
684 });
685 request.tools = Some(vec![good, bad]);
686 request.tool_choice = Some(json!("auto"));
687
688 let body = capture_moonshot_chat_request_body(
689 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
690 crate::config::KIMI_CODE_K3_MODEL,
691 request,
692 )
693 .await;
694 let tools = body["tools"]
695 .as_array()
696 .expect("compatible tool must stay on the wire");
697 assert_eq!(
698 tools.len(),
699 1,
700 "only the incompatible tool may be dropped (streaming={streaming}): {body}"
701 );
702 assert_eq!(tools[0]["function"]["name"], "compatible_lookup");
703 assert_eq!(
704 body["tool_choice"],
705 json!("auto"),
706 "tool_choice survives while any tool remains: {body}"
707 );
708 assert!(
709 !body.to_string().contains("private-regex-7701"),
710 "the dropped tool's private schema values must not reach the wire: {body}"
711 );
712 }
713 }
714
715 #[tokio::test]
716 async fn moonshot_rejects_named_choice_for_omitted_tool() {
717 for streaming in [false, true] {
718 let server = MockServer::start().await;
719 let client = moonshot_request_boundary_client(
720 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
721 crate::config::KIMI_CODE_K3_MODEL,
722 server.uri(),
723 );
724 let mut request =
725 k3_request_fixture(crate::config::KIMI_CODE_K3_MODEL, Some("low"), streaming);
726 let good = test_tool("compatible_lookup");
727 let mut bad = test_tool("mcp_pattern_tool");
728 bad.input_schema = json!({
729 "type": "object",
730 "properties": {
731 "query": {"type": "string", "pattern": "^private-regex-8801$"}
732 }
733 });
734 request.tools = Some(vec![good, bad]);
735 request.tool_choice = Some(json!({
736 "type": "tool",
737 "name": "mcp_pattern_tool"
738 }));
739
740 let error = if streaming {
741 match client.create_message_stream(request).await {
742 Ok(_) => {
743 panic!("a named choice for an omitted tool must fail before transport")
744 }
745 Err(error) => error,
746 }
747 } else {
748 match client.create_message(request).await {
749 Ok(_) => {
750 panic!("a named choice for an omitted tool must fail before transport")
751 }
752 Err(error) => error,
753 }
754 };
755 let diagnostic = error.to_string();
756 assert!(
757 diagnostic.contains("cannot force tool 'mcp_pattern_tool'"),
758 "streaming={streaming}: {diagnostic}"
759 );
760 assert!(
761 !diagnostic.contains("private-regex-8801"),
762 "schema values must not leak into the user-visible diagnostic: {diagnostic}"
763 );
764 assert!(
765 server
766 .received_requests()
767 .await
768 .expect("request log")
769 .is_empty(),
770 "dangling named tool_choice must fail locally (streaming={streaming})"
771 );
772 }
773 }
774
775 #[tokio::test]
776 async fn moonshot_stream_emits_one_projection_warning() {
777 let server = MockServer::start().await;
778 Mock::given(method("POST"))
779 .and(path("/v1/chat/completions"))
780 .respond_with(
781 ResponseTemplate::new(200)
782 .insert_header("content-type", "text/event-stream")
783 .set_body_string("data: [DONE]\n\n"),
784 )
785 .expect(1)
786 .mount(&server)
787 .await;
788 let client = moonshot_request_boundary_client(
789 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
790 crate::config::KIMI_CODE_K3_MODEL,
791 server.uri(),
792 );
793 let mut request = k3_request_fixture(crate::config::KIMI_CODE_K3_MODEL, Some("low"), true);
794 let good = test_tool("compatible_lookup");
795 let mut bad = test_tool("mcp_pattern_tool");
796 bad.input_schema = json!({
797 "type": "object",
798 "properties": {
799 "query": {"type": "string", "pattern": "^private-regex-9901$"}
800 }
801 });
802 request.tools = Some(vec![good, bad]);
803 request.tool_choice = Some(json!("auto"));
804
805 let mut stream = client
806 .create_message_stream(request)
807 .await
808 .expect("compatible tools keep the request sendable");
809 let first = stream
810 .next()
811 .await
812 .expect("projection warning precedes provider SSE")
813 .expect("projection warning is not a stream error");
814 let (provider, omitted_tool_names, omitted_tool_count) = match first {
815 codewhale_models::StreamEvent::ToolProjectionWarning {
816 provider,
817 omitted_tool_names,
818 omitted_tool_count,
819 } => (provider, omitted_tool_names, omitted_tool_count),
820 other => panic!("first event must be the projection warning, got {other:?}"),
821 };
822 assert!(provider.contains("Moonshot"), "{provider}");
823 assert_eq!(omitted_tool_names, vec!["mcp_pattern_tool"]);
824 assert_eq!(omitted_tool_count, 1);
825
826 let mut additional_warnings = 0;
827 while let Some(event) = stream.next().await {
828 if matches!(
829 event.expect("captured SSE response remains valid"),
830 codewhale_models::StreamEvent::ToolProjectionWarning { .. }
831 ) {
832 additional_warnings += 1;
833 }
834 }
835 assert_eq!(
836 additional_warnings, 0,
837 "warning must be emitted once per request"
838 );
839
840 let requests = server.received_requests().await.expect("recorded request");
841 assert_eq!(requests.len(), 1);
842 let body: Value = serde_json::from_slice(&requests[0].body).expect("request JSON");
843 assert_eq!(body["tools"].as_array().map(Vec::len), Some(1));
844 assert!(
845 !body.to_string().contains("private-regex-9901"),
846 "omitted schema values must not reach the wire: {body}"
847 );
848 }
849
850 /// #6528 — a rejected key names the route, host, key source and the
851 /// command that replaces it; an unknown model names the route and host.
852 #[test]
853 fn auth_and_unknown_model_errors_name_route_host_and_key_source() {
854 let _ = rustls::crypto::ring::default_provider().install_default();
855 let config = Config {
856 provider: Some("openrouter".to_string()),
857 providers: Some(ProvidersConfig {
858 openrouter: ProviderConfig {
859 api_key: Some("or-rejected-key-1234567890".to_string()),
860 ..ProviderConfig::default()
861 },
862 ..ProvidersConfig::default()
863 }),
864 ..Config::default()
865 };
866 let client = CodewhaleClient::new(&config).expect("openrouter client");
867 let auth = client
868 .http_error_with_route_context(401, "Invalid API key", None)
869 .to_string();
870 assert!(auth.contains("provider: openrouter"), "{auth}");
871 assert!(auth.contains("openrouter.ai"), "{auth}");
872 assert!(auth.contains("key source: config file"), "{auth}");
873 assert!(
874 auth.contains("fix: codewhale auth set --provider openrouter"),
875 "{auth}"
876 );
877 assert!(!auth.contains("or-rejected-key-1234567890"), "{auth}");
878
879 let model = client
880 .http_error_with_route_context(404, "model not found: nope", None)
881 .to_string();
882 assert!(model.contains("provider route: openrouter"), "{model}");
883 assert!(model.contains("host: openrouter.ai"), "{model}");
884 }
885
886 /// A key route gets no subscription guidance; a sign-in route's plan
887 /// limit names the account label and the switch command, never a token.
888 #[test]
889 fn subscription_quota_errors_name_account_and_switch_command() {
890 let _ = rustls::crypto::ring::default_provider().install_default();
891 let config = Config {
892 provider: Some("openrouter".to_string()),
893 providers: Some(ProvidersConfig {
894 openrouter: ProviderConfig {
895 api_key: Some("or-quota-key-1234567890".to_string()),
896 ..ProviderConfig::default()
897 },
898 ..ProvidersConfig::default()
899 }),
900 ..Config::default()
901 };
902 let mut client = CodewhaleClient::new(&config).expect("openrouter client");
903 assert_eq!(client.subscription_limit_guidance, None);
904 let body =
905 r#"{"error":{"type":"usage_limit_reached","message":"The usage limit has been reached"}}"#;
906 let plain = client.http_error_with_route_context(429, body, None);
907 assert!(matches!(plain, LlmError::QuotaExhausted(_)), "{plain:?}");
908 assert!(!plain.to_string().contains("codewhale auth"), "{plain}");
909
910 client.subscription_limit_guidance = Some(crate::oauth::usage_limit_guidance(
911 crate::oauth::OAuthProvider::Chatgpt,
912 Some("a@example.com (plus)"),
913 ));
914 let guided = client
915 .http_error_with_route_context(429, body, None)
916 .to_string();
917 assert!(
918 guided.contains("The usage limit has been reached"),
919 "{guided}"
920 );
921 assert!(
922 guided.contains("ChatGPT account a@example.com (plus)"),
923 "{guided}"
924 );
925 assert!(
926 guided.contains("CODEWHALE_CHATGPT_NEW_ACCOUNT=1 codewhale auth chatgpt"),
927 "{guided}"
928 );
929 assert!(!guided.contains("or-quota-key-1234567890"), "{guided}");
930 // Ordinary rate limits stay retryable and unannotated.
931 let rate = client.http_error_with_route_context(429, "Too Many Requests", None);
932 assert!(rate.is_retryable());
933 assert!(!rate.to_string().contains("codewhale auth"), "{rate}");
934 }
935
936 /// `auth_mode = "oauth"` alone does not mean a sign-in made the request:
937 /// with no usable OAuth credential the resolver falls through to the API
938 /// key, and a credit error must not blame (or name) a sign-in.
939 #[test]
940 fn xai_oauth_mode_that_fell_back_to_an_api_key_gets_no_sign_in_guidance() {
941 let _ = rustls::crypto::ring::default_provider().install_default();
942 let _env = crate::test_support::lock_test_env();
943 let home = tempfile::tempdir().expect("temp home");
944 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
945 let _key = crate::test_support::EnvVarGuard::remove("XAI_API_KEY");
946 let _base = crate::test_support::EnvVarGuard::remove("XAI_BASE_URL");
947 let config = Config {
948 provider: Some("xai".to_string()),
949 providers: Some(ProvidersConfig {
950 xai: ProviderConfig {
951 api_key: Some("xai-fallback-key-1234567890".to_string()),
952 auth_mode: Some("oauth".to_string()),
953 ..ProviderConfig::default()
954 },
955 ..ProvidersConfig::default()
956 }),
957 ..Config::default()
958 };
959 let client = CodewhaleClient::new(&config).expect("xai client");
960 assert_ne!(client.api_key_source, crate::config::XAI_OAUTH_KEY_SOURCE);
961 assert_eq!(client.subscription_limit_guidance, None);
962 let body =
963 r#"{"error":{"code":"credit_balance_exhausted","message":"credit balance exhausted"}}"#;
964 let error = client
965 .http_error_with_route_context(402, body, None)
966 .to_string();
967 assert!(!error.contains("codewhale auth"), "{error}");
968 }
969
970 fn test_id_token(email: &str) -> String {
971 use base64::Engine as _;
972 format!(
973 "header.{}.sig",
974 base64::engine::general_purpose::URL_SAFE_NO_PAD
975 .encode(json!({ "email": email }).to_string())
976 )
977 }
978
979 /// The official ChatGPT route names the selected owned grant even when a
980 /// legacy external login is present and consented for import.
981 #[test]
982 fn chatgpt_quota_guidance_names_owned_grant_and_ignores_legacy_import() {
983 let _ = rustls::crypto::ring::default_provider().install_default();
984 let _env = crate::test_support::lock_test_env();
985 let home = tempfile::tempdir().expect("temp home");
986 let root = home.path().canonicalize().expect("canonical temp root");
987 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &root);
988 let _access = crate::test_support::EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
989 let _legacy_access = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
990 let path = root.join("legacy-auth.json");
991 std::fs::write(
992 &path,
993 serde_json::to_vec(&json!({"tokens": {
994 "access_token": crate::test_support::future_test_jwt("legacy-import"),
995 "id_token": test_id_token("legacy@example.com"),
996 }}))
997 .unwrap(),
998 )
999 .unwrap();
1000 let _auth_path = crate::test_support::EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &path);
1001 let mut config = Config {
1002 provider: Some(ProviderKind::OpenaiCodex.as_str().to_string()),
1003 providers: Some(ProvidersConfig {
1004 openai_codex: ProviderConfig {
1005 external_credentials: Some(
1006 codewhale_config::ExternalCredentialConsentToml::read_only(
1007 codewhale_config::ProviderKind::OpenaiCodex,
1008 codewhale_config::ExternalCredentialSource::CodexCli,
1009 path,
1010 ),
1011 ),
1012 ..ProviderConfig::default()
1013 },
1014 ..ProvidersConfig::default()
1015 }),
1016 ..Config::default()
1017 };
1018 assert!(
1019 CodewhaleClient::new(&config).is_err(),
1020 "an import cannot authorize the official route"
1021 );
1022 let config_path = root.join("config.toml");
1023 std::fs::write(&config_path, "").unwrap();
1024 // Stored verified-grant fixture; OAuth signature validation has its
1025 // own signed-token boundary tests.
1026 crate::oauth::activate_login(
1027 crate::oauth::pending_login_with_id_token_for_test(
1028 crate::oauth::OAuthProvider::Chatgpt,
1029 "own-verified-access",
1030 "own-verified-refresh",
1031 Some(&test_id_token("owned@example.com")),
1032 ),
1033 Some(&config_path),
1034 Some(&mut config),
1035 )
1036 .unwrap();
1037 let client = CodewhaleClient::new(&config).expect("official owned ChatGPT client");
1038 assert_eq!(client.api_key, "own-verified-access");
1039 let guidance = client.subscription_limit_guidance.as_deref().unwrap();
1040 assert!(
1041 guidance.contains("ChatGPT account owned@example.com"),
1042 "{guidance}"
1043 );
1044 assert!(!guidance.contains("legacy@example.com"), "{guidance}");
1045 let error = client.http_error_with_route_context(
1046 429,
1047 r#"{"error":{"code":"subscription_sharing_usage_limit_exceeded"}}"#,
1048 None,
1049 );
1050 assert!(matches!(error, LlmError::QuotaExhausted(_)));
1051 assert!(!error.is_retryable());
1052 assert!(error.to_string().contains("owned@example.com"));
1053 assert!(!error.to_string().contains("own-verified-access"));
1054 }
1055
1056 /// An xAI OAuth route names the signed-in account from the credential
1057 /// the client sends; the resolver, the guidance and the picker's
1058 /// credential source share that one read.
1059 #[test]
1060 fn xai_oauth_quota_guidance_names_the_signed_in_account() {
1061 let _ = rustls::crypto::ring::default_provider().install_default();
1062 let _env = crate::test_support::lock_test_env();
1063 let home = tempfile::tempdir().expect("temp home");
1064 let root = home.path().canonicalize().expect("canonical temp root");
1065 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &root);
1066 let _key = crate::test_support::EnvVarGuard::remove("XAI_API_KEY");
1067 let _base = crate::test_support::EnvVarGuard::remove("XAI_BASE_URL");
1068 let config_path = root.join("config.toml");
1069 std::fs::write(&config_path, "").expect("empty config");
1070 let mut config = Config {
1071 provider: Some("xai".to_string()),
1072 ..Config::default()
1073 };
1074 crate::oauth::activate_login(
1075 crate::oauth::pending_login_with_id_token_for_test(
1076 crate::oauth::OAuthProvider::Xai,
1077 "xai-oauth-access",
1078 "xai-oauth-refresh",
1079 Some(&test_id_token("grok@example.com")),
1080 ),
1081 Some(&config_path),
1082 Some(&mut config),
1083 )
1084 .expect("xAI login");
1085 let client = CodewhaleClient::new(&config).expect("xai client");
1086 assert_eq!(client.api_key, "xai-oauth-access");
1087 assert_eq!(client.api_key_source, crate::config::XAI_OAUTH_KEY_SOURCE);
1088 let guidance = client
1089 .subscription_limit_guidance
1090 .as_deref()
1091 .expect("sign-in guidance");
1092 assert!(
1093 guidance.contains("xAI account grok@example.com"),
1094 "{guidance}"
1095 );
1096 assert!(!guidance.contains("xai-oauth-"), "{guidance}");
1097 assert_eq!(
1098 crate::config::resolve_credential_source(
1099 &config,
1100 &(config).test_identity_for_kind(ProviderKind::Xai)
1101 )
1102 .source,
1103 crate::credentials::CredentialSource::OAuth {
1104 flow: "xAI".to_string(),
1105 account: Some("grok@example.com".to_string()),
1106 }
1107 );
1108 }
1109
1110 /// #6715 review: a consented Grok CLI import is a sign-in the user can
1111 /// switch away from, and its quota error must name the account the Grok
1112 /// file holds (the credential this client sends), not a Codewhale-owned
1113 /// account that sent nothing.
1114 #[test]
1115 fn xai_quota_guidance_names_the_consented_grok_import_that_sent_the_request() {
1116 let _ = rustls::crypto::ring::default_provider().install_default();
1117 let _env = crate::test_support::lock_test_env();
1118 let home = tempfile::tempdir().expect("temp home");
1119 let root = home.path().canonicalize().expect("canonical temp root");
1120 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &root);
1121 let _key = crate::test_support::EnvVarGuard::remove("XAI_API_KEY");
1122 let _base = crate::test_support::EnvVarGuard::remove("XAI_BASE_URL");
1123 let path = root.join("grok-auth.json");
1124 let token = crate::test_support::future_test_jwt("grok-cli");
1125 let scope = format!(
1126 "{}::{}",
1127 crate::oauth::XAI_OIDC_ISSUER,
1128 crate::oauth::GROK_OIDC_CLIENT_ID
1129 );
1130 std::fs::write(
1131 &path,
1132 serde_json::to_vec(&json!({
1133 scope: {
1134 "key": token.clone(),
1135 "expires_at": (chrono::Utc::now() + chrono::Duration::hours(1)).to_rfc3339(),
1136 "id_token": test_id_token("grok-cli@example.com"),
1137 "oidc_issuer": crate::oauth::XAI_OIDC_ISSUER,
1138 "oidc_client_id": crate::oauth::GROK_OIDC_CLIENT_ID,
1139 "auth_mode": "oidc",
1140 }
1141 }))
1142 .expect("serialize fixture"),
1143 )
1144 .expect("write fixture");
1145 let _auth_path = crate::test_support::EnvVarGuard::set("GROK_AUTH_PATH", &path);
1146 let config = Config {
1147 provider: Some(ProviderKind::Xai.as_str().to_string()),
1148 providers: Some(ProvidersConfig {
1149 xai: ProviderConfig {
1150 auth_mode: Some("oauth".to_string()),
1151 external_credentials: Some(
1152 codewhale_config::ExternalCredentialConsentToml::read_only(
1153 codewhale_config::ProviderKind::Xai,
1154 codewhale_config::ExternalCredentialSource::GrokCli,
1155 path.clone(),
1156 ),
1157 ),
1158 ..ProviderConfig::default()
1159 },
1160 ..ProvidersConfig::default()
1161 }),
1162 ..Config::default()
1163 };
1164 let client = CodewhaleClient::new(&config).expect("xai client");
1165 assert_eq!(client.api_key, token);
1166 assert_eq!(client.api_key_source, crate::config::XAI_OAUTH_KEY_SOURCE);
1167 let guidance = client
1168 .subscription_limit_guidance
1169 .as_deref()
1170 .expect("sign-in guidance");
1171 assert!(
1172 guidance.contains("xAI account grok-cli@example.com"),
1173 "{guidance}"
1174 );
1175 assert!(!guidance.contains(&token), "{guidance}");
1176 }
1177
1178 /// Ambient legacy process tokens cannot authorize the official route.
1179 #[test]
1180 fn chatgpt_process_token_cannot_replace_owned_grant_or_account_guidance() {
1181 let _ = rustls::crypto::ring::default_provider().install_default();
1182 let _env = crate::test_support::lock_test_env();
1183 let home = tempfile::tempdir().expect("temp home");
1184 let root = home.path().canonicalize().expect("canonical temp root");
1185 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &root);
1186 let token = crate::test_support::future_test_jwt("process-token");
1187 let _access = crate::test_support::EnvVarGuard::set("OPENAI_CODEX_ACCESS_TOKEN", &token);
1188 let _legacy_access = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
1189 let mut config = Config {
1190 provider: Some(ProviderKind::OpenaiCodex.as_str().to_string()),
1191 ..Config::default()
1192 };
1193 assert!(CodewhaleClient::new(&config).is_err());
1194 let own = crate::oauth::install_test_chatgpt_registration(&mut config).unwrap();
1195 let client = CodewhaleClient::new(&config).expect("official ChatGPT client");
1196 assert_eq!(client.api_key, own);
1197 assert_ne!(client.api_key, token);
1198 assert!(client.subscription_limit_guidance.is_some());
1199 }
1200
1201 fn concentrate_client(server: &MockServer, model: &str) -> CodewhaleClient {
1202 let _ = rustls::crypto::ring::default_provider().install_default();
1203 let config = Config {
1204 provider: Some("concentrate".to_string()),
1205 providers: Some(ProvidersConfig {
1206 concentrate: ProviderConfig {
1207 api_key: Some("concentrate-test-key".to_string()),
1208 base_url: Some(format!("{}/v1", server.uri())),
1209 model: Some(model.to_string()),
1210 ..ProviderConfig::default()
1211 },
1212 ..ProvidersConfig::default()
1213 }),
1214 ..Config::default()
1215 };
1216 CodewhaleClient::new(&config).expect("Concentrate client should resolve its route")
1217 }
1218
1219 /// The documented Concentrate stream: `event:`-typed `response.*` frames
1220 /// with sequence numbers and NO `data: [DONE]` sentinel.
1221 /// https://concentrate.ai/docs/api-reference/endpoint/streaming
1222 fn concentrate_sse_fixture(model: &str) -> String {
1223 let completed = json!({
1224 "id": "resp_1",
1225 "object": "response",
1226 "status": "completed",
1227 "model": model,
1228 "output": [{
1229 "type": "message", "id": "msg_1", "status": "completed", "role": "assistant",
1230 "content": [{"type": "output_text", "text": "ok from stub", "annotations": []}]
1231 }],
1232 "usage": {"input_tokens": 12, "output_tokens": 5, "total_tokens": 17,
1233 "input_tokens_details": {"cached_tokens": 0}}
1234 });
1235 let frame = |event: &str, payload: Value| format!("event: {event}\ndata: {}\n\n", payload);
1236 [
1237 frame("response.created", json!({"type": "response.created", "sequence_number": 0, "response": {"id": "resp_1", "status": "in_progress"}})),
1238 frame("response.output_item.added", json!({"type": "response.output_item.added", "sequence_number": 1, "output_index": 0, "item": {"type": "message", "id": "msg_1", "status": "in_progress", "role": "assistant", "content": []}})),
1239 frame("response.content_part.added", json!({"type": "response.content_part.added", "sequence_number": 2, "item_id": "msg_1", "output_index": 0, "content_index": 0, "part": {"type": "output_text", "text": ""}})),
1240 frame("response.output_text.delta", json!({"type": "response.output_text.delta", "sequence_number": 3, "item_id": "msg_1", "output_index": 0, "content_index": 0, "delta": "ok "})),
1241 frame("response.output_text.delta", json!({"type": "response.output_text.delta", "sequence_number": 4, "item_id": "msg_1", "output_index": 0, "content_index": 0, "delta": "from stub"})),
1242 frame("response.output_text.done", json!({"type": "response.output_text.done", "sequence_number": 5, "item_id": "msg_1", "output_index": 0, "content_index": 0, "text": "ok from stub"})),
1243 frame("response.output_item.done", json!({"type": "response.output_item.done", "sequence_number": 6, "output_index": 0, "item": completed["output"][0].clone()})),
1244 frame("response.completed", json!({"type": "response.completed", "sequence_number": 7, "response": completed})),
1245 ]
1246 .concat()
1247 }
1248
1249 /// Request URL, bearer header, verbatim model, documented-fields body, and
1250 /// the typed SSE stream (no `[DONE]`) — the whole Concentrate contract on
1251 /// a loopback wiremock, never the live gateway.
1252 #[tokio::test]
1253 async fn concentrate_responses_request_matches_the_documented_contract() {
1254 let server = MockServer::start().await;
1255 Mock::given(method("POST"))
1256 .and(path("/v1/responses"))
1257 .and(header("authorization", "Bearer concentrate-test-key"))
1258 .respond_with(
1259 ResponseTemplate::new(200)
1260 .insert_header("Content-Type", "text/event-stream")
1261 .set_body_string(concentrate_sse_fixture("openai/gpt-5.6-sol")),
1262 )
1263 .expect(1)
1264 .mount(&server)
1265 .await;
1266
1267 let client = concentrate_client(&server, "openai/gpt-5.6-sol");
1268 assert_eq!(client.wire_format, WireFormat::Responses);
1269 assert_eq!(client.api_provider, ProviderKind::Concentrate);
1270 assert_eq!(
1271 responses_api_url(DEFAULT_CONCENTRATE_BASE_URL, ProviderKind::Concentrate),
1272 "https://api.concentrate.ai/v1/responses",
1273 "the official base URL maps to the documented Responses endpoint"
1274 );
1275
1276 let mut stream = client
1277 .create_message_stream(minimal_zen_request("openai/gpt-5.6-sol"))
1278 .await
1279 .expect("Concentrate Responses request should start");
1280 let mut text = String::new();
1281 let mut usage = None;
1282 let mut stopped = false;
1283 while let Some(event) = stream.next().await {
1284 match event.expect("Concentrate stream event") {
1285 StreamEvent::ContentBlockDelta {
1286 delta: Delta::TextDelta { text: piece },
1287 ..
1288 } => text.push_str(&piece),
1289 StreamEvent::MessageDelta { usage: Some(u), .. } => usage = Some(u),
1290 StreamEvent::MessageStop => stopped = true,
1291 _ => {}
1292 }
1293 }
1294 assert_eq!(text, "ok from stub", "typed deltas assemble the reply");
1295 let usage = usage.expect("response.completed carries usage");
1296 assert_eq!((usage.input_tokens, usage.output_tokens), (12, 5));
1297 assert!(
1298 stopped,
1299 "the stream ends on response.completed without a [DONE] sentinel"
1300 );
1301
1302 let requests = server.received_requests().await.expect("recorded request");
1303 assert_eq!(requests.len(), 1);
1304 let request = &requests[0];
1305 assert_eq!(request.url.path(), "/v1/responses");
1306 for forbidden in [
1307 "openai-beta",
1308 "originator",
1309 "chatgpt-account-id",
1310 "x-api-key",
1311 ] {
1312 assert!(
1313 request.headers.get(forbidden).is_none(),
1314 "Concentrate request must not include {forbidden}"
1315 );
1316 }
1317 let body: Value = serde_json::from_slice(&request.body).expect("Responses JSON body");
1318 assert_eq!(
1319 body["model"], "openai/gpt-5.6-sol",
1320 "model id verbatim: {body}"
1321 );
1322 assert_eq!(body["stream"], true);
1323 assert!(
1324 body.get("messages").is_none(),
1325 "Responses body, not Chat: {body}"
1326 );
1327 for undocumented in [
1328 "store",
1329 "include",
1330 "instructions",
1331 "metadata",
1332 "previous_response_id",
1333 ] {
1334 assert!(
1335 body.get(undocumented).is_none(),
1336 "undocumented field {undocumented} on the wire: {body}"
1337 );
1338 }
1339 assert_eq!(
1340 body["input"][0]["role"], "system",
1341 "system prompt rides as a system input item: {body}"
1342 );
1343 }
1344
1345 /// The documented error body surfaces verbatim and classifies by message:
1346 /// 401 → authentication, 402 → quota (insufficient credits).
1347 /// https://concentrate.ai/docs/api-reference/endpoint/errors
1348 #[tokio::test]
1349 async fn concentrate_error_bodies_surface_verbatim_and_classify() {
1350 for (status, body, expected, needle) in [
1351 (
1352 401,
1353 r#"{"error":"Unauthorized","message":"Invalid API key"}"#,
1354 crate::error_taxonomy::ErrorCategory::Authentication,
1355 "Invalid API key",
1356 ),
1357 (
1358 402,
1359 r#"{"error":"Insufficient funds","message":"Your account has insufficient credits. Please add credits to continue."}"#,
1360 crate::error_taxonomy::ErrorCategory::RateLimit,
1361 "insufficient credits",
1362 ),
1363 (
1364 400,
1365 r#"{"error":"Bad Request","message":"Invalid model name: 'invalid-model-xyz'"}"#,
1366 crate::error_taxonomy::ErrorCategory::InvalidInput,
1367 "Invalid model name",
1368 ),
1369 ] {
1370 let server = MockServer::start().await;
1371 Mock::given(method("POST"))
1372 .and(path("/v1/responses"))
1373 .respond_with(ResponseTemplate::new(status).set_body_string(body))
1374 .mount(&server)
1375 .await;
1376 let client = concentrate_client(&server, "gpt-5.6-sol");
1377 let error = match client
1378 .create_message_stream(minimal_zen_request("gpt-5.6-sol"))
1379 .await
1380 {
1381 Ok(mut stream) => {
1382 let mut failure = None;
1383 while let Some(event) = stream.next().await {
1384 if let Err(err) = event {
1385 failure = Some(err);
1386 break;
1387 }
1388 }
1389 failure.expect("HTTP {status} must fail the stream")
1390 }
1391 Err(err) => err,
1392 };
1393 let message = format!("{error:#}");
1394 assert!(
1395 message.contains(needle),
1396 "HTTP {status}: message must carry the documented text, got: {message}"
1397 );
1398 assert_eq!(
1399 crate::error_taxonomy::classify_error_message(&message),
1400 expected,
1401 "HTTP {status}: {message}"
1402 );
1403 }
1404 }
1405
1406 /// Concentrate's `GET /v1/models` is unauthenticated, so a 2xx must not
1407 /// count as key verification. Guided setup treats the probe as unobserved;
1408 /// health_check must not issue the request either.
1409 #[tokio::test]
1410 async fn concentrate_health_check_does_not_treat_unauthenticated_models_as_key_proof() {
1411 let server = MockServer::start().await;
1412 let client = concentrate_client(&server, DEFAULT_CONCENTRATE_MODEL);
1413
1414 assert!(client.health_check().await.expect("health check"));
1415 assert!(!provider_api_key_verification_is_observed(
1416 ProviderKind::Concentrate
1417 ));
1418 let requests = server.received_requests().await.expect("recorded requests");
1419 assert!(
1420 requests.is_empty(),
1421 "Concentrate must not treat unauthenticated GET /v1/models as key verification"
1422 );
1423 }
1424
1425 /// `GET /v1/models` needs no key and answers the OpenAI list shape; rows
1426 /// are provider-scoped, the default is marked, and unknowns stay unclaimed.
1427 /// https://concentrate.ai/docs/api-reference/endpoint/list-models
1428 #[tokio::test]
1429 async fn concentrate_live_catalog_is_provider_scoped_and_marks_the_default() {
1430 let server = MockServer::start().await;
1431 Mock::given(method("GET"))
1432 .and(path("/v1/models"))
1433 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1434 "object": "list",
1435 "data": [
1436 {"id": "claude-fable-5", "object": "model", "owned_by": "anthropic"},
1437 {"id": DEFAULT_CONCENTRATE_MODEL, "object": "model", "owned_by": "deepseek"},
1438 {"id": "gpt-5.6-sol", "object": "model", "owned_by": "openai"}
1439 ]
1440 })))
1441 .expect(1)
1442 .mount(&server)
1443 .await;
1444
1445 let delta = concentrate_client(&server, DEFAULT_CONCENTRATE_MODEL)
1446 .fetch_catalog_delta()
1447 .await
1448 .expect("Concentrate catalog delta");
1449 assert_eq!(delta.provider, "concentrate");
1450 assert_eq!(delta.offerings.len(), 3);
1451 let default = delta
1452 .offerings
1453 .iter()
1454 .find(|offering| offering.wire_model_id == DEFAULT_CONCENTRATE_MODEL)
1455 .expect("default row");
1456 assert!(default.default_for_provider);
1457 let unknown = delta
1458 .offerings
1459 .iter()
1460 .find(|offering| offering.wire_model_id == "claude-fable-5")
1461 .expect("unclaimed row");
1462 assert!(!unknown.default_for_provider);
1463 assert_eq!(unknown.canonical_model, None);
1464 assert_eq!(
1465 unknown.cost, None,
1466 "no pricing claim from a gateway catalog"
1467 );
1468 assert!(matches!(unknown.source, CatalogSource::Live { .. }));
1469 }
1470
1471 /// A Codewhale-route client pointed at a loopback stub.
1472 ///
1473 /// `base_url` goes through the provider table rather than
1474 /// `CODEWHALE_API_BASE` so the test does not mutate process env, but it
1475 /// exercises the same "declared origin" path: the key must follow the
1476 /// route to whatever origin the operator pointed it at.
1477 fn codewhale_client(server: &MockServer, model: &str) -> CodewhaleClient {
1478 let config = Config {
1479 provider: Some("codewhale".to_string()),
1480 providers: Some(ProvidersConfig {
1481 codewhale: ProviderConfig {
1482 api_key: Some("cwc_key_test_value".to_string()),
1483 base_url: Some(server.uri()),
1484 model: Some(model.to_string()),
1485 ..ProviderConfig::default()
1486 },
1487 ..ProvidersConfig::default()
1488 }),
1489 ..Config::default()
1490 };
1491 CodewhaleClient::new(&config).expect("Codewhale client should resolve its model route")
1492 }
1493
1494 /// The account key must ride as `Authorization: Bearer` and never as
1495 /// `x-api-key`, on both protocols the account API serves.
1496 fn assert_codewhale_bearer(request: &wiremock::Request) {
1497 assert_eq!(
1498 request
1499 .headers
1500 .get(AUTHORIZATION)
1501 .and_then(|value| value.to_str().ok()),
1502 Some("Bearer cwc_key_test_value")
1503 );
1504 assert!(
1505 request.headers.get("x-api-key").is_none(),
1506 "the Codewhale API does not accept x-api-key"
1507 );
1508 }
1509
1510 #[tokio::test]
1511 async fn codewhale_chat_request_carries_the_account_bearer() {
1512 let server = MockServer::start().await;
1513 Mock::given(method("POST"))
1514 .and(path("/v1/chat/completions"))
1515 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1516 "id": "chatcmpl_cw",
1517 "object": "chat.completion",
1518 "model": "deepseek/deepseek-v4-pro",
1519 "choices": [{
1520 "index": 0,
1521 "message": {"role": "assistant", "content": "ok"},
1522 "finish_reason": "stop"
1523 }],
1524 "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
1525 })))
1526 .expect(1)
1527 .mount(&server)
1528 .await;
1529
1530 let client = codewhale_client(&server, "deepseek/deepseek-v4-pro");
1531 assert_eq!(client.wire_format, WireFormat::ChatCompletions);
1532 client
1533 .create_message(minimal_zen_request("deepseek/deepseek-v4-pro"))
1534 .await
1535 .expect("Codewhale chat request should succeed");
1536
1537 let requests = server.received_requests().await.expect("recorded request");
1538 assert_eq!(requests.len(), 1);
1539 assert_codewhale_bearer(&requests[0]);
1540 let body: Value = serde_json::from_slice(&requests[0].body).expect("chat JSON body");
1541 // Model ids reach the account API exactly as its catalog returns them.
1542 assert_eq!(
1543 body.get("model").and_then(Value::as_str),
1544 Some("deepseek/deepseek-v4-pro")
1545 );
1546 }
1547
1548 #[tokio::test]
1549 async fn codewhale_messages_request_carries_the_account_bearer_not_x_api_key() {
1550 let server = MockServer::start().await;
1551 Mock::given(method("POST"))
1552 .and(path("/v1/messages"))
1553 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1554 "id": "msg_cw",
1555 "type": "message",
1556 "role": "assistant",
1557 "content": [{"type": "text", "text": "ok"}],
1558 "model": "anthropic/claude-sonnet-5",
1559 "stop_reason": "end_turn",
1560 "stop_sequence": null,
1561 "usage": {"input_tokens": 1, "output_tokens": 1}
1562 })))
1563 .expect(1)
1564 .mount(&server)
1565 .await;
1566
1567 let client = codewhale_client(&server, "anthropic/claude-sonnet-5");
1568 assert_eq!(client.wire_format, WireFormat::AnthropicMessages);
1569 client
1570 .create_message(minimal_zen_request("anthropic/claude-sonnet-5"))
1571 .await
1572 .expect("Codewhale messages request should succeed");
1573
1574 let requests = server.received_requests().await.expect("recorded request");
1575 assert_eq!(requests.len(), 1);
1576 assert_codewhale_bearer(&requests[0]);
1577 assert_eq!(
1578 requests[0]
1579 .headers
1580 .get("anthropic-version")
1581 .and_then(|value| value.to_str().ok()),
1582 Some("2023-06-01")
1583 );
1584 }
1585
1585 lines RUST