返回 CodeWhale
test_cases_05.rs
根目录 / crates / tui / src / client / test_cases_05.rs
1
2 /// #5055: the Chat and Responses DeepSeek mappings are two spellings of
3 /// one table. If they ever disagree, one of them was edited alone.
4 #[test]
5 fn deepseek_chat_and_responses_wires_agree_with_the_shared_effort_table() {
6 use super::deepseek_effort::{
7 DEEPSEEK_DEFAULT_EFFORT_TIER, DEEPSEEK_EFFORT_ALIASES, deepseek_effort_tier,
8 };
9
10 for &(alias, tier) in DEEPSEEK_EFFORT_ALIASES {
11 for provider in [ProviderKind::Deepseek, ProviderKind::Deepseek] {
12 let mut body = json!({});
13 apply_reasoning_effort(&mut body, Some(alias), provider);
14 assert_eq!(
15 body.get("reasoning_effort").and_then(Value::as_str),
16 tier.chat_reasoning_effort(),
17 "chat wire disagrees with the table for {alias:?} on {provider:?}"
18 );
19 assert_eq!(
20 body.pointer("/thinking/type").and_then(Value::as_str),
21 Some(if tier.chat_thinking_enabled() {
22 "enabled"
23 } else {
24 "disabled"
25 }),
26 "chat thinking toggle disagrees with the table for {alias:?}"
27 );
28 }
29
30 assert_eq!(
31 super::responses::responses_reasoning_effort(alias, true),
32 Some(tier.responses_effort()),
33 "responses wire disagrees with the table for {alias:?}"
34 );
35 }
36
37 // A spelling the table does not name: the Chat wire writes nothing,
38 // the Responses wire must still send a documented label.
39 assert_eq!(deepseek_effort_tier("auto"), None);
40 let mut body = json!({});
41 apply_reasoning_effort(&mut body, Some("auto"), ProviderKind::Deepseek);
42 assert_eq!(body, json!({}));
43 assert_eq!(
44 super::responses::responses_reasoning_effort("auto", true),
45 Some(DEEPSEEK_DEFAULT_EFFORT_TIER.responses_effort())
46 );
47 }
48
49 async fn capture_deepseek_chat_body_for_effort(effort: Option<&str>) -> Value {
50 let server = MockServer::start().await;
51 Mock::given(method("POST"))
52 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
53 "id": "chatcmpl-deepseek-effort-ladder",
54 "object": "chat.completion",
55 "model": "deepseek-v4-pro",
56 "choices": [{
57 "index": 0,
58 "message": {"role": "assistant", "content": "ok"},
59 "finish_reason": "stop"
60 }],
61 "usage": {
62 "prompt_tokens": 1,
63 "completion_tokens": 1,
64 "total_tokens": 2
65 }
66 })))
67 .expect(1)
68 .mount(&server)
69 .await;
70
71 let request = MessageRequest {
72 model: "deepseek-v4-pro".to_string(),
73 messages: vec![Message {
74 role: Role::User,
75 content: vec![ContentBlock::Text {
76 text: "effort ladder capture".to_string(),
77 cache_control: None,
78 }],
79 }],
80 max_tokens: 64,
81 system: None,
82 tools: None,
83 tool_choice: None,
84 metadata: None,
85 thinking: None,
86 reasoning_effort: effort.map(str::to_string),
87 stream: Some(false),
88 temperature: None,
89 top_p: None,
90 };
91 let client =
92 deepseek_request_boundary_client(crate::config::DEFAULT_DEEPSEEK_BASE_URL, server.uri());
93 client
94 .create_message(request)
95 .await
96 .expect("non-streaming request succeeds");
97
98 let requests = server.received_requests().await.expect("recorded request");
99 assert_eq!(requests.len(), 1);
100 serde_json::from_slice(&requests[0].body).expect("captured request JSON")
101 }
102
103 /// Request-body capture per effort level on the first-party DeepSeek chat
104 /// route: the wire must carry the documented low/high/max ladder and the
105 /// thinking toggle, never an invented value (#52).
106 #[tokio::test]
107 async fn deepseek_chat_wire_body_tracks_the_documented_effort_ladder() {
108 for (effort, expected_effort, expected_thinking) in [
109 (Some("low"), Some("low"), Some("enabled")),
110 (Some("medium"), Some("high"), Some("enabled")),
111 (Some("high"), Some("high"), Some("enabled")),
112 (Some("max"), Some("max"), Some("enabled")),
113 (Some("off"), None, Some("disabled")),
114 (None, None, None),
115 ] {
116 let body = capture_deepseek_chat_body_for_effort(effort).await;
117 assert_eq!(
118 body.get("reasoning_effort").and_then(Value::as_str),
119 expected_effort,
120 "reasoning_effort on the wire for {effort:?}: {body}"
121 );
122 assert_eq!(
123 body.pointer("/thinking/type").and_then(Value::as_str),
124 expected_thinking,
125 "thinking on the wire for {effort:?}: {body}"
126 );
127 }
128 }
129
130 /// TelecomJS TokenHub: the gateway's OpenAI Chat Completions API does NOT
131 /// support `reasoning_effort` or `thinking` fields (#4188 review). Verify
132 /// that no reasoning fields are injected for any effort level, since not
133 /// every gateway model (qwen-max, deepseek-chat, gpt-4o, claude, etc.)
134 /// accepts the same reasoning dialect.
135 #[test]
136 fn reasoning_effort_telecomjs_does_not_inject_reasoning_fields() {
137 for effort in &["off", "low", "medium", "high", "max", "xhigh"] {
138 let mut body = json!({});
139 apply_reasoning_effort(&mut body, Some(effort), ProviderKind::Telecomjs);
140 assert!(
141 body.get("reasoning_effort").is_none(),
142 "TelecomJS must not inject reasoning_effort for effort={effort}: {body}"
143 );
144 assert!(
145 body.get("thinking").is_none(),
146 "TelecomJS must not inject thinking for effort={effort}: {body}"
147 );
148 assert!(
149 body.get("think").is_none(),
150 "TelecomJS must not inject think for effort={effort}: {body}"
151 );
152 }
153 }
154
155 #[test]
156 fn moonshot_uses_codewhale_user_agent_not_kimi_cli_identity() {
157 let user_agent = client_user_agent(ProviderKind::Moonshot);
158
159 assert!(user_agent.contains("codewhale/"));
160 assert!(!user_agent.to_ascii_lowercase().contains("kimi_cli"));
161 assert!(!user_agent.to_ascii_lowercase().contains("kimi-code-cli"));
162 }
163
164 #[test]
165 fn reasoning_effort_scenario_2() {
166 // Scenario consolidation of: reasoning_effort_ollama_cloud_uses_openai_compatible_field, reasoning_effort_uses_nvidia_nim_chat_template_kwargs, reasoning_effort_off_disables_nvidia_nim_thinking, reasoning_effort_uses_openai_compatible_shape_for_fireworks, reasoning_effort_uses_arcee_reasoning_effort_without_thinking_object, reasoning_effort_maps_openrouter_scale_without_deepseek_max_label, reasoning_effort_uses_xiaomi_mimo_thinking_parameter_only, reasoning_effort_zai_uses_documented_thinking_shape
167 // from reasoning_effort_ollama_cloud_uses_openai_compatible_field
168 {
169 for (effort, expected) in [
170 ("off", "none"),
171 ("low", "low"),
172 ("medium", "medium"),
173 ("high", "high"),
174 ("max", "max"),
175 ] {
176 let mut body = json!({});
177 apply_reasoning_effort(&mut body, Some(effort), ProviderKind::OllamaCloud);
178 assert_eq!(body, json!({ "reasoning_effort": expected }));
179 }
180
181 let mut local = json!({});
182 apply_reasoning_effort(&mut local, Some("high"), ProviderKind::Ollama);
183 assert_eq!(local, json!({ "think": true }));
184 }
185 // from reasoning_effort_uses_nvidia_nim_chat_template_kwargs
186 {
187 let mut body = json!({});
188 apply_reasoning_effort(&mut body, Some("max"), ProviderKind::NvidiaNim);
189
190 assert_eq!(
191 body.pointer("/chat_template_kwargs/thinking")
192 .and_then(Value::as_bool),
193 Some(true)
194 );
195 assert_eq!(
196 body.pointer("/chat_template_kwargs/reasoning_effort")
197 .and_then(Value::as_str),
198 Some("max")
199 );
200 assert!(body.get("thinking").is_none());
201 assert!(body.get("reasoning_effort").is_none());
202 }
203 // from reasoning_effort_off_disables_nvidia_nim_thinking
204 {
205 let mut body = json!({});
206 apply_reasoning_effort(&mut body, Some("off"), ProviderKind::NvidiaNim);
207
208 assert_eq!(
209 body.pointer("/chat_template_kwargs/thinking")
210 .and_then(Value::as_bool),
211 Some(false)
212 );
213 assert!(
214 body.pointer("/chat_template_kwargs/reasoning_effort")
215 .is_none()
216 );
217 }
218 // from reasoning_effort_uses_openai_compatible_shape_for_fireworks
219 {
220 let mut body = json!({});
221 apply_reasoning_effort(&mut body, Some("max"), ProviderKind::Fireworks);
222
223 assert_eq!(
224 body.get("reasoning_effort").and_then(Value::as_str),
225 Some("max")
226 );
227 assert!(
228 body.get("thinking").is_none(),
229 "Fireworks strict-validates OpenAI-compatible requests and rejects top-level thinking"
230 );
231 }
232 // from reasoning_effort_uses_arcee_reasoning_effort_without_thinking_object
233 {
234 for (input, expected) in [
235 ("minimal", "minimal"),
236 ("low", "low"),
237 ("mid", "medium"),
238 ("medium", "medium"),
239 ("high", "high"),
240 ("max", "high"),
241 ] {
242 let mut body = json!({});
243 apply_reasoning_effort(&mut body, Some(input), ProviderKind::Arcee);
244
245 assert_eq!(
246 body.get("reasoning_effort").and_then(Value::as_str),
247 Some(expected)
248 );
249 assert!(
250 body.get("thinking").is_none(),
251 "Arcee documents reasoning_effort rather than a DeepSeek thinking object"
252 );
253 }
254 }
255 // from reasoning_effort_maps_openrouter_scale_without_deepseek_max_label
256 {
257 for (input, expected) in [
258 ("low", "low"),
259 ("minimal", "low"),
260 ("medium", "medium"),
261 ("mid", "medium"),
262 ("high", "high"),
263 ("max", "xhigh"),
264 ("xhigh", "xhigh"),
265 ] {
266 let mut body = json!({});
267 apply_reasoning_effort(&mut body, Some(input), ProviderKind::Openrouter);
268
269 assert_eq!(
270 body.get("reasoning_effort").and_then(Value::as_str),
271 Some(expected),
272 "OpenRouter effort mapping for {input}"
273 );
274 assert_eq!(
275 body.pointer("/thinking/type").and_then(Value::as_str),
276 Some("enabled")
277 );
278 }
279 }
280 // from reasoning_effort_uses_xiaomi_mimo_thinking_parameter_only
281 {
282 for input in ["low", "medium", "max", "xhigh"] {
283 let mut body = json!({});
284 apply_reasoning_effort(&mut body, Some(input), ProviderKind::XiaomiMimo);
285
286 assert_eq!(
287 body.pointer("/thinking/type").and_then(Value::as_str),
288 Some("enabled"),
289 "MiMo thinking mapping for {input}"
290 );
291 assert!(body.get("reasoning_effort").is_none());
292 }
293
294 let mut body = json!({});
295 apply_reasoning_effort(&mut body, Some("off"), ProviderKind::XiaomiMimo);
296 assert_eq!(
297 body.pointer("/thinking/type").and_then(Value::as_str),
298 Some("disabled")
299 );
300 assert!(body.get("reasoning_effort").is_none());
301 }
302 // from reasoning_effort_zai_uses_documented_thinking_shape
303 {
304 let mut body = json!({});
305 apply_reasoning_effort(&mut body, Some("high"), ProviderKind::Zai);
306 assert_eq!(
307 body,
308 json!({ "thinking": { "type": "enabled", "clear_thinking": false } })
309 );
310
311 let mut body = json!({});
312 apply_reasoning_effort(&mut body, Some("max"), ProviderKind::Zai);
313 assert_eq!(
314 body,
315 json!({ "thinking": { "type": "enabled", "clear_thinking": false } })
316 );
317
318 let mut body = json!({});
319 apply_reasoning_effort(&mut body, Some("ultracode"), ProviderKind::Zai);
320 assert_eq!(
321 body,
322 json!({ "thinking": { "type": "enabled", "clear_thinking": false } })
323 );
324
325 let mut body = json!({});
326 apply_reasoning_effort(&mut body, Some("off"), ProviderKind::Zai);
327 assert_eq!(body, json!({ "thinking": { "type": "disabled" } }));
328 }
329 }
330
331 #[test]
332 fn reasoning_effort_minimax_requires_exact_route_to_split_reasoning() {
333 let mut body = json!({});
334 chat::apply_route_reasoning_controls(
335 &mut body,
336 ProviderKind::Minimax,
337 crate::config::DEFAULT_MINIMAX_BASE_URL,
338 crate::config::DEFAULT_MINIMAX_MODEL,
339 Some("high"),
340 );
341 assert_eq!(
342 body.get("reasoning_split").and_then(Value::as_bool),
343 Some(true)
344 );
345 assert_eq!(
346 body.pointer("/thinking/type").and_then(Value::as_str),
347 Some("adaptive")
348 );
349 assert!(body.get("reasoning_effort").is_none());
350
351 let mut body = json!({});
352 chat::apply_route_reasoning_controls(
353 &mut body,
354 ProviderKind::Minimax,
355 crate::config::DEFAULT_MINIMAX_BASE_URL,
356 crate::config::DEFAULT_MINIMAX_MODEL,
357 Some("max"),
358 );
359 assert_eq!(
360 body.pointer("/thinking/type").and_then(Value::as_str),
361 Some("adaptive")
362 );
363 assert!(body.get("reasoning_effort").is_none());
364
365 let mut body = json!({});
366 chat::apply_route_reasoning_controls(
367 &mut body,
368 ProviderKind::Minimax,
369 crate::config::DEFAULT_MINIMAX_BASE_URL,
370 crate::config::DEFAULT_MINIMAX_MODEL,
371 Some("off"),
372 );
373 assert_eq!(
374 body.get("reasoning_split").and_then(Value::as_bool),
375 Some(true)
376 );
377 assert_eq!(
378 body.pointer("/thinking/type").and_then(Value::as_str),
379 Some("disabled")
380 );
381
382 let mut body = json!({});
383 chat::apply_route_reasoning_controls(
384 &mut body,
385 ProviderKind::Minimax,
386 crate::config::DEFAULT_MINIMAX_BASE_URL,
387 crate::config::DEFAULT_MINIMAX_MODEL,
388 None,
389 );
390 assert_eq!(body, json!({ "reasoning_split": true }));
391
392 for (base_url, model) in [
393 (
394 "https://gateway.example/v1",
395 crate::config::DEFAULT_MINIMAX_MODEL,
396 ),
397 (crate::config::DEFAULT_MINIMAX_BASE_URL, "MiniMax-M2"),
398 ] {
399 for effort in ["off", "high", "max"] {
400 let mut body = json!({});
401 chat::apply_route_reasoning_controls(
402 &mut body,
403 ProviderKind::Minimax,
404 base_url,
405 model,
406 Some(effort),
407 );
408 assert_eq!(body, json!({}), "{base_url} {model} {effort}");
409 }
410 }
411 }
412
413 #[test]
414 fn chat_parser_accepts_nvidia_nim_reasoning_field() -> Result<()> {
415 let response = parse_chat_message(&json!({
416 "id": "chatcmpl-test",
417 "model": "deepseek-ai/deepseek-v4-pro",
418 "choices": [{
419 "message": {
420 "role": "assistant",
421 "reasoning": "thinking via NIM",
422 "content": "final answer"
423 },
424 "finish_reason": "stop"
425 }],
426 "usage": {
427 "prompt_tokens": 10,
428 "completion_tokens": 3
429 }
430 }))?;
431
432 assert!(matches!(
433 response.content.first(),
434 Some(ContentBlock::Thinking { thinking, .. }) if thinking == "thinking via NIM"
435 ));
436 assert!(matches!(
437 response.content.get(1),
438 Some(ContentBlock::Text { text, .. }) if text == "final answer"
439 ));
440 Ok(())
441 }
442
443 #[test]
444 fn sse_parser_accepts_nvidia_nim_reasoning_delta() {
445 let mut content_index = 0;
446 let mut text_started = false;
447 let mut thinking_started = false;
448 let mut tool_indices = std::collections::HashMap::new();
449 let mut reasoning_detail_buffers = std::collections::HashMap::new();
450 let events = parse_sse_chunk(
451 &json!({
452 "choices": [{
453 "delta": {
454 "reasoning": "nim thought"
455 }
456 }]
457 }),
458 &mut content_index,
459 &mut text_started,
460 &mut thinking_started,
461 &mut tool_indices,
462 &mut reasoning_detail_buffers,
463 true,
464 );
465
466 assert!(events.iter().any(|event| matches!(
467 event,
468 StreamEvent::ContentBlockDelta {
469 delta: Delta::ThinkingDelta { thinking },
470 ..
471 } if thinking == "nim thought"
472 )));
473 }
474
475 #[test]
476 fn chat_tool_scenario() {
477 // Scenario consolidation of: chat_tool_strict_flag_is_nested_under_function, chat_tool_wire_shape_omits_anthropic_only_metadata
478 // from chat_tool_strict_flag_is_nested_under_function
479 {
480 let tool = Tool {
481 tool_type: Some("function".to_string()),
482 name: "emit_json".to_string(),
483 description: "Emit JSON".to_string(),
484 input_schema: json!({"type": "object", "properties": {}}),
485 allowed_callers: None,
486 defer_loading: None,
487 input_examples: None,
488 strict: Some(true),
489 cache_control: None,
490 };
491 let encoded = tool_to_chat(&tool);
492 assert_eq!(
493 encoded
494 .get("function")
495 .and_then(|function| function.get("strict"))
496 .and_then(Value::as_bool),
497 Some(true)
498 );
499 assert!(encoded.get("strict").is_none());
500 }
501 // from chat_tool_wire_shape_omits_anthropic_only_metadata
502 {
503 let tool = Tool {
504 tool_type: Some("function".to_string()),
505 name: "mcp_read_resource".to_string(),
506 description: "Read resource".to_string(),
507 input_schema: json!({"type": "object", "properties": {}}),
508 allowed_callers: Some(vec!["direct".to_string()]),
509 defer_loading: Some(false),
510 input_examples: Some(vec![json!({"uri": "file://example"})]),
511 strict: None,
512 cache_control: None,
513 };
514
515 let encoded = tool_to_chat_for_base_url(&tool, "https://api.fireworks.ai/inference/v1");
516
517 assert!(encoded.get("allowed_callers").is_none());
518 assert!(encoded.get("defer_loading").is_none());
519 assert!(encoded.get("input_examples").is_none());
520 }
521 }
522
523 #[test]
524 fn deepseek_non_beta_base_url_strips_strict_tool_flag() {
525 let tool = Tool {
526 tool_type: Some("function".to_string()),
527 name: "emit_json".to_string(),
528 description: "Emit JSON".to_string(),
529 input_schema: json!({"type": "object", "properties": {}}),
530 allowed_callers: None,
531 defer_loading: None,
532 input_examples: None,
533 strict: Some(true),
534 cache_control: None,
535 };
536
537 let encoded = tool_to_chat_for_base_url(&tool, "https://api.deepseek.com/v1");
538
539 assert!(
540 encoded
541 .get("function")
542 .and_then(|function| function.get("strict"))
543 .is_none()
544 );
545 }
546
547 #[test]
548 fn deepseek_beta_and_custom_base_urls_keep_strict_tool_flag() {
549 let tool = Tool {
550 tool_type: Some("function".to_string()),
551 name: "emit_json".to_string(),
552 description: "Emit JSON".to_string(),
553 input_schema: json!({"type": "object", "properties": {}}),
554 allowed_callers: None,
555 defer_loading: None,
556 input_examples: None,
557 strict: Some(true),
558 cache_control: None,
559 };
560
561 for base_url in [
562 "https://api.deepseek.com/beta",
563 "https://example.com/openai/v1",
564 ] {
565 let encoded = tool_to_chat_for_base_url(&tool, base_url);
566 assert_eq!(
567 encoded
568 .get("function")
569 .and_then(|function| function.get("strict"))
570 .and_then(Value::as_bool),
571 Some(true)
572 );
573 }
574 }
575
576 #[test]
577 fn chat_messages_scenario() {
578 // Scenario consolidation of: chat_messages_drop_thinking_only_assistant_for_non_reasoning_model, chat_messages_drop_orphan_tool_results
579 // from chat_messages_drop_thinking_only_assistant_for_non_reasoning_model
580 {
581 let message = Message {
582 role: Role::Assistant,
583 content: vec![ContentBlock::Thinking {
584 signature: None,
585 state: None,
586 thinking: "plan".to_string(),
587 }],
588 };
589 let out = build_chat_messages(None, &[message], "some-non-deepseek-model");
590 assert!(
591 !out.iter()
592 .any(|value| value.get("role").and_then(Value::as_str) == Some("assistant")),
593 "non-reasoning model should drop thinking-only assistant"
594 );
595 }
596 // from chat_messages_drop_orphan_tool_results
597 {
598 let messages = vec![Message {
599 role: Role::User,
600 content: vec![ContentBlock::ToolResult {
601 execution_id: None,
602 tool_use_id: "tool-1".to_string(),
603 content: "ok".to_string(),
604 is_error: None,
605 content_blocks: None,
606 }],
607 }];
608
609 let out = build_chat_messages(None, &messages, "deepseek-v4-flash");
610 assert!(
611 !out.iter()
612 .any(|value| { value.get("role").and_then(Value::as_str) == Some("tool") })
613 );
614 }
615 }
616
617 #[test]
618 fn parse_sse_chunk_closes_each_tool_block_with_matching_index() {
619 let chunk = json!({
620 "choices": [{
621 "delta": {
622 "tool_calls": [
623 {
624 "index": 0,
625 "id": "call_0",
626 "function": {"name": "read_file", "arguments": "{\"path\":\"a\"}"}
627 },
628 {
629 "index": 1,
630 "id": "call_1",
631 "function": {"name": "read_file", "arguments": "{\"path\":\"b\"}"}
632 }
633 ]
634 },
635 "finish_reason": "tool_calls"
636 }]
637 });
638
639 let mut content_index = 0;
640 let mut text_started = false;
641 let mut thinking_started = false;
642 let mut tool_indices: std::collections::HashMap<u32, u32> = std::collections::HashMap::new();
643 let mut reasoning_detail_buffers = std::collections::HashMap::new();
644 let events = parse_sse_chunk(
645 &chunk,
646 &mut content_index,
647 &mut text_started,
648 &mut thinking_started,
649 &mut tool_indices,
650 &mut reasoning_detail_buffers,
651 false,
652 );
653
654 let starts: Vec<u32> = events
655 .iter()
656 .filter_map(|event| match event {
657 StreamEvent::ContentBlockStart {
658 index,
659 content_block: ContentBlockStart::ToolUse { .. },
660 } => Some(*index),
661 _ => None,
662 })
663 .collect();
664 let stops: Vec<u32> = events
665 .iter()
666 .filter_map(|event| match event {
667 StreamEvent::ContentBlockStop { index } => Some(*index),
668 _ => None,
669 })
670 .collect();
671 let deltas: Vec<u32> = events
672 .iter()
673 .filter_map(|event| match event {
674 StreamEvent::ContentBlockDelta {
675 index,
676 delta: Delta::InputJsonDelta { .. },
677 } => Some(*index),
678 _ => None,
679 })
680 .collect();
681
682 assert_eq!(starts, vec![0, 1]);
683 assert_eq!(stops, vec![0, 1]);
684 assert_eq!(deltas, vec![0, 1]);
685 }
686
687 #[test]
688 fn parse_sse_chunk_handles_empty_choices_usage_chunk() {
689 let chunk = json!({
690 "choices": [],
691 "usage": {
692 "prompt_tokens": 100,
693 "completion_tokens": 20,
694 "prompt_cache_hit_tokens": 70,
695 "prompt_cache_miss_tokens": 30
696 }
697 });
698
699 let mut content_index = 0;
700 let mut text_started = false;
701 let mut thinking_started = false;
702 let mut tool_indices: std::collections::HashMap<u32, u32> = std::collections::HashMap::new();
703 let mut reasoning_detail_buffers = std::collections::HashMap::new();
704 let events = parse_sse_chunk(
705 &chunk,
706 &mut content_index,
707 &mut text_started,
708 &mut thinking_started,
709 &mut tool_indices,
710 &mut reasoning_detail_buffers,
711 false,
712 );
713
714 let StreamEvent::MessageDelta {
715 usage: Some(usage), ..
716 } = &events[0]
717 else {
718 panic!("expected usage delta");
719 };
720 assert_eq!(usage.input_tokens, 100);
721 assert_eq!(usage.prompt_cache_hit_tokens, Some(70));
722 assert_eq!(usage.prompt_cache_miss_tokens, Some(30));
723 }
724
725 #[test]
726 fn chat_messages_include_tool_results_when_call_present() {
727 let messages = vec![
728 Message {
729 role: Role::Assistant,
730 content: vec![
731 ContentBlock::Thinking {
732 signature: None,
733 state: None,
734 thinking: "Need to inspect the directory".to_string(),
735 },
736 ContentBlock::ToolUse {
737 execution_id: None,
738 id: "tool-1".to_string(),
739 name: "list_dir".to_string(),
740 input: json!({}),
741 caller: None,
742 thought_signature: None,
743 },
744 ],
745 },
746 Message {
747 role: Role::User,
748 content: vec![ContentBlock::ToolResult {
749 execution_id: None,
750 tool_use_id: "tool-1".to_string(),
751 content: "ok".to_string(),
752 is_error: None,
753 content_blocks: None,
754 }],
755 },
756 ];
757
758 let out = build_chat_messages(None, &messages, "deepseek-v4-flash");
759 assert!(
760 out.iter()
761 .any(|value| { value.get("role").and_then(Value::as_str) == Some("tool") })
762 );
763 let assistant = out
764 .iter()
765 .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant"))
766 .expect("assistant message");
767 assert!(assistant.get("tool_calls").is_some());
768 }
769
770 #[test]
771 fn chat_messages_encode_tool_call_names() {
772 let messages = vec![
773 Message {
774 role: Role::Assistant,
775 content: vec![
776 ContentBlock::Thinking {
777 signature: None,
778 state: None,
779 thinking: "Need to search".to_string(),
780 },
781 ContentBlock::ToolUse {
782 execution_id: None,
783 id: "tool-1".to_string(),
784 name: "web.run".to_string(),
785 input: json!({}),
786 caller: None,
787 thought_signature: None,
788 },
789 ],
790 },
791 Message {
792 role: Role::User,
793 content: vec![ContentBlock::ToolResult {
794 execution_id: None,
795 tool_use_id: "tool-1".to_string(),
796 content: "ok".to_string(),
797 is_error: None,
798 content_blocks: None,
799 }],
800 },
801 ];
802
803 let out = build_chat_messages(None, &messages, "deepseek-v4-flash");
804 let assistant = out
805 .iter()
806 .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant"))
807 .expect("assistant message");
808 let tool_calls = assistant
809 .get("tool_calls")
810 .and_then(Value::as_array)
811 .expect("tool_calls array");
812 let function_name = tool_calls
813 .first()
814 .and_then(|call| call.get("function"))
815 .and_then(|func| func.get("name"))
816 .and_then(Value::as_str)
817 .expect("tool call function name");
818
819 assert_eq!(function_name, to_api_tool_name("web.run"));
820 }
821
822 #[test]
823 fn chat_messages_strips_orphaned_tool_calls_after_compaction() {
824 // Simulates post-compaction state: assistant has tool_calls but the
825 // tool result messages were summarized away.
826 let messages = vec![
827 Message {
828 role: Role::Assistant,
829 content: vec![ContentBlock::ToolUse {
830 execution_id: None,
831 id: "tool-orphan".to_string(),
832 name: "read_file".to_string(),
833 input: json!({"path": "src/main.rs"}),
834 caller: None,
835 thought_signature: None,
836 }],
837 },
838 // No tool result follows — it was removed by compaction.
839 Message {
840 role: Role::User,
841 content: vec![ContentBlock::Text {
842 text: "continue".to_string(),
843 cache_control: None,
844 }],
845 },
846 ];
847
848 let out = build_chat_messages(None, &messages, "deepseek-v4-flash");
849 let assistant = out
850 .iter()
851 .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant"));
852 // The safety net may drop the assistant message entirely if it only
853 // contained orphaned tool_calls and no text content.
854 assert!(
855 assistant.is_none(),
856 "assistant without content/tool_calls should be removed"
857 );
858 assert!(
859 !out.iter()
860 .any(|v| v.get("role").and_then(Value::as_str) == Some("tool")),
861 "orphaned tool results should also be removed"
862 );
863 }
864
865 #[test]
866 fn chat_messages_keeps_valid_tool_calls_intact() {
867 // Complete call+result pair should NOT be stripped.
868 let messages = vec![
869 Message {
870 role: Role::Assistant,
871 content: vec![
872 ContentBlock::Thinking {
873 signature: None,
874 state: None,
875 thinking: "Need to list files".to_string(),
876 },
877 ContentBlock::ToolUse {
878 execution_id: None,
879 id: "tool-ok".to_string(),
880 name: "list_dir".to_string(),
881 input: json!({}),
882 caller: None,
883 thought_signature: None,
884 },
885 ],
886 },
887 Message {
888 role: Role::User,
889 content: vec![ContentBlock::ToolResult {
890 execution_id: None,
891 tool_use_id: "tool-ok".to_string(),
892 content: "files".to_string(),
893 is_error: None,
894 content_blocks: None,
895 }],
896 },
897 ];
898
899 let out = build_chat_messages(None, &messages, "deepseek-v4-flash");
900 let assistant = out
901 .iter()
902 .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant"))
903 .expect("assistant message");
904 assert!(
905 assistant.get("tool_calls").is_some(),
906 "valid tool_calls should remain intact"
907 );
908 assert!(
909 out.iter()
910 .any(|value| value.get("role").and_then(Value::as_str) == Some("tool")),
911 "tool result should remain"
912 );
913 }
914
915 #[test]
916 fn chat_messages_strips_partial_tool_results() {
917 let messages = vec![
918 Message {
919 role: Role::Assistant,
920 content: vec![
921 ContentBlock::ToolUse {
922 execution_id: None,
923 id: "t1".to_string(),
924 name: "read_file".to_string(),
925 input: json!({"path": "a.rs"}),
926 caller: None,
927 thought_signature: None,
928 },
929 ContentBlock::ToolUse {
930 execution_id: None,
931 id: "t2".to_string(),
932 name: "read_file".to_string(),
933 input: json!({"path": "b.rs"}),
934 caller: None,
935 thought_signature: None,
936 },
937 ContentBlock::ToolUse {
938 execution_id: None,
939 id: "t3".to_string(),
940 name: "shell".to_string(),
941 input: json!({"cmd": "ls"}),
942 caller: None,
943 thought_signature: None,
944 },
945 ],
946 },
947 Message {
948 role: Role::User,
949 content: vec![ContentBlock::ToolResult {
950 execution_id: None,
951 tool_use_id: "t1".to_string(),
952 content: "content a".to_string(),
953 is_error: None,
954 content_blocks: None,
955 }],
956 },
957 Message {
958 role: Role::User,
959 content: vec![ContentBlock::ToolResult {
960 execution_id: None,
961 tool_use_id: "t2".to_string(),
962 content: "content b".to_string(),
963 is_error: None,
964 content_blocks: None,
965 }],
966 },
967 // No result for t3
968 Message {
969 role: Role::User,
970 content: vec![ContentBlock::Text {
971 text: "continue".to_string(),
972 cache_control: None,
973 }],
974 },
975 ];
976
977 let out = build_chat_messages(None, &messages, "deepseek-v4-flash");
978 let assistant = out
979 .iter()
980 .find(|v| v.get("role").and_then(Value::as_str) == Some("assistant"));
981 assert!(
982 assistant.is_none(),
983 "assistant with only partial tool_calls should be removed"
984 );
985 assert!(
986 !out.iter()
987 .any(|v| v.get("role").and_then(Value::as_str) == Some("tool")),
988 "all orphaned tool results should be removed"
989 );
990 }
991
992 #[test]
993 fn codewhale_models_listing_carries_the_wire_protocol_per_model() {
994 let payload = r#"{
995 "object": "list",
996 "data": [
997 {"id": "deepseek/deepseek-v4-pro", "object": "model", "owned_by": "deepseek",
998 "codewhale": {"provider": "deepseek", "model": "deepseek-v4-pro",
999 "protocol": "chat-completions",
1000 "endpoint": "/v1/chat/completions",
1001 "default": true, "usable": true}},
1002 {"id": "anthropic/claude-sonnet-5", "object": "model", "owned_by": "anthropic",
1003 "codewhale": {"provider": "anthropic", "model": "claude-sonnet-5",
1004 "protocol": "anthropic-messages",
1005 "endpoint": "/v1/messages", "usable": true}},
1006 {"id": "openai/gpt-5.6", "object": "model", "owned_by": "openai",
1007 "codewhale": {"provider": "openai", "model": "gpt-5.6",
1008 "protocol": "responses",
1009 "endpoint": "/v1/responses", "usable": true}},
1010 {"id": "xai/grok-4.6", "object": "model", "owned_by": "xai",
1011 "codewhale": {"provider": "xai", "model": "grok-4.6",
1012 "protocol": "some-future-wire", "usable": true}},
1013 {"id": "deepseek/deepseek-v4-pro", "object": "model"},
1014 {"id": " ", "object": "model"},
1015 {"id": "anthropic/claude-opus-4-8", "object": "model"}
1016 ]
1017 }"#;
1018
1019 let rows = codewhale_catalog_offerings_from_body(payload, "codewhale", "fp", 7)
1020 .expect("the account listing should parse");
1021 let by_id: std::collections::BTreeMap<&str, &CatalogOffering> = rows
1022 .iter()
1023 .map(|row| (row.wire_model_id.as_str(), row))
1024 .collect();
1025 assert_eq!(rows.len(), 5, "blank and duplicate ids are dropped");
1026 // The protocol comes from the response, not from a compiled roster.
1027 assert_eq!(by_id["deepseek/deepseek-v4-pro"].endpoint_key, "chat");
1028 assert!(by_id["deepseek/deepseek-v4-pro"].default_for_provider);
1029 assert_eq!(by_id["anthropic/claude-sonnet-5"].endpoint_key, "messages");
1030 assert!(!by_id["anthropic/claude-sonnet-5"].default_for_provider);
1031 assert_eq!(by_id["openai/gpt-5.6"].endpoint_key, "responses");
1032 // A protocol label this build predates is not an error: the row falls
1033 // back to the namespace rule so a forward-compatible catalog stays
1034 // reachable.
1035 assert_eq!(by_id["xai/grok-4.6"].endpoint_key, "chat");
1036 // A row with no `codewhale` block still resolves through the namespace
1037 // rule rather than being dropped: the account listed it.
1038 assert_eq!(by_id["anthropic/claude-opus-4-8"].endpoint_key, "messages");
1039 // Nothing is claimed that the account service did not state.
1040 assert!(
1041 rows.iter()
1042 .all(|row| row.canonical_model.is_none() && row.limit.is_none() && row.cost.is_none())
1043 );
1044
1045 assert_eq!(
1046 codewhale_catalog_offerings_from_body(
1047 r#"{"object":"list","data":[]}"#,
1048 "codewhale",
1049 "fp",
1050 7
1051 ),
1052 Err(CatalogRefreshError::EmptyList)
1053 );
1054 assert_eq!(
1055 codewhale_catalog_offerings_from_body("not json", "codewhale", "fp", 7),
1056 Err(CatalogRefreshError::InvalidResponse)
1057 );
1058 }
1059
1060 #[test]
1061 fn parse_models_response_parses_and_deduplicates() {
1062 let payload = r#"{
1063 "object": "list",
1064 "data": [
1065 {"id": "deepseek-v4-pro", "object": "model", "owned_by": "deepseek", "created": 1},
1066 {"id": "deepseek-v4-flash", "object": "model"},
1067 {"id": "deepseek-v4-pro", "object": "model", "owned_by": "deepseek", "created": 1}
1068 ]
1069 }"#;
1070
1071 let models = parse_models_response(payload).expect("parse models");
1072 assert_eq!(
1073 models,
1074 vec![
1075 AvailableModel {
1076 id: "deepseek-v4-flash".to_string(),
1077 owned_by: None,
1078 created: None,
1079 display_name: None
1080 },
1081 AvailableModel {
1082 id: "deepseek-v4-pro".to_string(),
1083 owned_by: Some("deepseek".to_string()),
1084 created: Some(1),
1085 display_name: None
1086 }
1087 ]
1088 );
1089 }
1090
1091 #[test]
1092 fn parse_models_response_accepts_ollama_tag_ids() {
1093 let payload = r#"{
1094 "object": "list",
1095 "data": [
1096 {"id": "qwen2.5-coder:7b", "object": "model", "owned_by": "library"},
1097 {"id": "deepseek-coder-v2:16b", "object": "model"}
1098 ]
1099 }"#;
1100
1101 let models = parse_models_response(payload).expect("parse models");
1102 assert_eq!(
1103 models
1104 .iter()
1105 .map(|model| model.id.as_str())
1106 .collect::<Vec<_>>(),
1107 vec!["deepseek-coder-v2:16b", "qwen2.5-coder:7b"]
1108 );
1109 }
1110
1111 // === #3385: provider live /models fetch + secret-free cache ==============
1112 //
1113 // All model ids below are SYNTHETIC (never real vendor model names), per the
1114 // issue's anti-hardcoding rule.
1115
1116 /// Build a client whose OpenRouter base URL points at a mock server.
1117 pub(super) fn openrouter_client_for(server: &MockServer) -> CodewhaleClient {
1118 let _ = rustls::crypto::ring::default_provider().install_default();
1119 CodewhaleClient::new(&Config {
1120 provider: Some("openrouter".to_string()),
1121 providers: Some(ProvidersConfig {
1122 openrouter: ProviderConfig {
1123 api_key: Some("test-key".to_string()),
1124 base_url: Some(server.uri()),
1125 ..ProviderConfig::default()
1126 },
1127 ..ProvidersConfig::default()
1128 }),
1129 ..Config::default()
1130 })
1131 .expect("openrouter client")
1132 }
1133
1134 pub(super) fn custom_mock_client_for_identity(
1135 server: &MockServer,
1136 identity: &str,
1137 ) -> CodewhaleClient {
1138 let _ = rustls::crypto::ring::default_provider().install_default();
1139 let mut providers = ProvidersConfig::default();
1140 providers.custom.insert(
1141 identity.to_string(),
1142 ProviderConfig {
1143 kind: Some("openai-compatible".to_string()),
1144 api_key: Some("test-custom-key".to_string()),
1145 base_url: Some(format!("{}/v1", server.uri())),
1146 model: Some("synthetic/custom-model".to_string()),
1147 ..ProviderConfig::default()
1148 },
1149 );
1150 CodewhaleClient::new(&Config {
1151 provider: Some(identity.to_string()),
1152 providers: Some(providers),
1153 ..Config::default()
1154 })
1155 .expect("Baseten client")
1156 }
1157
1158 pub(super) fn opencode_go_client_for(server: &MockServer) -> CodewhaleClient {
1159 let _ = rustls::crypto::ring::default_provider().install_default();
1160 CodewhaleClient::new(&Config {
1161 provider: Some("opencode-go".to_string()),
1162 providers: Some(ProvidersConfig {
1163 opencode_go: ProviderConfig {
1164 api_key: Some("test-key".to_string()),
1165 base_url: Some(server.uri()),
1166 ..ProviderConfig::default()
1167 },
1168 ..ProvidersConfig::default()
1169 }),
1170 ..Config::default()
1171 })
1172 .expect("OpenCode Go client")
1173 }
1174
1175 fn telecomjs_client_for(server: &MockServer) -> CodewhaleClient {
1176 let _ = rustls::crypto::ring::default_provider().install_default();
1177 CodewhaleClient::new(&Config {
1178 provider: Some("telecomjs".to_string()),
1179 providers: Some(ProvidersConfig {
1180 telecomjs: ProviderConfig {
1181 api_key: Some("test-key".to_string()),
1182 base_url: Some(server.uri()),
1183 ..ProviderConfig::default()
1184 },
1185 ..ProvidersConfig::default()
1186 }),
1187 ..Config::default()
1188 })
1189 .expect("TelecomJS client")
1190 }
1191
1192 fn edenai_client_for(server: &MockServer) -> CodewhaleClient {
1193 let _ = rustls::crypto::ring::default_provider().install_default();
1194 CodewhaleClient::new(&Config {
1195 provider: Some("edenai".to_string()),
1196 providers: Some(ProvidersConfig {
1197 edenai: ProviderConfig {
1198 api_key: Some("test-key".to_string()),
1199 base_url: Some(format!("{}/v3", server.uri())),
1200 ..ProviderConfig::default()
1201 },
1202 ..ProvidersConfig::default()
1203 }),
1204 ..Config::default()
1205 })
1206 .expect("Eden AI client")
1207 }
1208
1209 pub(super) async fn mount_models_json(server: &MockServer, status: u16, body: serde_json::Value) {
1210 Mock::given(method("GET"))
1211 .and(path("/v1/models"))
1212 .respond_with(ResponseTemplate::new(status).set_body_json(body))
1213 .mount(server)
1214 .await;
1215 }
1216
1217 #[tokio::test]
1218 async fn verify_provider_scenario() {
1219 // Scenario consolidation of: verify_provider_api_key_accepts_mocked_models_success, verify_provider_api_key_returns_status_and_unicode_body_without_panic
1220 // from verify_provider_api_key_accepts_mocked_models_success
1221 {
1222 let server = MockServer::start().await;
1223 Mock::given(method("GET"))
1224 .and(path("/v1/models"))
1225 .and(header("authorization", "Bearer test-key"))
1226 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"data": []})))
1227 .mount(&server)
1228 .await;
1229
1230 verify_provider_api_key(ProviderKind::Openrouter, "test-key", &server.uri())
1231 .await
1232 .expect("mocked /models success should verify");
1233 }
1234 // from verify_provider_api_key_returns_status_and_unicode_body_without_panic
1235 {
1236 let server = MockServer::start().await;
1237 Mock::given(method("GET"))
1238 .and(path("/v1/models"))
1239 .respond_with(ResponseTemplate::new(401).set_body_string("密钥无效"))
1240 .mount(&server)
1241 .await;
1242
1243 let err = verify_provider_api_key(ProviderKind::Openrouter, "bad-key", &server.uri())
1244 .await
1245 .expect_err("mocked /models failure should be reported");
1246
1247 assert!(err.contains("HTTP 401"), "status is preserved: {err}");
1248 assert!(err.contains("密钥无效"), "unicode body is preserved: {err}");
1249 }
1250 }
1251
1252 #[test]
1253 fn opencode_go_client_rejects_unknown_protocol_models() {
1254 let _ = rustls::crypto::ring::default_provider().install_default();
1255 for model in ["claude-unproven", "gpt-unlisted"] {
1256 let config = Config {
1257 provider: Some("opencode-go".to_string()),
1258 providers: Some(ProvidersConfig {
1259 opencode_go: ProviderConfig {
1260 api_key: Some("test-key".to_string()),
1261 model: Some(model.to_string()),
1262 ..ProviderConfig::default()
1263 },
1264 ..ProvidersConfig::default()
1265 }),
1266 ..Config::default()
1267 };
1268 let err = CodewhaleClient::new(&config)
1269 .err()
1270 .expect("unknown protocol must fail before client construction");
1271 assert!(err.to_string().contains(model), "{err:#}");
1272 }
1273 }
1274
1275 #[tokio::test]
1276 async fn opencode_go_live_model_paths_keep_documented_protocol_rows() {
1277 let server = MockServer::start().await;
1278 let mut rows: Vec<_> = crate::config::opencode_go_models()
1279 .iter()
1280 .map(|id| json!({"id": id}))
1281 .collect();
1282 rows.extend([
1283 json!({"id": "minimax-m3"}),
1284 json!({"id": "minimax-m2.7"}),
1285 json!({"id": "minimax-m2.5"}),
1286 json!({"id": "qwen3.7-max"}),
1287 json!({"id": "qwen3.7-plus"}),
1288 json!({"id": "qwen3.6-plus"}),
1289 ]);
1290 mount_models_json(&server, 200, json!({"data": rows})).await;
1291 let client = opencode_go_client_for(&server);
1292
1293 let listed = client.list_models().await.expect("filtered model list");
1294 let listed: std::collections::BTreeSet<_> = listed.into_iter().map(|model| model.id).collect();
1295 let expected: std::collections::BTreeSet<_> = crate::config::opencode_go_models()
1296 .iter()
1297 .map(|model| (*model).to_string())
1298 .collect();
1299 assert_eq!(listed, expected);
1300
1301 let delta = client.fetch_catalog_delta().await.expect("filtered delta");
1302 assert_eq!(delta.provider, "opencode-go");
1303 let delta_ids: std::collections::BTreeSet<_> = delta
1304 .offerings
1305 .iter()
1306 .map(|offering| offering.wire_model_id.clone())
1307 .collect();
1308 assert_eq!(delta_ids, expected);
1309 assert!(
1310 delta
1311 .offerings
1312 .iter()
1313 .all(|offering| Some(offering.endpoint_key.as_str())
1314 == codewhale_config::opencode_go_endpoint_key(&offering.wire_model_id))
1315 );
1316 }
1317
1318 #[tokio::test]
1319 async fn telecomjs_live_catalog_keeps_cross_provider_metadata_unknown() {
1320 let server = MockServer::start().await;
1321 let ambiguous_id = codewhale_config::catalog::bundled_catalog_offerings()
1322 .into_iter()
1323 .find(|offering| {
1324 !offering.provider.eq_ignore_ascii_case("telecomjs")
1325 && !offering
1326 .wire_model_id
1327 .eq_ignore_ascii_case(DEFAULT_TELECOMJS_MODEL)
1328 && (offering.canonical_model.is_some()
1329 || offering.family.is_some()
1330 || offering.limit.is_some()
1331 || offering.cost.is_some()
1332 || offering.reasoning.is_some()
1333 || offering.tool_call.is_some())
1334 })
1335 .expect("bundled catalog should contain a metadata-bearing non-TelecomJS row")
1336 .wire_model_id;
1337 Mock::given(method("GET"))
1338 .and(path("/v1/models"))
1339 .and(header("authorization", "Bearer test-key"))
1340 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1341 "data": [
1342 {"id": ambiguous_id.clone()},
1343 {"id": DEFAULT_TELECOMJS_MODEL}
1344 ]
1345 })))
1346 .mount(&server)
1347 .await;
1348
1349 let delta = telecomjs_client_for(&server)
1350 .fetch_catalog_delta()
1351 .await
1352 .expect("TelecomJS catalog delta");
1353 assert_eq!(delta.provider, "telecomjs");
1354 assert_eq!(delta.offerings.len(), 2);
1355
1356 let ambiguous = delta
1357 .offerings
1358 .iter()
1359 .find(|offering| offering.wire_model_id == ambiguous_id)
1360 .expect("ambiguous cross-provider id");
1361 assert!(!ambiguous.default_for_provider);
1362 assert_eq!(ambiguous.endpoint_key, "chat");
1363 assert_eq!(ambiguous.canonical_model, None);
1364 assert_eq!(ambiguous.family, None);
1365 assert_eq!(ambiguous.limit, None);
1366 assert_eq!(ambiguous.cost, None);
1367 assert_eq!(ambiguous.modalities, None);
1368 assert_eq!(ambiguous.attachment, None);
1369 assert_eq!(ambiguous.reasoning, None);
1370 assert_eq!(ambiguous.tool_call, None);
1371 assert_eq!(ambiguous.structured_output, None);
1372 assert!(ambiguous.reasoning_options.is_empty());
1373 assert!(matches!(ambiguous.source, CatalogSource::Live { .. }));
1374
1375 let default = delta
1376 .offerings
1377 .iter()
1378 .find(|offering| offering.wire_model_id == DEFAULT_TELECOMJS_MODEL)
1379 .expect("TelecomJS default row");
1380 assert!(default.default_for_provider);
1381 }
1382
1383 #[tokio::test]
1384 async fn edenai_live_catalog_marks_the_default_and_keeps_unknowns_unclaimed() {
1385 let server = MockServer::start().await;
1386 Mock::given(method("GET"))
1387 .and(path("/v3/models"))
1388 .and(header("authorization", "Bearer test-key"))
1389 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1390 "data": [
1391 {"id": "synthetic/vendor-model"},
1392 {"id": DEFAULT_EDENAI_MODEL}
1393 ]
1394 })))
1395 .mount(&server)
1396 .await;
1397
1398 let delta = edenai_client_for(&server)
1399 .fetch_catalog_delta()
1400 .await
1401 .expect("Eden AI catalog delta");
1402 assert_eq!(delta.provider, "edenai");
1403 assert_eq!(delta.offerings.len(), 2);
1404
1405 let unknown = delta
1406 .offerings
1407 .iter()
1408 .find(|offering| offering.wire_model_id == "synthetic/vendor-model")
1409 .expect("synthetic Eden AI row");
1410 assert_eq!(unknown.canonical_model, None);
1411 assert_eq!(unknown.reasoning, None);
1412 assert_eq!(unknown.tool_call, None);
1413 assert!(!unknown.default_for_provider);
1414
1415 let default = delta
1416 .offerings
1417 .iter()
1418 .find(|offering| offering.wire_model_id == DEFAULT_EDENAI_MODEL)
1419 .expect("Eden AI default row");
1420 assert!(default.default_for_provider);
1421 }
1422
1423 #[tokio::test]
1424 async fn fetch_catalog_delta_success_builds_scoped_secret_free_live_delta() {
1425 let server = MockServer::start().await;
1426 mount_models_json(
1427 &server,
1428 200,
1429 json!({"data": [
1430 {"id": "synthetic-model-alpha", "owned_by": "synthetic-owner"},
1431 {"id": "synthetic-model-beta"}
1432 ]}),
1433 )
1434 .await;
1435 let client = openrouter_client_for(&server);
1436
1437 let delta = client.fetch_catalog_delta().await.expect("delta");
1438 assert_eq!(delta.provider, "openrouter");
1439 assert_eq!(
1440 delta.base_url_fingerprint,
1441 base_url_fingerprint(&server.uri()),
1442 "delta is scoped to the base-URL fingerprint"
1443 );
1444 let ids: Vec<&str> = delta
1445 .offerings
1446 .iter()
1447 .map(|offering| offering.wire_model_id.as_str())
1448 .collect();
1449 assert!(ids.contains(&"synthetic-model-alpha"), "ids: {ids:?}");
1450 assert!(ids.contains(&"synthetic-model-beta"), "ids: {ids:?}");
1451 for offering in &delta.offerings {
1452 // Live rows carry honest provenance and no inferred facts/secrets.
1453 assert!(matches!(offering.source, CatalogSource::Live { .. }));
1454 assert_eq!(offering.canonical_model, None);
1455 assert_eq!(offering.cost, None);
1456 assert!(offering.reasoning.is_none());
1457 }
1458 }
1459
1460 /// #6690: rows shaped like the live OpenRouter roster. A `~` "latest"
1461 /// alias, a router's `"-1"` variable price, an undecodable row and an id
1462 /// the catalog cannot hold each used to fail the whole refresh closed
1463 /// (`invalid_response`), so the lake never went fresh and no OpenRouter
1464 /// turn could be priced. Each now costs only its own row.
1465 #[tokio::test]
1466 async fn fetch_catalog_delta_skips_openrouter_latest_aliases_and_keeps_prices() {
1467 let server = MockServer::start().await;
1468 mount_models_json(
1469 &server,
1470 200,
1471 json!({"data": [
1472 {"id": "~deepseek/deepseek-pro-latest",
1473 "pricing": {"prompt": "0.0000002523", "completion": "0.0000035",
1474 "input_cache_read": "0.0000002518"}},
1475 {"id": "openrouter/auto", "context_length": 2000000,
1476 "pricing": {"prompt": "-1", "completion": "-1"},
1477 "top_provider": {"context_length": null, "max_completion_tokens": null}},
1478 {"id": "synthetic/malformed-row", "context_length": "very long",
1479 "pricing": {"prompt": "0.000001", "completion": "0.000002"}},
1480 {"id": "synthetic/unreadable-price",
1481 "pricing": {"prompt": "not-a-number", "completion": "0.000002"}},
1482 {"id": "synthetic/bad id"},
1483 {"id": "deepseek/deepseek-v4-pro", "context_length": 1048576,
1484 "pricing": {"prompt": "0.00000095526", "completion": "0.00000191052",
1485 "input_cache_read": "0.000000079605"},
1486 "top_provider": {"context_length": 1024000, "max_completion_tokens": 384000}}
1487 ]}),
1488 )
1489 .await;
1490 let client = openrouter_client_for(&server);
1491
1492 let delta = client
1493 .fetch_catalog_delta()
1494 .await
1495 .expect("a bad row must not fail the whole roster");
1496 let ids: Vec<&str> = delta
1497 .offerings
1498 .iter()
1499 .map(|offering| offering.wire_model_id.as_str())
1500 .collect();
1501 assert_eq!(ids, ["openrouter/auto", "deepseek/deepseek-v4-pro"]);
1502 assert_eq!(delta.offerings[0].cost, None, "router price is variable");
1503 let cost = delta.offerings[1].cost.as_ref().expect("served price");
1504 let close = |got: Option<f64>, want: f64| got.is_some_and(|got| (got - want).abs() < 1e-9);
1505 assert!(close(cost.input, 0.95526), "{cost:?}");
1506 assert!(close(cost.output, 1.91052), "{cost:?}");
1507 assert!(close(cost.cache_read, 0.079605), "{cost:?}");
1508 assert_eq!(cost.cache_write, None);
1509
1510 // Only structurally invalid responses still fail the refresh.
1511 for body in [
1512 json!({"data": {"id": "deepseek/deepseek-v4-pro"}}),
1513 json!({"models": []}),
1514 json!({"data": [{"name": "no id"}, {"id": "synthetic/bad id"}]}),
1515 ] {
1516 let server = MockServer::start().await;
1517 mount_models_json(&server, 200, body.clone()).await;
1518 assert_eq!(
1519 openrouter_client_for(&server)
1520 .fetch_catalog_delta()
1521 .await
1522 .expect_err("structurally invalid roster"),
1523 CatalogRefreshError::InvalidResponse,
1524 "{body}"
1525 );
1526 }
1527 }
1528
1529 #[tokio::test]
1530 async fn fetch_catalog_scenario() {
1531 // Scenario consolidation of: fetch_catalog_delta_maps_http_statuses_to_typed_errors, fetch_catalog_delta_maps_invalid_json_and_empty_list
1532 // from fetch_catalog_delta_maps_http_statuses_to_typed_errors
1533 {
1534 for (status, expected) in [
1535 (401u16, CatalogRefreshError::Unauthorized),
1536 (403, CatalogRefreshError::Forbidden),
1537 (404, CatalogRefreshError::NotFound),
1538 (429, CatalogRefreshError::RateLimited),
1539 (500, CatalogRefreshError::Network),
1540 ] {
1541 let server = MockServer::start().await;
1542 mount_models_json(&server, status, json!({"error": "nope"})).await;
1543 let client = openrouter_client_for(&server);
1544 let err = client.fetch_catalog_delta().await.expect_err("should fail");
1545 assert_eq!(err, expected, "status {status} should map to {expected:?}");
1546 }
1547 }
1548 // from fetch_catalog_delta_maps_invalid_json_and_empty_list
1549 {
1550 // Invalid JSON -> InvalidResponse.
1551 let server = MockServer::start().await;
1552 Mock::given(method("GET"))
1553 .and(path("/v1/models"))
1554 .respond_with(ResponseTemplate::new(200).set_body_string("not json"))
1555 .mount(&server)
1556 .await;
1557 let client = openrouter_client_for(&server);
1558 assert_eq!(
1559 client
1560 .fetch_catalog_delta()
1561 .await
1562 .expect_err("invalid json"),
1563 CatalogRefreshError::InvalidResponse
1564 );
1565
1566 // Empty list -> EmptyList.
1567 let server = MockServer::start().await;
1568 mount_models_json(&server, 200, json!({"data": []})).await;
1569 let client = openrouter_client_for(&server);
1570 assert_eq!(
1571 client.fetch_catalog_delta().await.expect_err("empty list"),
1572 CatalogRefreshError::EmptyList
1573 );
1574 }
1575 }
1576
1576 lines RUST