返回 CodeWhale
test_cases_03.rs
根目录 / crates / tui / src / client / test_cases_03.rs
1
2 /// A catalog row stating `codewhale.protocol = "responses"` must dispatch
3 /// to the account API's Responses surface — `{base}/responses` — not the
4 /// Chat Completions default its `openai/` namespace alone would imply.
5 #[tokio::test]
6 async fn codewhale_responses_catalog_row_dispatches_to_responses_endpoint() {
7 let _env = crate::test_support::lock_test_env();
8 let _live = crate::provider_lake::lock_live_snapshot();
9 let home = tempfile::tempdir().expect("home");
10 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
11 crate::provider_catalog_live::reset_cache_for_test();
12 crate::provider_lake::clear_live_snapshot();
13
14 let server = MockServer::start().await;
15 Mock::given(method("POST"))
16 .and(path("/v1/responses"))
17 .respond_with(
18 ResponseTemplate::new(200)
19 .insert_header("Content-Type", "text/event-stream")
20 .set_body_string(concat!(
21 "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"",
22 ",\"usage\":{\"input_tokens\":3,\"output_tokens\":1}}}\n\n",
23 "data: [DONE]\n\n"
24 )),
25 )
26 .expect(1)
27 .mount(&server)
28 .await;
29
30 // Seed the account catalog through the same refresh seam the runtime
31 // uses, so the wire choice comes from the row's stated protocol.
32 let fingerprint = base_url_fingerprint(&server.uri());
33 let offerings = codewhale_catalog_offerings_from_body(
34 r#"{"object":"list","data":[
35 {"id":"openai/gpt-5.6","object":"model","owned_by":"openai",
36 "codewhale":{"provider":"openai","model":"gpt-5.6",
37 "protocol":"responses","endpoint":"/v1/responses",
38 "default":true,"usable":true}}
39 ]}"#,
40 "codewhale",
41 &fingerprint,
42 now_unix(),
43 )
44 .expect("fixture catalog parses");
45 crate::provider_catalog_live::record_success(ProviderCatalogDelta {
46 provider: "codewhale".to_string(),
47 base_url_fingerprint: fingerprint,
48 fetched_at: now_unix(),
49 offerings,
50 });
51
52 let mut client = codewhale_client(&server, "openai/gpt-5.6");
53 assert_eq!(client.wire_format, WireFormat::Responses);
54 client.retry.enabled = false;
55 let response = client
56 .create_message(minimal_zen_request("openai/gpt-5.6"))
57 .await
58 .expect("Codewhale responses request should succeed");
59
60 // Responses usage arrives on the terminal `response.completed` event —
61 // the dialect's equivalent of chat's `stream_options.include_usage`.
62 assert_eq!(response.usage.input_tokens, 3);
63 assert_eq!(response.usage.output_tokens, 1);
64 let requests = server.received_requests().await.expect("recorded request");
65 assert_eq!(requests.len(), 1);
66 assert_codewhale_bearer(&requests[0]);
67 let body: Value = serde_json::from_slice(&requests[0].body).expect("responses JSON body");
68 assert_eq!(
69 body.get("model").and_then(Value::as_str),
70 Some("openai/gpt-5.6")
71 );
72 assert!(body.get("input").is_some(), "Responses body: {body}");
73 assert!(body.get("messages").is_none(), "Responses body: {body}");
74
75 crate::provider_catalog_live::reset_cache_for_test();
76 crate::provider_lake::clear_live_snapshot();
77 }
78
79 fn opencode_zen_client(server: &MockServer, model: &str) -> CodewhaleClient {
80 let config = Config {
81 provider: Some("opencode-zen".to_string()),
82 providers: Some(ProvidersConfig {
83 opencode_zen: ProviderConfig {
84 api_key: Some("zen-test-key".to_string()),
85 base_url: Some(server.uri()),
86 model: Some(model.to_string()),
87 ..ProviderConfig::default()
88 },
89 ..ProvidersConfig::default()
90 }),
91 ..Config::default()
92 };
93 CodewhaleClient::new(&config).expect("OpenCode Zen client should resolve its model route")
94 }
95
96 fn minimal_zen_request(model: &str) -> MessageRequest {
97 translation_message_request("hello", model.to_string(), "English", 4096)
98 }
99
100 fn assert_zen_bearer_without_codex_headers(request: &wiremock::Request) {
101 assert_eq!(
102 request
103 .headers
104 .get(AUTHORIZATION)
105 .and_then(|value| value.to_str().ok()),
106 Some("Bearer zen-test-key")
107 );
108 for forbidden in [
109 "openai-beta",
110 "originator",
111 "chatgpt-account-id",
112 "x-api-key",
113 ] {
114 assert!(
115 request.headers.get(forbidden).is_none(),
116 "Zen request must not include {forbidden}"
117 );
118 }
119 }
120
121 fn assert_zen_messages_api_key_without_bearer(request: &wiremock::Request) {
122 assert_eq!(
123 request
124 .headers
125 .get("x-api-key")
126 .and_then(|value| value.to_str().ok()),
127 Some("zen-test-key")
128 );
129 assert!(
130 request.headers.get(AUTHORIZATION).is_none(),
131 "Zen Messages request must not include Authorization"
132 );
133 for forbidden in ["openai-beta", "originator", "chatgpt-account-id"] {
134 assert!(
135 request.headers.get(forbidden).is_none(),
136 "Zen request must not include {forbidden}"
137 );
138 }
139 }
140
141 #[tokio::test]
142 async fn opencode_go_dispatches_all_three_wires_with_gateway_auth_and_stable_session() {
143 let mut session = None;
144 for (model, wire, endpoint) in [
145 (
146 "deepseek-v4-pro",
147 WireFormat::ChatCompletions,
148 "/zen/go/v1/chat/completions",
149 ),
150 ("grok-4.6", WireFormat::Responses, "/zen/go/v1/responses"),
151 (
152 "minimax-m3",
153 WireFormat::AnthropicMessages,
154 "/zen/go/v1/messages",
155 ),
156 ] {
157 let server = MockServer::start().await;
158 let response = match wire {
159 WireFormat::Responses => ResponseTemplate::new(200)
160 .insert_header("Content-Type", "text/event-stream")
161 .set_body_string("data: [DONE]\n\n"),
162 WireFormat::AnthropicMessages => ResponseTemplate::new(200).set_body_json(json!({
163 "id":"msg_go", "type":"message", "role":"assistant",
164 "content":[{"type":"text", "text":"ok"}], "model":model,
165 "stop_reason":"end_turn", "stop_sequence":null,
166 "usage":{"input_tokens":3,"output_tokens":1}
167 })),
168 WireFormat::ChatCompletions => ResponseTemplate::new(200).set_body_json(json!({
169 "id":"chat_go", "object":"chat.completion", "created":1, "model":model,
170 "choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],
171 "usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}
172 })),
173 };
174 Mock::given(method("POST"))
175 .and(path(endpoint))
176 .respond_with(response)
177 .expect(1)
178 .mount(&server)
179 .await;
180 let mut client = CodewhaleClient::new(&Config {
181 provider: Some("opencode-go".into()),
182 providers: Some(ProvidersConfig {
183 opencode_go: ProviderConfig {
184 api_key: Some("go-test-key".into()),
185 base_url: Some(format!("{}/zen/go/v1", server.uri())),
186 model: Some(format!("opencode-go/{model}")),
187 ..ProviderConfig::default()
188 },
189 ..ProvidersConfig::default()
190 }),
191 ..Config::default()
192 })
193 .expect("Go client resolves the model protocol");
194 client.retry.enabled = false;
195 assert_eq!(client.wire_format, wire);
196 if wire == WireFormat::Responses {
197 let mut stream = client
198 .create_message_stream(minimal_zen_request(model))
199 .await
200 .unwrap();
201 while let Some(event) = stream.next().await {
202 event.unwrap();
203 }
204 assert!(
205 client
206 .prepare_outbound_request(minimal_zen_request("minimax-m3"), false)
207 .is_err(),
208 "a request cannot silently change an existing client's protocol"
209 );
210 } else {
211 client
212 .create_message(minimal_zen_request(model))
213 .await
214 .unwrap();
215 }
216 let requests = server.received_requests().await.unwrap();
217 assert_eq!(requests.len(), 1);
218 let request = &requests[0];
219 let header = |name: &str| {
220 request
221 .headers
222 .get(name)
223 .and_then(|value| value.to_str().ok())
224 };
225 let observed_session = header("x-opencode-session").expect("stable session header");
226 assert!(!observed_session.is_empty());
227 if let Some(previous) = &session {
228 assert_eq!(observed_session, previous);
229 }
230 session = Some(observed_session.to_string());
231 assert!(
232 header("user-agent")
233 .is_some_and(|value| value.contains("Codewhale") || value.contains("codewhale"))
234 );
235 for forbidden in ["openai-beta", "originator", "chatgpt-account-id"] {
236 assert!(
237 header(forbidden).is_none(),
238 "gateway requests cannot carry {forbidden}"
239 );
240 }
241 let body: Value = serde_json::from_slice(&request.body).unwrap();
242 assert_eq!(body["model"], model);
243 if wire == WireFormat::AnthropicMessages {
244 assert_eq!(header("x-api-key"), Some("go-test-key"));
245 assert_eq!(header("anthropic-version"), Some("2023-06-01"));
246 assert!(header("authorization").is_none());
247 assert!(body.get("messages").is_some());
248 } else {
249 assert_eq!(header("authorization"), Some("Bearer go-test-key"));
250 assert!(header("x-api-key").is_none());
251 assert!(
252 body.get(if wire == WireFormat::Responses {
253 "input"
254 } else {
255 "messages"
256 })
257 .is_some()
258 );
259 }
260 }
261 }
262
263 #[tokio::test]
264 async fn opencode_zen_responses_request_uses_responses_route_without_oauth_headers() {
265 let server = MockServer::start().await;
266 Mock::given(method("POST"))
267 .and(path("/v1/responses"))
268 .respond_with(
269 ResponseTemplate::new(200)
270 .insert_header("Content-Type", "text/event-stream")
271 .set_body_string("data: [DONE]\n\n"),
272 )
273 .expect(1)
274 .mount(&server)
275 .await;
276
277 let client = opencode_zen_client(&server, "gpt-5.5");
278 assert_eq!(client.wire_format, WireFormat::Responses);
279 let mut stream = client
280 .create_message_stream(minimal_zen_request("gpt-5.5"))
281 .await
282 .expect("Zen Responses request should start");
283 while let Some(event) = stream.next().await {
284 event.expect("Zen Responses stream event");
285 }
286
287 let requests = server.received_requests().await.expect("recorded request");
288 assert_eq!(requests.len(), 1);
289 assert_zen_bearer_without_codex_headers(&requests[0]);
290 let body: Value = serde_json::from_slice(&requests[0].body).expect("Responses JSON body");
291 assert_eq!(body.get("model").and_then(Value::as_str), Some("gpt-5.5"));
292 assert!(body.get("input").is_some(), "Responses body: {body}");
293 assert!(body.get("messages").is_none(), "Responses body: {body}");
294 }
295
296 #[tokio::test]
297 async fn opencode_zen_messages_request_shape_uses_api_key_anthropic_route() {
298 let server = MockServer::start().await;
299 Mock::given(method("POST"))
300 .and(path("/v1/messages"))
301 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
302 "id": "msg_zen",
303 "type": "message",
304 "role": "assistant",
305 "content": [{"type": "text", "text": "ok"}],
306 "model": "claude-sonnet-4-6",
307 "stop_reason": "end_turn",
308 "stop_sequence": null,
309 "usage": {"input_tokens": 1, "output_tokens": 1}
310 })))
311 .expect(1)
312 .mount(&server)
313 .await;
314
315 let client = opencode_zen_client(&server, "claude-sonnet-4-6");
316 assert_eq!(client.wire_format, WireFormat::AnthropicMessages);
317 client
318 .create_message(minimal_zen_request("claude-sonnet-4-6"))
319 .await
320 .expect("Zen Messages request should succeed");
321
322 let requests = server.received_requests().await.expect("recorded request");
323 assert_eq!(requests.len(), 1);
324 assert_zen_messages_api_key_without_bearer(&requests[0]);
325 assert_eq!(
326 requests[0]
327 .headers
328 .get("anthropic-version")
329 .and_then(|value| value.to_str().ok()),
330 Some("2023-06-01")
331 );
332 }
333
334 #[tokio::test]
335 async fn opencode_zen_chat_request_uses_chat_completions_route() {
336 let server = MockServer::start().await;
337 Mock::given(method("POST"))
338 .and(path("/v1/chat/completions"))
339 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
340 "id": "chatcmpl_zen",
341 "object": "chat.completion",
342 "model": "deepseek-v4-pro",
343 "choices": [{
344 "index": 0,
345 "message": {"role": "assistant", "content": "ok"},
346 "finish_reason": "stop"
347 }],
348 "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
349 })))
350 .expect(1)
351 .mount(&server)
352 .await;
353
354 let client = opencode_zen_client(&server, "deepseek-v4-pro");
355 assert_eq!(client.wire_format, WireFormat::ChatCompletions);
356 client
357 .create_message(minimal_zen_request("deepseek-v4-pro"))
358 .await
359 .expect("Zen Chat Completions request should succeed");
360
361 let requests = server.received_requests().await.expect("recorded request");
362 assert_eq!(requests.len(), 1);
363 assert_zen_bearer_without_codex_headers(&requests[0]);
364 assert!(requests[0].headers.get("anthropic-version").is_none());
365 }
366
367 #[tokio::test]
368 async fn opencode_zen_client_fails_closed_when_request_model_changes_protocol() {
369 let server = MockServer::start().await;
370 let client = opencode_zen_client(&server, "gpt-5.5");
371
372 let error = client
373 .create_message(minimal_zen_request("claude-sonnet-4-6"))
374 .await
375 .expect_err("a Responses-bound client must not send a Messages model");
376 assert!(format!("{error:#}").contains("resolve a new model route"));
377 assert!(
378 server
379 .received_requests()
380 .await
381 .expect("recorded requests")
382 .is_empty()
383 );
384 }
385
386 const CONFIG_SECRET_SENTINELS: [&str; 8] = [
387 "deepseek-config-secret-001",
388 "arcee-config-secret-002",
389 "moonshot-config-secret-003",
390 "openrouter-config-secret-004",
391 "together-config-secret-005",
392 "xiaomi-config-secret-006",
393 "zai-active-config-secret-007",
394 "sakana-config-secret-008",
395 ];
396
397 #[test]
398 fn chatgpt_client_rejects_external_tokens_and_uses_its_owned_grant() {
399 let _env = crate::test_support::lock_test_env();
400 let temp = tempfile::tempdir().expect("credential fixture");
401 let root = temp
402 .path()
403 .canonicalize()
404 .expect("canonical credential home");
405 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &root);
406 let _ambient =
407 crate::test_support::EnvVarGuard::set("OPENAI_CODEX_ACCESS_TOKEN", "external-token");
408 let mut config = Config {
409 provider: Some("openai-codex".to_string()),
410 ..Config::default()
411 };
412 crate::external_credentials::reset_side_effect_trap();
413 assert!(CodewhaleClient::new(&config).is_err());
414 assert_eq!(
415 crate::external_credentials::complete_side_effect_trap_counts(),
416 (0, 0, 0, 0, 0)
417 );
418 let token =
419 crate::oauth::install_test_chatgpt_registration(&mut config).expect("owned registration");
420 crate::external_credentials::reset_side_effect_trap();
421 let client = CodewhaleClient::new(&config).expect("official ChatGPT client");
422 assert_eq!(client.api_key, token);
423 assert_eq!(client.base_url, "https://api.openai.com/v1");
424 assert_eq!(
425 crate::external_credentials::complete_side_effect_trap_counts(),
426 (0, 0, 0, 0, 0)
427 );
428 }
429
430 #[test]
431 fn chatgpt_reasoning_scope_separates_account_workspace_and_custom_routes() {
432 let _env = crate::test_support::lock_test_env();
433 let temp = tempfile::tempdir().unwrap();
434 let root = temp
435 .path()
436 .canonicalize()
437 .expect("canonical credential home");
438 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &root);
439 let mut config = Config {
440 provider: Some("openai-codex".into()),
441 ..Default::default()
442 };
443 let mut scopes = Vec::new();
444 for (subject, client_id) in [
445 ("account-a", "oaiapp_workspace_a"),
446 ("account-b", "oaiapp_workspace_a"),
447 ("account-a", "oaiapp_workspace_b"),
448 ] {
449 crate::oauth::install_test_chatgpt_registration_for(&mut config, subject, client_id)
450 .unwrap();
451 let client = CodewhaleClient::new(&config).unwrap();
452 assert_eq!(
453 client.chatgpt_reasoning_api,
454 client.clone().chatgpt_reasoning_api
455 );
456 scopes.push(client.chatgpt_reasoning_api.unwrap());
457 }
458 assert_ne!(scopes[0], scopes[1]);
459 assert_ne!(scopes[0], scopes[2]);
460 assert!(
461 scopes
462 .iter()
463 .all(|scope| scope.starts_with("openai-responses-siwc-v1:")
464 && !scope.contains("account-"))
465 );
466 let provider = config
467 .provider_config_for_mut(&config.test_identity_for_kind(ProviderKind::OpenaiCodex))
468 .unwrap();
469 provider.base_url = Some("http://127.0.0.1:9/v1".into());
470 provider.api_key = Some("configured-fixture-key".into());
471 provider.auth_mode = None;
472 provider.oauth_credential_generation = None;
473 assert!(
474 CodewhaleClient::new(&config)
475 .unwrap()
476 .chatgpt_reasoning_api
477 .is_none()
478 );
479 }
480
481 #[test]
482 fn chatgpt_roster_preserves_visible_account_order_and_labels() {
483 let roster = parse_models_response_for_provider(
484 r#"{"models":[
485 {"slug":"gpt-z","display_name":"GPT Z","visibility":"list"},
486 {"slug":"gpt-hidden","display_name":"Hidden","visibility":"hidden"},
487 {"slug":"gpt-a","display_name":"GPT A","visibility":"list"},
488 {"slug":"gpt-z","display_name":"Duplicate","visibility":"list"}
489 ]}"#,
490 ProviderKind::OpenaiCodex,
491 )
492 .expect("roster");
493 assert_eq!(
494 roster.iter().map(|row| row.id.as_str()).collect::<Vec<_>>(),
495 ["gpt-z", "gpt-a"]
496 );
497 assert_eq!(roster[0].display_name.as_deref(), Some("GPT Z"));
498 assert!(
499 parse_models_response_for_provider(
500 r#"{"data":[{"id":"gpt-z"}]}"#,
501 ProviderKind::OpenaiCodex
502 )
503 .is_err()
504 );
505 assert!(
506 parse_models_response_for_provider(
507 r#"{"models":[{"slug":"gpt-bad\n","display_name":"Bad","visibility":"list"}]}"#,
508 ProviderKind::OpenaiCodex
509 )
510 .is_err()
511 );
512 }
513
514 fn client_with_config_secret_sentinels() -> CodewhaleClient {
515 let _ = rustls::crypto::ring::default_provider().install_default();
516 CodewhaleClient::new(
517 &Config {
518 provider: Some("zai".to_string()),
519 providers: Some(ProvidersConfig {
520 arcee: ProviderConfig {
521 api_key: Some(CONFIG_SECRET_SENTINELS[1].to_string()),
522 ..ProviderConfig::default()
523 },
524 moonshot: ProviderConfig {
525 api_key: Some(CONFIG_SECRET_SENTINELS[2].to_string()),
526 ..ProviderConfig::default()
527 },
528 openrouter: ProviderConfig {
529 api_key: Some(CONFIG_SECRET_SENTINELS[3].to_string()),
530 ..ProviderConfig::default()
531 },
532 together: ProviderConfig {
533 api_key: Some(CONFIG_SECRET_SENTINELS[4].to_string()),
534 ..ProviderConfig::default()
535 },
536 xiaomi_mimo: ProviderConfig {
537 api_key: Some(CONFIG_SECRET_SENTINELS[5].to_string()),
538 ..ProviderConfig::default()
539 },
540 zai: ProviderConfig {
541 api_key: Some(CONFIG_SECRET_SENTINELS[6].to_string()),
542 ..ProviderConfig::default()
543 },
544 sakana: ProviderConfig {
545 api_key: Some(CONFIG_SECRET_SENTINELS[7].to_string()),
546 ..ProviderConfig::default()
547 },
548 ..ProvidersConfig::default()
549 }),
550 ..Config::default()
551 }
552 .with_legacy_root(Some(CONFIG_SECRET_SENTINELS[0].to_string()), None),
553 )
554 .expect("client with secret sentinels")
555 }
556
557 fn request_with_tool_result(content: impl Into<String>) -> MessageRequest {
558 MessageRequest {
559 model: "glm-5.2".to_string(),
560 messages: vec![
561 Message {
562 role: Role::Assistant,
563 content: vec![ContentBlock::ToolUse {
564 execution_id: None,
565 id: "call-secret-test".to_string(),
566 name: "read_file".to_string(),
567 input: json!({"path": "config.toml"}),
568 caller: None,
569 thought_signature: None,
570 }],
571 },
572 Message {
573 role: Role::User,
574 content: vec![ContentBlock::ToolResult {
575 execution_id: None,
576 tool_use_id: "call-secret-test".to_string(),
577 content: content.into(),
578 is_error: None,
579 content_blocks: None,
580 }],
581 },
582 ],
583 max_tokens: 128,
584 system: None,
585 tools: None,
586 tool_choice: None,
587 metadata: None,
588 thinking: None,
589 reasoning_effort: None,
590 stream: None,
591 temperature: None,
592 top_p: None,
593 }
594 }
595
596 fn tool_result_content(request: &MessageRequest) -> &str {
597 request
598 .messages
599 .iter()
600 .flat_map(|message| &message.content)
601 .find_map(|block| match block {
602 ContentBlock::ToolResult { content, .. } => Some(content.as_str()),
603 _ => None,
604 })
605 .expect("tool result content")
606 }
607
608 #[test]
609 fn model_bound_request_repairs_dangling_tool_call_before_adapter_projection() {
610 let client = client_with_config_secret_sentinels();
611 let mut request = request_with_tool_result("unused");
612 request.messages.pop();
613
614 let prepared = client.prepare_model_bound_request(request);
615
616 assert!(prepared.messages.iter().any(|message| {
617 message.content.iter().any(|block| {
618 matches!(
619 block,
620 ContentBlock::ToolResult {
621 tool_use_id,
622 content,
623 is_error: Some(true),
624 ..
625 } if tool_use_id == "call-secret-test"
626 && content.contains("crashed_and_repaired")
627 )
628 })
629 }));
630 assert_eq!(
631 prepared.messages.last().expect("repaired result").role,
632 "user"
633 );
634 assert!(!prepared.messages.iter().any(|message| {
635 message.content.iter().any(|block| {
636 matches!(
637 block,
638 ContentBlock::Text { text, .. }
639 if text.contains("[tool_history_repair]")
640 )
641 })
642 }));
643 }
644
645 #[test]
646 fn model_bound_tool_results_keep_ordinary_code_byte_exact() {
647 // #5546: key-only hits in source files must reach the model unchanged
648 // so exact-match edits and read-back verification keep working.
649 let client = client_with_config_secret_sentinels();
650 let source = "\
651 \"jsonwebtoken\": \"^9.0.2\",
652 password: credentials?.password,
653 token = generate_verification_token()
654 secret: process.env.NEXTAUTH_SECRET!,
655 {\"id\":1, \"password\": \"x\", \"language\": \"en\"}
656 ";
657 let prepared = client.prepare_model_bound_request(request_with_tool_result(source.to_string()));
658 assert_eq!(tool_result_content(&prepared), source);
659
660 // A configured credential and a credential-shaped value are still hidden.
661 let leaking = format!(
662 "api_key = \"{}\"\nsession = \"{}\"\n",
663 CONFIG_SECRET_SENTINELS[0],
664 ["sk-", "abcdef1234567890abcdef"].concat()
665 );
666 let prepared = client.prepare_model_bound_request(request_with_tool_result(leaking));
667 let content = tool_result_content(&prepared);
668 assert!(!content.contains(CONFIG_SECRET_SENTINELS[0]));
669 assert!(!content.contains("abcdef1234567890abcdef"));
670 assert_eq!(
671 content
672 .matches(codewhale_config::persistence::REDACTED)
673 .count(),
674 2
675 );
676 }
677
678 #[test]
679 fn model_bound_scenario() {
680 // Scenario consolidation of: model_bound_request_redacts_configured_secrets_and_bare_active_key, model_bound_request_leaves_ordinary_tool_output_unchanged
681 // from model_bound_request_redacts_configured_secrets_and_bare_active_key
682 {
683 let client = client_with_config_secret_sentinels();
684 let config_dump = format!(
685 "api_key = \"{}\"\n[providers.arcee]\napi_key = \"{}\"\n\
686 ordinary_setting = \"keep-me\"\nall bare values: {}",
687 CONFIG_SECRET_SENTINELS[0],
688 CONFIG_SECRET_SENTINELS[1],
689 CONFIG_SECRET_SENTINELS.join(" ")
690 );
691
692 let prepared = client.prepare_model_bound_request(request_with_tool_result(config_dump));
693 let content = tool_result_content(&prepared);
694
695 for secret in CONFIG_SECRET_SENTINELS {
696 assert!(!content.contains(secret), "secret survived redaction");
697 }
698 assert!(content.contains(codewhale_config::persistence::REDACTED));
699 assert!(content.contains("ordinary_setting"));
700 assert!(content.contains("keep-me"));
701 }
702 // from model_bound_request_leaves_ordinary_tool_output_unchanged
703 {
704 let client = client_with_config_secret_sentinels();
705 let ordinary = "tests passed: 42\nREADME.md updated\n";
706 let prepared =
707 client.prepare_model_bound_request(request_with_tool_result(ordinary.to_string()));
708 assert_eq!(tool_result_content(&prepared), ordinary);
709 }
710 }
711
712 /// The `[redaction] model_bound = "disabled"` opt-out, once confirmed on
713 /// the startup gate, must let the model see tool output byte-for-byte —
714 /// including configured secrets and credential-shaped values that the
715 /// default masking would have removed (#5546 keeps code quotable; this
716 /// opt-out goes further and keeps credentials quotable too).
717 #[test]
718 fn confirmed_opt_out_keeps_configured_secrets_visible_to_the_model() {
719 let _env_lock = crate::test_support::lock_test_env();
720 let tmp = tempfile::tempdir().expect("tempdir");
721 let home = tmp.path().join("home");
722 std::fs::create_dir_all(&home).expect("create isolated home");
723 let _home = crate::test_support::EnvVarGuard::set("HOME", &home);
724 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", &home);
725 let codewhale_home = tmp.path().join("codewhale-home");
726 let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
727 std::fs::create_dir_all(&codewhale_home).expect("create config home");
728 std::fs::write(
729 codewhale_home.join("config.toml"),
730 "[redaction]\nmodel_bound = \"disabled\"\n",
731 )
732 .expect("write opt-out request");
733 codewhale_config::redaction::record_model_bound_disabled_confirmation(
734 &codewhale_home.join("config.toml"),
735 )
736 .expect("record opt-out confirmation");
737
738 let client = CodewhaleClient::new(
739 &Config {
740 loaded_config_path: Some(codewhale_home.join("config.toml")),
741 provider: Some("zai".to_string()),
742 providers: Some(ProvidersConfig {
743 zai: ProviderConfig {
744 api_key: Some(CONFIG_SECRET_SENTINELS[6].to_string()),
745 ..ProviderConfig::default()
746 },
747 ..ProvidersConfig::default()
748 }),
749 redaction: Some(codewhale_config::redaction::RedactionToml {
750 model_bound: Some(codewhale_config::redaction::ModelBoundMasking::Disabled),
751 }),
752 ..Config::default()
753 }
754 .with_legacy_root(Some(CONFIG_SECRET_SENTINELS[0].to_string()), None),
755 )
756 .expect("client with confirmed opt-out");
757
758 let tool_output = format!(
759 "api_key = \"{}\"\n[providers.arcee]\napi_key = \"{}\"\nbearer {}",
760 CONFIG_SECRET_SENTINELS[0], CONFIG_SECRET_SENTINELS[1], CONFIG_SECRET_SENTINELS[3]
761 );
762 let prepared =
763 client.prepare_model_bound_request(request_with_tool_result(tool_output.clone()));
764 assert_eq!(
765 tool_result_content(&prepared),
766 tool_output,
767 "a confirmed opt-out must keep tool output byte-exact"
768 );
769 }
770
771 #[test]
772 fn redaction_confirmation_follows_explicit_and_environment_config_loading() {
773 use crate::test_support::{EnvVarGuard, lock_test_env};
774 let _lock = lock_test_env();
775 let temp = tempfile::tempdir().unwrap();
776 let _home = EnvVarGuard::set("CODEWHALE_HOME", temp.path());
777 let default = temp.path().join("config.toml");
778 let custom = temp.path().join("selected.toml");
779 let body = format!(
780 "provider = \"zai\"\n[providers.zai]\napi_key = \"{}\"\n[redaction]\nmodel_bound = \"disabled\"\n",
781 CONFIG_SECRET_SENTINELS[6]
782 );
783 std::fs::write(&default, &body).unwrap();
784 std::fs::write(&custom, &body).unwrap();
785 codewhale_config::redaction::record_model_bound_disabled_confirmation(&default).unwrap();
786 let _config_path = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", &custom);
787 let _legacy_config = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
788 let tool_output = format!("api_key = \"{}\"", CONFIG_SECRET_SENTINELS[6]);
789 for explicit in [Some(custom.clone()), None] {
790 let config = Config::load(explicit, None).unwrap();
791 assert_eq!(
792 config
793 .loaded_config_path
794 .as_ref()
795 .unwrap()
796 .canonicalize()
797 .unwrap(),
798 custom.canonicalize().unwrap()
799 );
800 assert!(crate::tui::redaction_gate::confirmation_required(&config));
801 let client = CodewhaleClient::new(&config).unwrap();
802 let prepared =
803 client.prepare_model_bound_request(request_with_tool_result(tool_output.clone()));
804 assert!(!tool_result_content(&prepared).contains(CONFIG_SECRET_SENTINELS[6]));
805 }
806 let config = Config::load(None, None).unwrap();
807 crate::tui::redaction_gate::record_confirmation(&config).unwrap();
808 for explicit in [Some(custom.clone()), None] {
809 let config = Config::load(explicit, None).unwrap();
810 assert!(!crate::tui::redaction_gate::confirmation_required(&config));
811 let client = CodewhaleClient::new(&config).unwrap();
812 let prepared =
813 client.prepare_model_bound_request(request_with_tool_result(tool_output.clone()));
814 assert_eq!(tool_result_content(&prepared), tool_output);
815 }
816 // Local provenance is never accepted from serialized configuration.
817 let decoded: Config =
818 toml::from_str("loaded_config_path = \"/untrusted/config.toml\"\n").unwrap();
819 assert!(decoded.loaded_config_path.is_none());
820 }
821
822 /// Without a confirmation receipt the same config request stays masked:
823 /// the gate is what separates a wish from an effective opt-out.
824 #[test]
825 fn unconfirmed_opt_out_request_stays_masked() {
826 let _env_lock = crate::test_support::lock_test_env();
827 let tmp = tempfile::tempdir().expect("tempdir");
828 let home = tmp.path().join("home");
829 std::fs::create_dir_all(&home).expect("create isolated home");
830 let _home = crate::test_support::EnvVarGuard::set("HOME", &home);
831 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", &home);
832 let codewhale_home = tmp.path().join("codewhale-home");
833 let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
834
835 let client = CodewhaleClient::new(
836 &Config {
837 provider: Some("zai".to_string()),
838 providers: Some(ProvidersConfig {
839 zai: ProviderConfig {
840 api_key: Some(CONFIG_SECRET_SENTINELS[6].to_string()),
841 ..ProviderConfig::default()
842 },
843 ..ProvidersConfig::default()
844 }),
845 redaction: Some(codewhale_config::redaction::RedactionToml {
846 model_bound: Some(codewhale_config::redaction::ModelBoundMasking::Disabled),
847 }),
848 ..Config::default()
849 }
850 .with_legacy_root(Some(CONFIG_SECRET_SENTINELS[0].to_string()), None),
851 )
852 .expect("client with unconfirmed opt-out request");
853
854 let secret = CONFIG_SECRET_SENTINELS[0];
855 let prepared = client
856 .prepare_model_bound_request(request_with_tool_result(format!("api_key = \"{secret}\"")));
857 let content = tool_result_content(&prepared);
858 assert!(
859 !content.contains(secret),
860 "an unconfirmed request must stay on the safe default"
861 );
862 }
863
864 #[test]
865 fn model_bound_request_redacts_inactive_file_store_and_environment_secrets() {
866 const FILE_STORED_INACTIVE: &str = "inactive-arcee-file-secret-901";
867 const BUILTIN_ENV_SECRET: &str = "inactive-arcee-env-secret-902";
868 const CUSTOM_ENV_NAME: &str = "CW_TEST_CUSTOM_PROVIDER_API_KEY";
869 const CUSTOM_ENV_SECRET: &str = "inactive-custom-env-secret-903";
870
871 let _env_lock = crate::test_support::lock_test_env();
872 let tmp = tempfile::tempdir().expect("tempdir");
873 let codewhale_home = tmp.path().join("codewhale-home");
874 let home = tmp.path().join("home");
875 std::fs::create_dir_all(&home).expect("create isolated home");
876 let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
877 let _secret_backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
878 let _home = crate::test_support::EnvVarGuard::set("HOME", &home);
879 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", &home);
880 let builtin_env_name = ProviderKind::Arcee
881 .provider()
882 .env_vars()
883 .first()
884 .copied()
885 .expect("Arcee API-key environment variable");
886 let _builtin_env = crate::test_support::EnvVarGuard::set(builtin_env_name, BUILTIN_ENV_SECRET);
887 let _custom_env = crate::test_support::EnvVarGuard::set(CUSTOM_ENV_NAME, CUSTOM_ENV_SECRET);
888
889 codewhale_secrets::Secrets::file_backed()
890 .set("arcee", FILE_STORED_INACTIVE)
891 .expect("write isolated inactive provider credential");
892
893 let _ = rustls::crypto::ring::default_provider().install_default();
894 let client = CodewhaleClient::new(&Config {
895 provider: Some("zai".to_string()),
896 providers: Some(ProvidersConfig {
897 zai: ProviderConfig {
898 api_key: Some("active-zai-secret-900".to_string()),
899 ..ProviderConfig::default()
900 },
901 custom: HashMap::from([(
902 "example-custom".to_string(),
903 ProviderConfig {
904 kind: Some("openai-compatible".to_string()),
905 api_key_env: Some(CUSTOM_ENV_NAME.to_string()),
906 ..ProviderConfig::default()
907 },
908 )]),
909 ..ProvidersConfig::default()
910 }),
911 ..Config::default()
912 })
913 .expect("client with inactive file-store credential");
914 let prepared = client.prepare_model_bound_request(request_with_tool_result(format!(
915 "retrieved values: {FILE_STORED_INACTIVE} {BUILTIN_ENV_SECRET} {CUSTOM_ENV_SECRET}\nordinary output survives"
916 )));
917 let content = tool_result_content(&prepared);
918
919 for secret in [FILE_STORED_INACTIVE, BUILTIN_ENV_SECRET, CUSTOM_ENV_SECRET] {
920 assert!(
921 !content.contains(secret),
922 "inactive secret survived redaction"
923 );
924 }
925 assert!(content.contains(codewhale_config::persistence::REDACTED));
926 assert!(content.contains("ordinary output survives"));
927 }
928
929 #[test]
930 fn whitespace_codewhale_home_does_not_load_ambient_redaction_secrets() {
931 let _env_lock = crate::test_support::lock_test_env();
932 let tmp = tempfile::tempdir().expect("tempdir");
933 let ambient_home = tmp.path().join("ambient-home");
934 std::fs::create_dir_all(&ambient_home).expect("create ambient home");
935 let _home = crate::test_support::EnvVarGuard::set("HOME", &ambient_home);
936 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", &ambient_home);
937 let _codewhale_home_unset = crate::test_support::EnvVarGuard::remove("CODEWHALE_HOME");
938 let _secret_backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
939 codewhale_secrets::Secrets::file_backed()
940 .set("arcee", "ambient-redaction-secret-sentinel")
941 .expect("seed ambient file secret store");
942 let _whitespace_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", " \t ");
943 let mut values = Vec::new();
944
945 push_file_backed_model_bound_secrets(&mut values);
946
947 assert!(
948 !values
949 .iter()
950 .any(|value| value == "ambient-redaction-secret-sentinel"),
951 "whitespace must not opt tests into reading the ambient secret store"
952 );
953 }
954
955 #[test]
956 fn short_chat_tool_payload_is_redacted_before_wire_serialization() {
957 let client = client_with_config_secret_sentinels();
958 let prepared = client.prepare_model_bound_request(request_with_tool_result(format!(
959 "active token: {}",
960 CONFIG_SECRET_SENTINELS[6]
961 )));
962 let wire = build_chat_messages_for_request(&prepared);
963 let serialized = serde_json::to_string(&wire).expect("serialize chat wire messages");
964
965 assert!(!serialized.contains(CONFIG_SECRET_SENTINELS[6]));
966 assert!(serialized.contains(codewhale_config::persistence::REDACTED));
967 }
968
969 #[test]
970 fn configured_secret_redaction_reaches_all_protocol_bodies() {
971 let client = client_with_config_secret_sentinels();
972 let prepared = client.prepare_model_bound_request(request_with_tool_result(format!(
973 "safe output then {}",
974 CONFIG_SECRET_SENTINELS[6]
975 )));
976
977 let chat = serde_json::to_string(&build_chat_messages_for_request(&prepared))
978 .expect("serialize Chat Completions body");
979 let anthropic = client.build_anthropic_body(&prepared, false).to_string();
980 let responses = build_responses_body(&prepared).to_string();
981
982 for (route, body) in [
983 ("chat", chat.as_str()),
984 ("anthropic", anthropic.as_str()),
985 ("responses", responses.as_str()),
986 ] {
987 assert!(
988 !body.contains(CONFIG_SECRET_SENTINELS[6]),
989 "{route} body retained the configured credential"
990 );
991 assert!(
992 body.contains(codewhale_config::persistence::REDACTED),
993 "{route} body lost the redaction marker"
994 );
995 }
996 }
997
998 // This test deliberately serializes access to process-global spillover
999 // state while awaiting the retrieval path.
1000 #[allow(clippy::await_holding_lock)]
1001 #[tokio::test]
1002 async fn retrieved_turn_loop_spillover_is_sanitized_before_model_wire() {
1003 let _guard = crate::tools::truncate::TEST_SPILLOVER_GUARD
1004 .lock()
1005 .unwrap_or_else(|err| err.into_inner());
1006 let tmp = tempfile::tempdir().expect("tempdir");
1007 let spillover_root = tmp.path().join(".codewhale").join("tool_outputs");
1008 let prior = crate::tools::truncate::set_test_spillover_root(Some(spillover_root.clone()));
1009 struct Restore(Option<std::path::PathBuf>);
1010 impl Drop for Restore {
1011 fn drop(&mut self) {
1012 crate::tools::truncate::set_test_spillover_root(self.0.take());
1013 }
1014 }
1015 let _restore = Restore(prior);
1016
1017 let head = (0..40)
1018 .map(|_| format!("{}\n", "safe-head".repeat(100)))
1019 .collect::<String>();
1020 let tail = (0..80)
1021 .map(|_| format!("{}\n", "safe-tail".repeat(100)))
1022 .collect::<String>();
1023 let raw = format!("{head}\n{}\n{tail}", CONFIG_SECRET_SENTINELS[6]);
1024 assert!(
1025 raw.len() > crate::tools::truncate::SPILLOVER_THRESHOLD_BYTES,
1026 "fixture must enter turn-loop spillover"
1027 );
1028
1029 let mut spilled = crate::tools::spec::ToolResult::success(raw.clone());
1030 let path = crate::tools::truncate::apply_spillover(&mut spilled, "call-local-secret")
1031 .expect("turn-loop spillover");
1032 crate::tools::truncate::publish_legacy_spillover_ownership(&path, "workspace", raw.as_bytes())
1033 .expect("publish compatibility ownership proof");
1034 assert_eq!(path.parent(), Some(spillover_root.as_path()));
1035 assert!(
1036 std::fs::read_to_string(&path)
1037 .expect("read local spillover")
1038 .contains(CONFIG_SECRET_SENTINELS[6]),
1039 "the full raw result remains available only in the local spillover store"
1040 );
1041 assert!(
1042 !spilled.content.contains(CONFIG_SECRET_SENTINELS[6]),
1043 "middle-only secret should not be present in retained head/tail"
1044 );
1045
1046 let context = crate::tools::spec::ToolContext::new(tmp.path().to_path_buf());
1047 let retrieved = crate::tools::spec::ToolSpec::execute(
1048 &crate::tools::tool_result_retrieval::RetrieveToolResultTool,
1049 json!({
1050 "ref": "call-local-secret",
1051 "mode": "query",
1052 "query": CONFIG_SECRET_SENTINELS[6],
1053 }),
1054 &context,
1055 )
1056 .await
1057 .expect("retrieve secret-bearing local spillover slice");
1058 assert!(retrieved.content.contains(CONFIG_SECRET_SENTINELS[6]));
1059
1060 let client = client_with_config_secret_sentinels();
1061 let prepared = client.prepare_model_bound_request(request_with_tool_result(retrieved.content));
1062 let wire = serde_json::to_string(&build_chat_messages_for_request(&prepared))
1063 .expect("serialize sanitized retrieval result");
1064 assert!(!wire.contains(CONFIG_SECRET_SENTINELS[6]));
1065 assert!(wire.contains(codewhale_config::persistence::REDACTED));
1066 }
1067
1068 #[test]
1069 fn wire_adapter_does_not_persist_sessionless_sha_spillover() {
1070 let _guard = crate::tools::truncate::TEST_SPILLOVER_GUARD
1071 .lock()
1072 .unwrap_or_else(|err| err.into_inner());
1073 let tmp = tempfile::tempdir().expect("tempdir");
1074 let prior = crate::tools::truncate::set_test_spillover_root(Some(
1075 tmp.path().join(".codewhale").join("tool_outputs"),
1076 ));
1077 struct Restore(Option<std::path::PathBuf>);
1078 impl Drop for Restore {
1079 fn drop(&mut self) {
1080 crate::tools::truncate::set_test_spillover_root(self.0.take());
1081 }
1082 }
1083 let _restore = Restore(prior);
1084
1085 let client = client_with_config_secret_sentinels();
1086 let raw = format!(
1087 "{}\ncredential={}\n{}",
1088 "ordinary output ".repeat(80),
1089 CONFIG_SECRET_SENTINELS[6],
1090 "tail ".repeat(80)
1091 );
1092 assert!(raw.len() > 1024, "fixture must enter wire dedup size class");
1093 let raw_sha = crate::hashing::sha256_hex(raw.as_bytes());
1094 let prepared = client.prepare_model_bound_request(request_with_tool_result(raw));
1095 let sanitized = tool_result_content(&prepared).to_string();
1096 let sanitized_sha = crate::hashing::sha256_hex(sanitized.as_bytes());
1097
1098 let wire = build_chat_messages_for_request(&prepared);
1099 let serialized = serde_json::to_string(&wire).expect("serialize chat wire messages");
1100 assert!(!serialized.contains(CONFIG_SECRET_SENTINELS[6]));
1101
1102 let sanitized_path = crate::tools::truncate::sha_spillover_path(&sanitized_sha)
1103 .expect("sanitized spillover path");
1104 assert!(
1105 !sanitized_path.exists(),
1106 "sessionless wire fallback must not create an ownerless SHA artifact"
1107 );
1108
1109 let raw_path =
1110 crate::tools::truncate::sha_spillover_path(&raw_sha).expect("raw spillover path");
1111 assert!(
1112 !raw_path.exists(),
1113 "unsanitized tool output must never be persisted by the wire adapter"
1114 );
1115 assert!(!serialized.contains("retrieve_tool_result ref=sha:"));
1116 }
1117
1118 fn deepseek_anthropic_client(server: &MockServer) -> CodewhaleClient {
1119 let _ = rustls::crypto::ring::default_provider().install_default();
1120 let providers = ProvidersConfig {
1121 deepseek_anthropic: ProviderConfig {
1122 api_key: Some("ds-test".to_string()),
1123 base_url: Some(server.uri()),
1124 ..ProviderConfig::default()
1125 },
1126 ..ProvidersConfig::default()
1127 };
1128 CodewhaleClient::new(&Config {
1129 provider: Some("deepseek-anthropic".to_string()),
1130 providers: Some(providers),
1131 ..Config::default()
1132 })
1133 .expect("deepseek anthropic client")
1134 }
1135
1136 fn minimax_anthropic_client_with_base_url(base_url: String) -> CodewhaleClient {
1137 let _ = rustls::crypto::ring::default_provider().install_default();
1138 let providers = ProvidersConfig {
1139 minimax_anthropic: ProviderConfig {
1140 api_key: Some("minimax-test".to_string()),
1141 base_url: Some(base_url),
1142 ..ProviderConfig::default()
1143 },
1144 ..ProvidersConfig::default()
1145 };
1146 CodewhaleClient::new(&Config {
1147 provider: Some("minimax-anthropic".to_string()),
1148 providers: Some(providers),
1149 ..Config::default()
1150 })
1151 .expect("minimax anthropic client")
1152 }
1153
1154 fn zai_client_for_test() -> CodewhaleClient {
1155 let _ = rustls::crypto::ring::default_provider().install_default();
1156 let providers = ProvidersConfig {
1157 zai: ProviderConfig {
1158 api_key: Some("zai-test".to_string()),
1159 base_url: Some("https://api.z.ai/api/coding/paas/v4".to_string()),
1160 ..ProviderConfig::default()
1161 },
1162 ..ProvidersConfig::default()
1163 };
1164 CodewhaleClient::new(&Config {
1165 provider: Some("zai".to_string()),
1166 providers: Some(providers),
1167 ..Config::default()
1168 })
1169 .expect("zai client")
1170 }
1171
1172 fn runtime_chat_gate_client(isolated: bool, unrelated: bool) -> CodewhaleClient {
1173 CodewhaleClient::new(&Config {
1174 provider: Some("ollama".to_string()),
1175 default_text_model: Some("fixture-local:tag".to_string()),
1176 runtime_chat_isolated: isolated,
1177 runtime_thread_inference_unrelated: unrelated,
1178 ..Config::default()
1179 })
1180 .expect("runtime chat gate test client")
1181 }
1182
1183 #[tokio::test]
1184 async fn runtime_chat_provider_gate_blocks_only_attached_run_participants() {
1185 let participant = runtime_chat_gate_client(false, false);
1186 let participant_clone = participant.clone();
1187 assert!(participant.remote_control_inference_participant);
1188 assert!(participant_clone.remote_control_inference_participant);
1189 assert!(
1190 !runtime_chat_gate_client(true, false).remote_control_inference_participant,
1191 "the isolated relay client must not deadlock on its own exclusive lease"
1192 );
1193 assert!(
1194 !runtime_chat_gate_client(false, true).remote_control_inference_participant,
1195 "an unrelated RuntimeThreadManager must remain concurrent"
1196 );
1197
1198 let ownership = acquire_runtime_chat_inference_ownership().await;
1199 let waiting = tokio::spawn(async move {
1200 participant_clone
1201 .acquire_remote_control_inference_permit()
1202 .await
1203 });
1204 let mut waiting = waiting;
1205 assert!(
1206 tokio::time::timeout(Duration::from_millis(40), &mut waiting)
1207 .await
1208 .is_err(),
1209 "an attached-run provider call must wait behind Runtime Chat ownership"
1210 );
1211 assert!(
1212 runtime_chat_gate_client(true, false)
1213 .acquire_remote_control_inference_permit()
1214 .await
1215 .is_none()
1216 );
1217 assert!(
1218 runtime_chat_gate_client(false, true)
1219 .acquire_remote_control_inference_permit()
1220 .await
1221 .is_none()
1222 );
1223 drop(ownership);
1224 assert!(
1225 tokio::time::timeout(Duration::from_secs(1), waiting)
1226 .await
1227 .expect("participant should resume")
1228 .expect("permit task")
1229 .is_some()
1230 );
1231 }
1232
1233 #[tokio::test]
1234 async fn provider_request_scenario() {
1235 // Scenario consolidation of: provider_request_concurrency_limiter_is_shared_across_client_clones, provider_request_permit_lives_until_stream_is_consumed
1236 // from provider_request_concurrency_limiter_is_shared_across_client_clones
1237 {
1238 let client = zai_client_for_test();
1239 assert_eq!(
1240 client.provider_request_concurrency_limit(),
1241 Some(crate::config::DEFAULT_ZAI_PROVIDER_MAX_CONCURRENCY)
1242 );
1243
1244 let clone = client.clone();
1245 let permit = client
1246 .acquire_provider_request_permit()
1247 .await
1248 .expect("zai default should install provider request limiter");
1249
1250 assert_eq!(client.active_provider_requests(), 1);
1251 assert_eq!(clone.active_provider_requests(), 1);
1252
1253 drop(permit);
1254
1255 assert_eq!(client.active_provider_requests(), 0);
1256 assert_eq!(clone.active_provider_requests(), 0);
1257 }
1258 // from provider_request_permit_lives_until_stream_is_consumed
1259 {
1260 let client = zai_client_for_test();
1261 let permit = client
1262 .acquire_provider_request_permit()
1263 .await
1264 .expect("zai default should install provider request limiter");
1265 let stream: crate::llm_client::StreamEventBox =
1266 Box::pin(futures_util::stream::iter(vec![Ok(
1267 StreamEvent::MessageStop,
1268 )]));
1269 let mut wrapped =
1270 CodewhaleClient::hold_provider_request_permit_for_stream(stream, Some(permit));
1271
1272 assert_eq!(client.active_provider_requests(), 1);
1273 assert!(wrapped.next().await.is_some());
1274 assert!(wrapped.next().await.is_none());
1275 assert_eq!(client.active_provider_requests(), 0);
1276 }
1277 }
1278
1279 #[tokio::test]
1280 async fn runtime_chat_read_permit_lives_until_stream_is_dropped() {
1281 let client = runtime_chat_gate_client(false, false);
1282 let permit = client
1283 .acquire_remote_control_inference_permit()
1284 .await
1285 .expect("interactive participant read permit");
1286 let stream: crate::llm_client::StreamEventBox = Box::pin(futures_util::stream::pending());
1287 let wrapped =
1288 CodewhaleClient::hold_remote_control_inference_permit_for_stream(stream, Some(permit));
1289
1290 let mut writer = tokio::spawn(acquire_runtime_chat_inference_ownership());
1291 assert!(
1292 tokio::time::timeout(Duration::from_millis(40), &mut writer)
1293 .await
1294 .is_err(),
1295 "a live participant stream must retain attached-run ownership through EOF/drop"
1296 );
1297 drop(wrapped);
1298 let ownership = tokio::time::timeout(Duration::from_secs(1), writer)
1299 .await
1300 .expect("writer resumes after stream drop")
1301 .expect("writer task");
1302 drop(ownership);
1303 }
1304
1305 #[test]
1306 fn parse_speech_scenario() {
1307 // Scenario consolidation of: parse_speech_audio_response_accepts_message_audio, parse_speech_audio_response_accepts_data_uri
1308 // from parse_speech_audio_response_accepts_message_audio
1309 {
1310 let encoded = general_purpose::STANDARD.encode(b"hi");
1311 let payload = json!({
1312 "choices": [{
1313 "message": {
1314 "audio": {
1315 "data": encoded,
1316 "transcript": "hi"
1317 }
1318 }
1319 }]
1320 });
1321
1322 let (audio, transcript) = parse_speech_audio_response(&payload).unwrap();
1323 assert_eq!(audio, b"hi");
1324 assert_eq!(transcript.as_deref(), Some("hi"));
1325 }
1326 // from parse_speech_audio_response_accepts_data_uri
1327 {
1328 let encoded = general_purpose::STANDARD.encode(b"wav");
1329 let payload = json!({
1330 "audio": {
1331 "data": format!("data:audio/wav;base64,{encoded}")
1332 }
1333 });
1334
1335 let (audio, transcript) = parse_speech_audio_response(&payload).unwrap();
1336 assert_eq!(audio, b"wav");
1337 assert_eq!(transcript, None);
1338 }
1339 }
1340
1341 #[test]
1342 fn speech_synthesis_scenario() {
1343 // Scenario consolidation of: speech_synthesis_body_omits_user_message_without_instruction, speech_synthesis_body_ignores_blank_instruction, speech_synthesis_body_includes_non_empty_instruction_first
1344 // from speech_synthesis_body_omits_user_message_without_instruction
1345 {
1346 let body =
1347 build_speech_synthesis_body("mimo-v2.5-tts", "hello", None, json!({"format": "wav"}));
1348 let messages = body["messages"].as_array().expect("messages array");
1349
1350 assert_eq!(messages.len(), 1);
1351 assert_eq!(messages[0]["role"], "assistant");
1352 assert_eq!(messages[0]["content"], "hello");
1353 assert!(
1354 messages
1355 .iter()
1356 .all(|message| message["content"].as_str() != Some(""))
1357 );
1358 }
1359 // from speech_synthesis_body_ignores_blank_instruction
1360 {
1361 let body = build_speech_synthesis_body(
1362 "mimo-v2.5-tts",
1363 "hello",
1364 Some(" \t\n "),
1365 json!({"format": "wav"}),
1366 );
1367 let messages = body["messages"].as_array().expect("messages array");
1368
1369 assert_eq!(messages.len(), 1);
1370 assert_eq!(messages[0]["role"], "assistant");
1371 }
1372 // from speech_synthesis_body_includes_non_empty_instruction_first
1373 {
1374 let body = build_speech_synthesis_body(
1375 "mimo-v2.5-tts-voicedesign",
1376 "hello",
1377 Some("warm and calm"),
1378 json!({"format": "wav"}),
1379 );
1380 let messages = body["messages"].as_array().expect("messages array");
1381
1382 assert_eq!(messages.len(), 2);
1383 assert_eq!(messages[0]["role"], "user");
1384 assert_eq!(messages[0]["content"], "warm and calm");
1385 assert_eq!(messages[1]["role"], "assistant");
1386 assert_eq!(messages[1]["content"], "hello");
1387 }
1388 }
1389
1390 #[test]
1391 fn tool_name_scenario() {
1392 // Scenario consolidation of: tool_name_roundtrip_dot, tool_name_decode_mangled_dot_prefix, tool_name_decode_bare_hex_no_trailing_dash, tool_name_bare_hex_preserves_alnum, tool_name_bare_hex_preserves_underscore, tool_name_roundtrip_colon
1393 // from tool_name_roundtrip_dot
1394 {
1395 let original = "multi_tool_use.parallel";
1396 let encoded = to_api_tool_name(original);
1397 assert_eq!(encoded, "multi_tool_use-x00002E-parallel");
1398 let decoded = from_api_tool_name(&encoded);
1399 assert_eq!(decoded, original);
1400 }
1401 // from tool_name_decode_mangled_dot_prefix
1402 {
1403 let mangled = "multi_tool_use.x00002E-parallel";
1404 let decoded = from_api_tool_name(mangled);
1405 assert_eq!(decoded, "multi_tool_use..parallel");
1406 }
1407 // from tool_name_decode_bare_hex_no_trailing_dash
1408 {
1409 let mangled = "foo_x00002Ebar";
1410 let decoded = from_api_tool_name(mangled);
1411 assert_eq!(decoded, "foo_.bar");
1412 }
1413 // from tool_name_bare_hex_preserves_alnum
1414 {
1415 let input = "foox000041bar";
1416 let decoded = from_api_tool_name(input);
1417 assert_eq!(decoded, input);
1418 }
1419 // from tool_name_bare_hex_preserves_underscore
1420 {
1421 let input = "foox00005Fbar";
1422 let decoded = from_api_tool_name(input);
1423 assert_eq!(decoded, input);
1424 }
1425 // from tool_name_roundtrip_colon
1426 {
1427 let original = "mcp__server:tool_name";
1428 let encoded = to_api_tool_name(original);
1429 let decoded = from_api_tool_name(&encoded);
1430 assert_eq!(decoded, original);
1431 }
1432 }
1433
1434 #[test]
1435 fn api_url_scenario() {
1436 // Scenario consolidation of: api_url_handles_default_v1_and_beta_base_urls, api_url_routes_beta_paths_from_any_deepseek_base, api_url_with_suffix_strips_version_before_chat_suffix, api_url_with_suffix_handles_leading_slash, api_url_with_suffix_ignores_suffix_for_models, api_url_with_suffix_ignores_suffix_for_beta_paths, api_url_with_suffix_default_behavior_without_suffix
1437 // from api_url_handles_default_v1_and_beta_base_urls
1438 {
1439 assert_eq!(
1440 api_url("https://api.deepseek.com", "chat/completions"),
1441 "https://api.deepseek.com/v1/chat/completions"
1442 );
1443 assert_eq!(
1444 api_url("https://api.deepseek.com/v1", "chat/completions"),
1445 "https://api.deepseek.com/v1/chat/completions"
1446 );
1447 // Non-beta paths from a /beta base URL route to /v1.
1448 // Only paths with an explicit beta/ prefix use the beta surface.
1449 assert_eq!(
1450 api_url("https://api.deepseek.com/beta", "chat/completions"),
1451 "https://api.deepseek.com/v1/chat/completions"
1452 );
1453 assert_eq!(
1454 api_url(
1455 "https://openai-compatible.example/api/coding/paas/v4",
1456 "chat/completions"
1457 ),
1458 "https://openai-compatible.example/api/coding/paas/v4/chat/completions"
1459 );
1460 }
1461 // from api_url_routes_beta_paths_from_any_deepseek_base
1462 {
1463 assert_eq!(
1464 api_url("https://api.deepseek.com", "beta/completions"),
1465 "https://api.deepseek.com/beta/completions"
1466 );
1467 assert_eq!(
1468 api_url("https://api.deepseek.com/v1", "beta/completions"),
1469 "https://api.deepseek.com/beta/completions"
1470 );
1471 assert_eq!(
1472 api_url("https://api.deepseek.com/beta", "beta/completions"),
1473 "https://api.deepseek.com/beta/completions"
1474 );
1475 }
1476 // from api_url_with_suffix_strips_version_before_chat_suffix
1477 {
1478 assert_eq!(
1479 api_url_with_suffix(
1480 "https://api.example.com/v1",
1481 "chat/completions",
1482 Some("/chat/completions")
1483 ),
1484 "https://api.example.com/chat/completions"
1485 );
1486 assert_eq!(
1487 api_url_with_suffix(
1488 "https://api.example.com/beta",
1489 "chat/completions",
1490 Some("/chat/completions")
1491 ),
1492 "https://api.example.com/chat/completions"
1493 );
1494 }
1495 // from api_url_with_suffix_handles_leading_slash
1496 {
1497 assert_eq!(
1498 api_url_with_suffix(
1499 "https://api.example.com/v1",
1500 "chat/completions",
1501 Some("chat/completions")
1502 ),
1503 "https://api.example.com/chat/completions"
1504 );
1505 }
1506 // from api_url_with_suffix_ignores_suffix_for_models
1507 {
1508 assert_eq!(
1509 api_url_with_suffix(
1510 "https://api.example.com/v1",
1511 "models",
1512 Some("/chat/completions")
1513 ),
1514 "https://api.example.com/v1/models"
1515 );
1516 }
1517 // from api_url_with_suffix_ignores_suffix_for_beta_paths
1518 {
1519 assert_eq!(
1520 api_url_with_suffix(
1521 "https://api.example.com/v1",
1522 "beta/completions",
1523 Some("/chat/completions")
1524 ),
1525 "https://api.example.com/beta/completions"
1526 );
1527 }
1528 // from api_url_with_suffix_default_behavior_without_suffix
1529 {
1530 assert_eq!(
1531 api_url_with_suffix("https://api.deepseek.com", "chat/completions", None),
1532 "https://api.deepseek.com/v1/chat/completions"
1533 );
1534 }
1535 }
1536
1537 #[test]
1538 fn api_url_routes_models_and_non_beta_paths_to_v1() {
1539 // The /models endpoint only exists at /v1/models, never at
1540 // /beta/models. Non-beta paths from a /beta base URL must
1541 // still route to /v1.
1542 assert_eq!(
1543 api_url("https://api.deepseek.com", "models"),
1544 "https://api.deepseek.com/v1/models"
1545 );
1546 assert_eq!(
1547 api_url("https://api.deepseek.com/v1", "models"),
1548 "https://api.deepseek.com/v1/models"
1549 );
1550 assert_eq!(
1551 api_url("https://api.deepseek.com/beta", "models"),
1552 "https://api.deepseek.com/v1/models"
1553 );
1554 assert_eq!(
1555 api_url("https://api.minimax.io/anthropic", "models"),
1556 "https://api.minimax.io/anthropic/v1/models"
1557 );
1558 assert_eq!(
1559 api_url("https://api.minimaxi.com/anthropic", "models"),
1560 "https://api.minimaxi.com/anthropic/v1/models"
1561 );
1562 // explicit v<N> versions other than /v1 should be preserved
1563 assert_eq!(
1564 api_url(
1565 "https://openai-compatible.example/api/coding/paas/v4",
1566 "models"
1567 ),
1568 "https://openai-compatible.example/api/coding/paas/v4/models"
1569 );
1570 }
1571
1571 lines RUST