返回 CodeWhale
client.rs
根目录 / crates / tui / src / client.rs
1 //! HTTP client for DeepSeek's OpenAI-compatible Chat Completions API.
2 //!
3 //! DeepSeek documents `/chat/completions` as the primary endpoint, and this
4 //! client now routes all normal traffic through that surface.
5
6 use std::collections::HashMap;
7 use std::sync::atomic::{AtomicUsize, Ordering};
8 use std::sync::{Arc, Mutex as StdMutex, OnceLock};
9 use std::time::{Duration, Instant};
10
11 use anyhow::{Context, Result, bail};
12 use base64::{Engine as _, engine::general_purpose};
13 use futures_util::StreamExt;
14 use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue};
15 use serde::{Deserialize, Serialize};
16 use serde_json::{Value, json};
17 use tokio::sync::{Mutex as AsyncMutex, OwnedSemaphorePermit, Semaphore};
18
19 use codewhale_config::catalog::{
20 CatalogOffering, CatalogRefreshError, CatalogSnapshot, CatalogSource, CatalogStatus,
21 ProviderCatalogCache, ProviderCatalogDelta, base_url_fingerprint, now_unix,
22 };
23 use codewhale_config::provider::WireFormat;
24 use codewhale_config::route::{LogicalModelRef, ReadyRouteCandidate, RouteRequest, RouteResolver};
25 use codewhale_config::{auth_mode_disables_api_key, is_upstream_auth_header};
26
27 use crate::config::{
28 ApiProvider, Config, RetryPolicy, validate_route, wire_model_for_provider_route,
29 };
30 use crate::llm_client::{
31 LlmClient, LlmError, RetryConfig as LlmRetryConfig, extract_retry_after,
32 sanitize_http_error_body, with_retry,
33 };
34 use crate::logging;
35 use crate::models::{
36 ContentBlock, Message, MessageRequest, MessageResponse, ServerToolUsage, SystemPrompt, Usage,
37 };
38
39 pub(super) fn to_api_tool_name(name: &str) -> String {
40 let mut out = String::new();
41 for ch in name.chars() {
42 if ch.is_ascii_alphanumeric() || ch == '_' {
43 out.push(ch);
44 } else if ch == '-' {
45 out.push_str("--");
46 } else {
47 out.push_str("-x");
48 out.push_str(&format!("{:06X}", ch as u32));
49 out.push('-');
50 }
51 }
52 out
53 }
54
55 pub(super) fn from_api_tool_name(name: &str) -> String {
56 let mut out = String::new();
57 let mut iter = name.chars().peekable();
58 while let Some(ch) = iter.next() {
59 if ch != '-' {
60 out.push(ch);
61 continue;
62 }
63 if let Some('-') = iter.peek().copied() {
64 iter.next();
65 out.push('-');
66 continue;
67 }
68 if iter.peek().copied() == Some('x') {
69 iter.next();
70 let mut hex = String::new();
71 for _ in 0..6 {
72 if let Some(h) = iter.next() {
73 hex.push(h);
74 } else {
75 break;
76 }
77 }
78 // Only decode if we got exactly 6 hex digits (matching encoder output).
79 // Fewer digits means a truncated/malformed sequence — pass through as-is.
80 if hex.len() == 6
81 && let Ok(code) = u32::from_str_radix(&hex, 16)
82 && let Some(decoded) = std::char::from_u32(code)
83 {
84 if let Some('-') = iter.peek().copied() {
85 iter.next();
86 }
87 out.push(decoded);
88 continue;
89 }
90 out.push('-');
91 out.push('x');
92 out.push_str(&hex);
93 continue;
94 }
95 out.push('-');
96 }
97
98 // Second pass: decode bare hex escapes (e.g. `x00002E`) that the model
99 // may produce when it mangles the `-x00002E-` delimiter form. Only
100 // decode when the resulting character is one that `to_api_tool_name`
101 // would have encoded (not alphanumeric, not `_`, not `-`).
102 decode_bare_hex_escapes(&out)
103 }
104
105 /// Decode bare `x[0-9A-Fa-f]{6}` sequences (optionally followed by `-`)
106 /// that survive the standard delimiter-based pass. This handles cases
107 /// where the model strips or replaces the leading `-` of `-x00002E-`.
108 pub(super) fn decode_bare_hex_escapes(input: &str) -> String {
109 use regex::Regex;
110 use std::sync::OnceLock;
111
112 static RE: OnceLock<Regex> = OnceLock::new();
113 let re = RE.get_or_init(|| Regex::new(r"x([0-9A-Fa-f]{6})-?").unwrap());
114
115 let result = re.replace_all(input, |caps: &regex::Captures| {
116 let hex = &caps[1];
117 if let Ok(code) = u32::from_str_radix(hex, 16)
118 && let Some(decoded) = std::char::from_u32(code)
119 {
120 // Only decode characters that to_api_tool_name would have encoded
121 if !decoded.is_ascii_alphanumeric() && decoded != '_' && decoded != '-' {
122 return decoded.to_string();
123 }
124 }
125 // Not a character we'd encode — leave as-is
126 caps[0].to_string()
127 });
128 result.into_owned()
129 }
130
131 // === Types ===
132
133 /// Model descriptor returned by the provider's `/v1/models` endpoint.
134 #[derive(Debug, Clone, Serialize, PartialEq, Eq)]
135 pub struct AvailableModel {
136 pub id: String,
137 pub owned_by: Option<String>,
138 pub created: Option<u64>,
139 }
140
141 /// Request payload for Xiaomi MiMo speech synthesis models.
142 ///
143 /// MiMo-V2.5-TTS / MiMo-V2-TTS use the OpenAI-compatible
144 /// `/v1/chat/completions` endpoint: the optional style/voice instruction is
145 /// sent as a `user` message, while the text to synthesize is sent as an
146 /// `assistant` message.
147 #[derive(Debug, Clone)]
148 pub struct SpeechSynthesisRequest {
149 pub model: String,
150 pub text: String,
151 pub instruction: Option<String>,
152 pub audio_format: String,
153 pub voice: Option<String>,
154 }
155
156 /// Decoded speech synthesis result.
157 #[derive(Debug, Clone)]
158 pub struct SpeechSynthesisResponse {
159 pub model: String,
160 pub audio_format: String,
161 pub audio_bytes: Vec<u8>,
162 pub transcript: Option<String>,
163 pub voice: Option<String>,
164 }
165
166 /// Client for DeepSeek's OpenAI-compatible APIs.
167 #[must_use]
168 pub struct DeepSeekClient {
169 pub(super) http_client: reqwest::Client,
170 /// HTTP/1.1-only twin of [`Self::http_client`], used for automatic
171 /// stream-header fallback when H2 stalls. Same auth and headers.
172 pub(super) http1_client: reqwest::Client,
173 api_key: String,
174 /// Exact configured credential values removed from model-bound tool
175 /// results. Structural redaction handles config/JSON assignments, while
176 /// this list closes the gap for bare provider tokens with no recognizable
177 /// prefix (for example token-plan and provider-specific keys).
178 model_bound_secret_values: Arc<Vec<String>>,
179 pub(super) base_url: String,
180 pub(super) api_provider: ApiProvider,
181 /// Exact configured provider identity and billing mode frozen when this
182 /// client is built. Child/tool calls only carry the client at dispatch, so
183 /// these route facts must travel with it instead of being reconstructed
184 /// from the mutable parent session at completion time.
185 provider_identity: String,
186 billing_surface: Option<String>,
187 billing_mode: crate::cost_status::RouteBillingMode,
188 /// ChatGPT account id captured through the same consent-gated credential
189 /// resolution as the Codex bearer token.
190 pub(super) codex_account_id: Option<String>,
191 wire_format: WireFormat,
192 retry: RetryPolicy,
193 /// Auxiliary inspection calls use the normal bounded retry schedule but
194 /// never publish retry/rate-limit state into process-global UI cells.
195 isolated_request_state: bool,
196 default_model: String,
197 connection_health: Arc<AsyncMutex<ConnectionHealth>>,
198 rate_limiter: Arc<AsyncMutex<TokenBucket>>,
199 request_concurrency: Option<ProviderConcurrencyLimiter>,
200 path_suffix: Option<String>,
201 /// Unit tests keep the semantic route exact while sending the actual
202 /// production request through a local capture server. This field is
203 /// compiled out of release builds.
204 #[cfg(test)]
205 test_chat_transport_base_url: Option<String>,
206 /// Messages equivalent of `test_chat_transport_base_url`; keeps exact
207 /// route shaping bound to the semantic endpoint while tests capture on a
208 /// local server.
209 #[cfg(test)]
210 test_messages_transport_base_url: Option<String>,
211 pub(super) reasoning_stream_style: Option<String>,
212 pub(super) stream_idle_timeout: Duration,
213 }
214
215 const CONNECTION_FAILURE_THRESHOLD: u32 = 2;
216 const RECOVERY_PROBE_COOLDOWN: Duration = Duration::from_secs(15);
217
218 const DEFAULT_CLIENT_RATE_LIMIT_RPS: f64 = 8.0;
219 const DEFAULT_CLIENT_RATE_LIMIT_BURST: f64 = 16.0;
220 const ALLOW_INSECURE_HTTP_ENV: &str = "CODEWHALE_ALLOW_INSECURE_HTTP";
221 /// Legacy alias for [`ALLOW_INSECURE_HTTP_ENV`].
222 const LEGACY_ALLOW_INSECURE_HTTP_ENV: &str = "DEEPSEEK_ALLOW_INSECURE_HTTP";
223
224 fn client_user_agent(api_provider: ApiProvider) -> &'static str {
225 // The ChatGPT Codex backend is the sole route with a documented
226 // compatibility exception. Kimi Code, including K3, must keep the normal
227 // Codewhale identity rather than impersonating a Kimi CLI.
228 if api_provider == ApiProvider::OpenaiCodex {
229 concat!(
230 "codex_cli_rs/0.137.0 (CodeWhale ",
231 env!("CARGO_PKG_VERSION"),
232 ")"
233 )
234 } else {
235 concat!(
236 "Mozilla/5.0 (compatible; codewhale/",
237 env!("CARGO_PKG_VERSION"),
238 "; +https://github.com/Hmbown/CodeWhale)"
239 )
240 }
241 }
242
243 /// Upper bound on a single sleep inside the provider-wide rate-limit pause
244 /// loop in `send_with_retry`. The pause window lives in process-global state
245 /// (`retry_status`), so waiting requests re-poll it on this cadence instead
246 /// of committing to the full remaining window up front.
247 const RATE_LIMIT_PAUSE_RECHECK_INTERVAL: Duration = Duration::from_millis(250);
248
249 pub(super) const SSE_BACKPRESSURE_HIGH_WATERMARK: usize = 1024 * 1024; // 1 MB
250 pub(super) const SSE_BACKPRESSURE_SLEEP_MS: u64 = 10;
251 pub(super) const SSE_MAX_LINES_PER_CHUNK: usize = 256;
252 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
253 enum ConnectionState {
254 Healthy,
255 Degraded,
256 Recovering,
257 }
258
259 #[derive(Debug)]
260 struct ConnectionHealth {
261 state: ConnectionState,
262 consecutive_failures: u32,
263 last_failure: Option<Instant>,
264 last_success: Option<Instant>,
265 last_probe: Option<Instant>,
266 }
267
268 impl Default for ConnectionHealth {
269 fn default() -> Self {
270 Self {
271 state: ConnectionState::Healthy,
272 consecutive_failures: 0,
273 last_failure: None,
274 last_success: None,
275 last_probe: None,
276 }
277 }
278 }
279
280 #[derive(Debug)]
281 struct TokenBucket {
282 enabled: bool,
283 capacity: f64,
284 tokens: f64,
285 refill_per_sec: f64,
286 last_refill: Instant,
287 }
288
289 #[derive(Debug, Clone)]
290 struct ProviderConcurrencyLimiter {
291 semaphore: Arc<Semaphore>,
292 active: Arc<AtomicUsize>,
293 limit: usize,
294 }
295
296 struct ProviderRequestPermit {
297 _permit: OwnedSemaphorePermit,
298 active: Arc<AtomicUsize>,
299 }
300
301 impl ProviderConcurrencyLimiter {
302 fn new(limit: usize) -> Self {
303 let limit = limit.max(1);
304 Self {
305 semaphore: Arc::new(Semaphore::new(limit)),
306 active: Arc::new(AtomicUsize::new(0)),
307 limit,
308 }
309 }
310
311 async fn acquire(&self) -> Option<ProviderRequestPermit> {
312 let permit = Arc::clone(&self.semaphore).acquire_owned().await.ok()?;
313 self.active.fetch_add(1, Ordering::AcqRel);
314 Some(ProviderRequestPermit {
315 _permit: permit,
316 active: Arc::clone(&self.active),
317 })
318 }
319
320 fn active(&self) -> usize {
321 self.active.load(Ordering::Acquire)
322 }
323
324 fn limit(&self) -> usize {
325 self.limit
326 }
327 }
328
329 impl Drop for ProviderRequestPermit {
330 fn drop(&mut self) {
331 self.active.fetch_sub(1, Ordering::AcqRel);
332 }
333 }
334
335 impl TokenBucket {
336 fn from_env() -> Self {
337 let rps = std::env::var("CODEWHALE_RATE_LIMIT_RPS")
338 .or_else(|_| std::env::var("DEEPSEEK_RATE_LIMIT_RPS"))
339 .ok()
340 .and_then(|v| v.parse::<f64>().ok())
341 .unwrap_or(DEFAULT_CLIENT_RATE_LIMIT_RPS)
342 .max(0.0);
343 let burst = std::env::var("CODEWHALE_RATE_LIMIT_BURST")
344 .or_else(|_| std::env::var("DEEPSEEK_RATE_LIMIT_BURST"))
345 .ok()
346 .and_then(|v| v.parse::<f64>().ok())
347 .unwrap_or(DEFAULT_CLIENT_RATE_LIMIT_BURST)
348 .max(1.0);
349 let enabled = rps > 0.0;
350 Self {
351 enabled,
352 capacity: burst,
353 tokens: burst,
354 refill_per_sec: rps,
355 last_refill: Instant::now(),
356 }
357 }
358
359 fn refill(&mut self, now: Instant) {
360 if !self.enabled {
361 return;
362 }
363 let elapsed = now.duration_since(self.last_refill).as_secs_f64();
364 self.last_refill = now;
365 self.tokens = (self.tokens + elapsed * self.refill_per_sec).min(self.capacity);
366 }
367
368 fn delay_until_available(&mut self, tokens: f64) -> Option<Duration> {
369 if !self.enabled {
370 return None;
371 }
372 let now = Instant::now();
373 self.refill(now);
374 if self.tokens >= tokens {
375 self.tokens -= tokens;
376 return None;
377 }
378 let needed = tokens - self.tokens;
379 self.tokens = 0.0;
380 if self.refill_per_sec <= 0.0 {
381 return Some(Duration::from_secs(1));
382 }
383 Some(Duration::from_secs_f64(needed / self.refill_per_sec))
384 }
385 }
386
387 fn apply_request_success(health: &mut ConnectionHealth, now: Instant) -> bool {
388 let recovered = health.state != ConnectionState::Healthy;
389 health.state = ConnectionState::Healthy;
390 health.consecutive_failures = 0;
391 health.last_success = Some(now);
392 recovered
393 }
394
395 fn apply_request_failure(health: &mut ConnectionHealth, now: Instant) {
396 health.consecutive_failures = health.consecutive_failures.saturating_add(1);
397 health.last_failure = Some(now);
398 if health.consecutive_failures >= CONNECTION_FAILURE_THRESHOLD {
399 health.state = ConnectionState::Degraded;
400 }
401 }
402
403 fn mark_recovery_probe_if_due(health: &mut ConnectionHealth, now: Instant) -> bool {
404 if health.state == ConnectionState::Healthy {
405 return false;
406 }
407 if health
408 .last_probe
409 .is_some_and(|last| now.duration_since(last) < RECOVERY_PROBE_COOLDOWN)
410 {
411 return false;
412 }
413 health.last_probe = Some(now);
414 health.state = ConnectionState::Recovering;
415 true
416 }
417
418 fn buffer_pool() -> &'static StdMutex<Vec<Vec<u8>>> {
419 static POOL: OnceLock<StdMutex<Vec<Vec<u8>>>> = OnceLock::new();
420 POOL.get_or_init(|| StdMutex::new(Vec::new()))
421 }
422
423 fn acquire_stream_buffer() -> Vec<u8> {
424 if let Ok(mut pool) = buffer_pool().lock() {
425 pool.pop().unwrap_or_else(|| Vec::with_capacity(8192))
426 } else {
427 Vec::with_capacity(8192)
428 }
429 }
430
431 fn release_stream_buffer(mut buf: Vec<u8>) {
432 buf.clear();
433 if buf.capacity() > 256 * 1024 {
434 buf.shrink_to(256 * 1024);
435 }
436 if let Ok(mut pool) = buffer_pool().lock()
437 && pool.len() < 8
438 {
439 pool.push(buf);
440 }
441 }
442
443 impl Clone for DeepSeekClient {
444 fn clone(&self) -> Self {
445 Self {
446 http_client: self.http_client.clone(),
447 http1_client: self.http1_client.clone(),
448 api_key: self.api_key.clone(),
449 model_bound_secret_values: Arc::clone(&self.model_bound_secret_values),
450 base_url: self.base_url.clone(),
451 api_provider: self.api_provider,
452 provider_identity: self.provider_identity.clone(),
453 billing_surface: self.billing_surface.clone(),
454 billing_mode: self.billing_mode,
455 codex_account_id: self.codex_account_id.clone(),
456 wire_format: self.wire_format,
457 retry: self.retry.clone(),
458 isolated_request_state: self.isolated_request_state,
459 default_model: self.default_model.clone(),
460 connection_health: self.connection_health.clone(),
461 rate_limiter: self.rate_limiter.clone(),
462 request_concurrency: self.request_concurrency.clone(),
463 path_suffix: self.path_suffix.clone(),
464 #[cfg(test)]
465 test_chat_transport_base_url: self.test_chat_transport_base_url.clone(),
466 #[cfg(test)]
467 test_messages_transport_base_url: self.test_messages_transport_base_url.clone(),
468 reasoning_stream_style: self.reasoning_stream_style.clone(),
469 stream_idle_timeout: self.stream_idle_timeout,
470 }
471 }
472 }
473
474 const MIN_EXACT_SECRET_CHARS: usize = 8;
475
476 fn push_model_bound_secret(values: &mut Vec<String>, value: Option<&str>) {
477 let Some(value) = value
478 .map(str::trim)
479 .filter(|value| !value.is_empty() && value.chars().count() >= MIN_EXACT_SECRET_CHARS)
480 else {
481 return;
482 };
483 if !values.iter().any(|existing| existing == value) {
484 values.push(value.to_string());
485 }
486 }
487
488 fn model_bound_secret_store_slot(provider: ApiProvider) -> Option<&'static str> {
489 match provider {
490 ApiProvider::DeepseekCN => Some("deepseek"),
491 ApiProvider::SiliconflowCn => Some("siliconflow"),
492 ApiProvider::Custom => None,
493 _ => Some(provider.as_str()),
494 }
495 }
496
497 fn push_file_backed_model_bound_secrets(values: &mut Vec<String>) {
498 // Unit tests must never inspect the developer's real credential store.
499 // The isolated regression below opts in with a temporary CODEWHALE_HOME,
500 // matching Config's existing secret-store test discipline.
501 #[cfg(test)]
502 if !codewhale_paths::codewhale_home_is_explicit()
503 || std::env::var_os("CODEWHALE_SECRET_BACKEND").is_none()
504 {
505 return;
506 }
507
508 // Redaction needs only a best-effort view of inactive file-backed
509 // credentials. It must not cause a legacy-store migration merely because a
510 // client is being constructed (notably for `doctor`'s live probe). Keep
511 // this file-only to avoid a burst of OS-keychain prompts for inactive
512 // providers; the active credential is already supplied by the route
513 // resolver.
514 let secrets = codewhale_secrets::Secrets::file_backed_read_only();
515 let mut slots = Vec::new();
516 for provider in ApiProvider::all()
517 .iter()
518 .copied()
519 .chain(std::iter::once(ApiProvider::DeepseekCN))
520 {
521 let Some(slot) = model_bound_secret_store_slot(provider) else {
522 continue;
523 };
524 if !slots.contains(&slot) {
525 slots.push(slot);
526 }
527 }
528 // The legacy literal `provider = "custom"` route owns this durable slot.
529 slots.push("custom");
530
531 for slot in slots {
532 if let Ok(Some(secret)) = secrets.get(slot) {
533 push_model_bound_secret(values, Some(&secret));
534 }
535 }
536 }
537
538 fn configured_model_bound_secret_values(config: &Config, active_api_key: &str) -> Vec<String> {
539 let mut values = Vec::new();
540 push_model_bound_secret(&mut values, Some(active_api_key));
541 push_model_bound_secret(&mut values, config.api_key.as_deref());
542 push_model_bound_secret(&mut values, config.sandbox_api_key.as_deref());
543 push_model_bound_secret(
544 &mut values,
545 config
546 .search
547 .as_ref()
548 .and_then(|search| search.api_key.as_deref()),
549 );
550 push_model_bound_secret(
551 &mut values,
552 config
553 .vision_model
554 .as_ref()
555 .and_then(|vision| vision.api_key.as_deref()),
556 );
557
558 if let Some(headers) = config.http_headers.as_ref() {
559 for (name, value) in headers {
560 if is_upstream_auth_header(name) {
561 push_model_bound_secret(&mut values, Some(value));
562 }
563 }
564 }
565
566 for provider in ApiProvider::all()
567 .iter()
568 .copied()
569 .chain(std::iter::once(ApiProvider::DeepseekCN))
570 .filter(|provider| *provider != ApiProvider::Custom)
571 {
572 for env_name in provider.env_vars() {
573 if let Ok(value) = std::env::var(env_name) {
574 push_model_bound_secret(&mut values, Some(&value));
575 }
576 }
577 let Some(provider_config) = config.provider_config_for(provider) else {
578 continue;
579 };
580 push_model_bound_secret(&mut values, provider_config.api_key.as_deref());
581 if let Some(headers) = provider_config.http_headers.as_ref() {
582 for (name, value) in headers {
583 if is_upstream_auth_header(name) {
584 push_model_bound_secret(&mut values, Some(value));
585 }
586 }
587 }
588 }
589
590 if let Some(providers) = config.providers.as_ref() {
591 for provider_config in providers.custom.values() {
592 push_model_bound_secret(&mut values, provider_config.api_key.as_deref());
593 if let Some(env_name) = provider_config
594 .api_key_env
595 .as_deref()
596 .map(str::trim)
597 .filter(|name| !name.is_empty())
598 && let Ok(value) = std::env::var(env_name)
599 {
600 push_model_bound_secret(&mut values, Some(&value));
601 }
602 if let Some(headers) = provider_config.http_headers.as_ref() {
603 for (name, value) in headers {
604 if is_upstream_auth_header(name) {
605 push_model_bound_secret(&mut values, Some(value));
606 }
607 }
608 }
609 }
610 }
611
612 push_file_backed_model_bound_secrets(&mut values);
613
614 // Replace longer values first in case one credential happens to contain
615 // another as a prefix.
616 values.sort_by_key(|value| std::cmp::Reverse(value.len()));
617 values
618 }
619
620 fn redact_model_bound_text(text: &str, exact_secret_values: &[String]) -> String {
621 let mut redacted = text.to_string();
622 for secret in exact_secret_values {
623 redacted = redacted.replace(secret, codewhale_config::persistence::REDACTED);
624 }
625 codewhale_config::persistence::redact_secrets(&redacted)
626 }
627
628 // === Helpers ===
629
630 /// Maximum bytes to read from an error response body (64 KB).
631 pub(super) const ERROR_BODY_MAX_BYTES: usize = 64 * 1024;
632
633 /// Read an error response body with a size limit to prevent unbounded allocation.
634 pub(super) async fn bounded_error_text(response: reqwest::Response, max_bytes: usize) -> String {
635 use futures_util::StreamExt;
636 let mut stream = response.bytes_stream();
637 let mut buf = Vec::with_capacity(max_bytes.min(8192));
638 while let Some(chunk) = stream.next().await {
639 let Ok(chunk) = chunk else { break };
640 let remaining = max_bytes.saturating_sub(buf.len());
641 if remaining == 0 {
642 break;
643 }
644 buf.extend_from_slice(&chunk[..chunk.len().min(remaining)]);
645 }
646 String::from_utf8_lossy(&buf).into_owned()
647 }
648
649 fn validate_base_url_security(base_url: &str) -> Result<()> {
650 let display_base_url = redact_url_for_display(base_url);
651 if base_url.starts_with("https://")
652 || base_url.starts_with("http://localhost")
653 || base_url.starts_with("http://127.0.0.1")
654 || base_url.starts_with("http://[::1]")
655 {
656 return Ok(());
657 }
658
659 if base_url.starts_with("http://")
660 && std::env::var(ALLOW_INSECURE_HTTP_ENV)
661 .or_else(|_| std::env::var(LEGACY_ALLOW_INSECURE_HTTP_ENV))
662 .ok()
663 .as_deref()
664 .is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
665 {
666 logging::warn(format!(
667 "Using insecure HTTP base URL because {ALLOW_INSECURE_HTTP_ENV} is set"
668 ));
669 return Ok(());
670 }
671
672 if base_url.starts_with("http://") {
673 anyhow::bail!(
674 "Refusing insecure base URL '{display_base_url}'.\n\
675 \n\
676 Loopback hosts (localhost, 127.0.0.1, [::1]) are auto-allowed.\n\
677 For other trusted local hosts (LAN, llama.cpp on a private IP, etc.)\n\
678 set the env var `{ALLOW_INSECURE_HTTP_ENV}=1` in the shell that runs codewhale and re-run.\n\
679 \n\
680 Example: `{ALLOW_INSECURE_HTTP_ENV}=1 codewhale` (note the underscores).",
681 );
682 }
683
684 anyhow::bail!(
685 "Refusing base URL '{display_base_url}': only HTTPS (or explicitly allowed HTTP) URLs are supported.",
686 )
687 }
688
689 pub(crate) fn redact_url_for_display(url: &str) -> String {
690 let Ok(mut parsed) = reqwest::Url::parse(url) else {
691 return url.to_string();
692 };
693 if !parsed.username().is_empty() || parsed.password().is_some() {
694 let _ = parsed.set_username("***");
695 let _ = parsed.set_password(Some("***"));
696 }
697 if parsed.query().is_none() {
698 return parsed.to_string();
699 }
700 let pairs: Vec<(String, String)> = parsed
701 .query_pairs()
702 .map(|(key, value)| {
703 let value = if is_sensitive_url_query_key(&key) {
704 "***".to_string()
705 } else {
706 value.into_owned()
707 };
708 (key.into_owned(), value)
709 })
710 .collect();
711 parsed.set_query(None);
712 let mut query = parsed.query_pairs_mut();
713 for (key, value) in pairs {
714 query.append_pair(&key, &value);
715 }
716 drop(query);
717 parsed.to_string()
718 }
719
720 fn is_sensitive_url_query_key(key: &str) -> bool {
721 let normalized = key.trim().replace(['-', '.'], "_").to_ascii_lowercase();
722 matches!(
723 normalized.as_str(),
724 "api_key"
725 | "apikey"
726 | "access_token"
727 | "auth_token"
728 | "authorization"
729 | "bearer"
730 | "client_secret"
731 | "credential"
732 | "id_token"
733 | "password"
734 | "refresh_token"
735 | "secret"
736 | "token"
737 ) || normalized.ends_with("_api_key")
738 || normalized.ends_with("_authorization")
739 || normalized.ends_with("_password")
740 || normalized.ends_with("_secret")
741 || normalized.ends_with("_token")
742 }
743
744 pub(super) fn versioned_base_url(base_url: &str) -> String {
745 let trimmed = base_url.trim_end_matches('/');
746 if base_url_has_version_suffix(trimmed) {
747 trimmed.to_string()
748 } else {
749 format!("{trimmed}/v1")
750 }
751 }
752
753 fn unversioned_base_url(base_url: &str) -> String {
754 let trimmed = base_url.trim_end_matches('/');
755 trimmed
756 .rsplit_once('/')
757 .filter(|(_, segment)| is_version_segment(segment))
758 .map(|(base, _)| base)
759 .unwrap_or(trimmed)
760 .to_string()
761 }
762
763 fn base_url_has_version_suffix(trimmed: &str) -> bool {
764 trimmed.rsplit('/').next().is_some_and(is_version_segment)
765 }
766
767 fn is_version_segment(segment: &str) -> bool {
768 segment.eq_ignore_ascii_case("beta")
769 || segment
770 .strip_prefix('v')
771 .or_else(|| segment.strip_prefix('V'))
772 .is_some_and(|rest| !rest.is_empty() && rest.chars().all(|ch| ch.is_ascii_digit()))
773 }
774
775 pub(crate) fn api_url(base_url: &str, path: &str) -> String {
776 api_url_with_suffix(base_url, path, None)
777 }
778
779 fn responses_api_url(base_url: &str, provider: ApiProvider) -> String {
780 let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
781 let official_deepseek = matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)
782 && matches!(
783 normalized.as_str(),
784 "https://api.deepseek.com"
785 | "https://api.deepseek.com/v1"
786 | "https://api.deepseek.com/beta"
787 | "https://api.deepseeki.com"
788 | "https://api.deepseeki.com/v1"
789 | "https://api.deepseeki.com/beta"
790 );
791 if official_deepseek {
792 format!("{}/responses", unversioned_base_url(base_url))
793 } else {
794 api_url(base_url, "responses")
795 }
796 }
797
798 pub(super) fn api_url_with_suffix(base_url: &str, path: &str, path_suffix: Option<&str>) -> String {
799 let path = path.trim_start_matches('/');
800 if path.starts_with("beta/") {
801 return format!("{}/{}", unversioned_base_url(base_url), path);
802 }
803 if let ("chat/completions", Some(suffix)) = (path, path_suffix) {
804 return format!(
805 "{}/{}",
806 unversioned_base_url(base_url),
807 suffix.trim_start_matches('/')
808 );
809 }
810 let mut versioned = versioned_base_url(base_url);
811 // The /beta suffix is not a real API version — it is an
812 // opt-in surface for beta features. Only paths with an
813 // explicit `beta/` prefix should hit the beta surface;
814 // everything else (models, chat/completions, health, …)
815 // must go to the standard /v1 surface.
816 if versioned.ends_with("beta") {
817 versioned = format!("{}/v1", unversioned_base_url(base_url));
818 }
819 format!("{}/{}", versioned.trim_end_matches('/'), path)
820 }
821
822 /// Route strict DeepSeek tool requests through the beta Chat Completions
823 /// surface while keeping every ordinary request on the canonical `/v1` path.
824 ///
825 /// DeepSeek requires its `/beta` base URL when a function opts into
826 /// `strict: true`. The configured route URL remains semantic here because
827 /// unit tests may replace only the transport origin with a local capture
828 /// server.
829 ///
830 /// Source: <https://api-docs.deepseek.com/guides/tool_calls/> (verified 2026-07-22).
831 fn chat_completions_url(
832 transport_base_url: &str,
833 route_base_url: &str,
834 provider: ApiProvider,
835 path_suffix: Option<&str>,
836 body: &Value,
837 ) -> String {
838 let uses_deepseek_beta = matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)
839 && is_official_deepseek_beta_base_url(route_base_url)
840 && body_uses_strict_tools(body)
841 && path_suffix.is_none();
842 let path = if uses_deepseek_beta {
843 "beta/chat/completions"
844 } else {
845 "chat/completions"
846 };
847 api_url_with_suffix(transport_base_url, path, path_suffix)
848 }
849
850 fn is_official_deepseek_beta_base_url(base_url: &str) -> bool {
851 matches!(
852 base_url.trim_end_matches('/').to_ascii_lowercase().as_str(),
853 "https://api.deepseek.com/beta" | "https://api.deepseeki.com/beta"
854 )
855 }
856
857 fn body_uses_strict_tools(body: &Value) -> bool {
858 body.get("tools")
859 .and_then(Value::as_array)
860 .is_some_and(|tools| {
861 tools
862 .iter()
863 .any(|tool| tool.pointer("/function/strict").and_then(Value::as_bool) == Some(true))
864 })
865 }
866
867 fn normalize_audio_format(format: &str) -> String {
868 let normalized = format.trim().to_ascii_lowercase();
869 if normalized.is_empty() {
870 "wav".to_string()
871 } else {
872 normalized
873 }
874 }
875
876 fn parse_speech_audio_response(payload: &Value) -> Result<(Vec<u8>, Option<String>)> {
877 let audio = payload
878 .get("choices")
879 .and_then(Value::as_array)
880 .and_then(|choices| choices.first())
881 .and_then(|choice| {
882 choice
883 .get("message")
884 .and_then(|message| message.get("audio"))
885 .or_else(|| choice.get("delta").and_then(|delta| delta.get("audio")))
886 })
887 .or_else(|| payload.get("audio"))
888 .context("Speech synthesis response did not include choices[0].message.audio")?;
889
890 let data = audio
891 .get("data")
892 .and_then(Value::as_str)
893 .context("Speech synthesis response did not include audio.data")?
894 .trim();
895 let data = data
896 .split_once(',')
897 .map(|(_, base64)| base64.trim())
898 .unwrap_or(data);
899 let audio_bytes = general_purpose::STANDARD
900 .decode(data)
901 .context("Failed to decode speech audio base64 data")?;
902 let transcript = audio
903 .get("transcript")
904 .and_then(Value::as_str)
905 .map(str::to_string);
906
907 Ok((audio_bytes, transcript))
908 }
909
910 fn build_speech_synthesis_body(
911 model: &str,
912 text: &str,
913 instruction: Option<&str>,
914 audio: Value,
915 ) -> Value {
916 let mut messages = Vec::new();
917 if let Some(instruction) = instruction.map(str::trim).filter(|value| !value.is_empty()) {
918 messages.push(json!({
919 "role": "user",
920 "content": instruction,
921 }));
922 }
923 messages.push(json!({
924 "role": "assistant",
925 "content": text,
926 }));
927
928 json!({
929 "model": model,
930 "messages": messages,
931 "audio": audio,
932 })
933 }
934
935 // === DeepSeekClient ===
936
937 /// Returns true when CODEWHALE_FORCE_HTTP1 (legacy alias: DEEPSEEK_FORCE_HTTP1)
938 /// is set to a truthy value (`1`, `true`, `yes`, `on`, case-insensitive). Used
939 /// by `build_http_client` to opt out of HTTP/2 entirely when a provider's edge
940 /// mishandles long-lived H2 streams (#103). Anything else (unset, `0`,
941 /// `false`, ...) leaves HTTP/2 on.
942 pub(crate) fn force_http1_from_env() -> bool {
943 std::env::var("CODEWHALE_FORCE_HTTP1")
944 .or_else(|_| std::env::var("DEEPSEEK_FORCE_HTTP1"))
945 .ok()
946 .map(|v| v.trim().to_ascii_lowercase())
947 .is_some_and(|v| matches!(v.as_str(), "1" | "true" | "yes" | "on"))
948 }
949
950 /// Read `SSL_CERT_FILE` and add its contents as extra root
951 /// certificates on the reqwest builder (#418). Tries the PEM-bundle
952 /// parser first (covers single-cert files too), then falls back to
953 /// DER. All failures log a warning and return the builder unchanged
954 /// so a malformed env var degrades gracefully.
955 fn add_extra_root_certs(
956 mut builder: reqwest::ClientBuilder,
957 cert_path: &str,
958 ) -> reqwest::ClientBuilder {
959 let bytes = match std::fs::read(cert_path) {
960 Ok(b) => b,
961 Err(err) => {
962 logging::warn(format!(
963 "SSL_CERT_FILE={cert_path} could not be read: {err}"
964 ));
965 return builder;
966 }
967 };
968
969 if let Ok(certs) = reqwest::Certificate::from_pem_bundle(&bytes) {
970 let added = certs.len();
971 for cert in certs {
972 builder = builder.add_root_certificate(cert);
973 }
974 logging::info(format!(
975 "SSL_CERT_FILE={cert_path} loaded ({added} cert(s))"
976 ));
977 return builder;
978 }
979
980 match reqwest::Certificate::from_der(&bytes) {
981 Ok(cert) => {
982 builder = builder.add_root_certificate(cert);
983 logging::info(format!("SSL_CERT_FILE={cert_path} loaded (1 DER cert)"));
984 }
985 Err(err) => {
986 logging::warn(format!(
987 "SSL_CERT_FILE={cert_path} could not be parsed as PEM bundle or DER: {err}"
988 ));
989 }
990 }
991 builder
992 }
993
994 impl DeepSeekClient {
995 /// Create a DeepSeek client from CLI configuration.
996 pub fn new(config: &Config) -> Result<Self> {
997 let api_provider = config.api_provider();
998 let model_aware = api_provider.metadata().is_some_and(|provider| {
999 provider.wire_policy() == codewhale_config::provider::WirePolicy::ModelAware
1000 });
1001 if model_aware {
1002 let route = crate::route_runtime::resolve_runtime_route(config, api_provider, None)
1003 .map_err(anyhow::Error::msg)?;
1004 return Self::from_candidate(&route.config, &route.candidate);
1005 }
1006 Self::from_parts(
1007 config.deepseek_base_url(),
1008 config.default_model(),
1009 provider_wire_format_for_config(api_provider, Some(config)),
1010 config,
1011 )
1012 }
1013
1014 /// Create a DeepSeek client whose transport is bound to a runtime-resolved
1015 /// route (#3384).
1016 ///
1017 /// The base URL and default model come from the executable `candidate`, so
1018 /// the client talks to exactly the endpoint and wire model the resolver
1019 /// chose instead of re-deriving them from `Config`. Secrets stay in
1020 /// `Config`: `ReadyRouteCandidate` is secret-free by design (it carries only
1021 /// an auth-source *class*), so the API key and provider are still read from
1022 /// `config`.
1023 pub fn from_candidate(config: &Config, candidate: &ReadyRouteCandidate) -> Result<Self> {
1024 Self::from_parts(
1025 candidate.endpoint().base_url.clone(),
1026 candidate.wire_model_id().as_str().to_string(),
1027 candidate.protocol(),
1028 config,
1029 )
1030 }
1031
1032 /// Shared constructor body for [`Self::new`] and [`Self::from_candidate`].
1033 ///
1034 /// `base_url` and `default_model` are the only inputs that differ between
1035 /// the two entry points; everything else (auth, provider, retry, headers,
1036 /// timeouts) is derived from `config` so the two paths cannot drift.
1037 fn from_parts(
1038 base_url: String,
1039 default_model: String,
1040 wire_format: WireFormat,
1041 config: &Config,
1042 ) -> Result<Self> {
1043 let api_provider = config.api_provider();
1044 let provider_identity = config.provider_identity_for(api_provider);
1045 let billing_surface = crate::route_billing::billing_surface_for_dispatch(
1046 Some(config),
1047 api_provider,
1048 Some(&base_url),
1049 )
1050 .map(str::to_string);
1051 let billing_mode = crate::route_billing::for_route(config, api_provider).into();
1052 if api_provider == ApiProvider::OpencodeGo {
1053 validate_route(api_provider, &default_model).map_err(anyhow::Error::msg)?;
1054 }
1055 let (api_key, codex_account_id) = if api_provider == ApiProvider::OpenaiCodex {
1056 let credentials = config.codex_credentials()?;
1057 (credentials.access_token, credentials.account_id)
1058 } else {
1059 (config.deepseek_api_key()?, None)
1060 };
1061 let model_bound_secret_values =
1062 Arc::new(configured_model_bound_secret_values(config, &api_key));
1063 validate_base_url_security(&base_url)?;
1064 let retry = config.retry_policy();
1065 let stream_idle_timeout = Duration::from_secs(config.stream_chunk_timeout_secs());
1066 let http_headers = config.http_headers();
1067 let auth_disabled =
1068 auth_mode_disables_api_key(config.auth_mode_for_provider(api_provider).as_deref());
1069 let insecure_skip_tls_verify = config.insecure_skip_tls_verify();
1070 let path_suffix = config
1071 .provider_config_for(api_provider)
1072 .and_then(|p| p.path_suffix.clone());
1073 let reasoning_stream_style = config
1074 .provider_config_for(api_provider)
1075 .and_then(|p| p.reasoning_stream_style.clone());
1076 let request_concurrency_limit = config.provider_max_concurrency(api_provider);
1077
1078 logging::info(format!("API provider: {}", api_provider.as_str()));
1079 logging::info(format!(
1080 "API base URL: {}",
1081 redact_url_for_display(&base_url)
1082 ));
1083 if let Some(suffix) = &path_suffix {
1084 logging::info(format!("API path suffix override: {suffix}"));
1085 }
1086 if !http_headers.is_empty() {
1087 logging::info(format!(
1088 "{} custom HTTP header(s) configured",
1089 http_headers.len()
1090 ));
1091 }
1092 if insecure_skip_tls_verify {
1093 logging::warn(format!(
1094 "TLS certificate verification cannot be disabled for provider {}; use SSL_CERT_FILE with a trusted custom CA bundle instead",
1095 api_provider.as_str()
1096 ));
1097 bail!(
1098 "TLS certificate verification cannot be disabled for provider {}; configure SSL_CERT_FILE with a trusted custom CA bundle instead",
1099 api_provider.as_str()
1100 );
1101 }
1102 logging::info(format!(
1103 "Retry policy: enabled={}, max_retries={}, initial_delay={}s, max_delay={}s",
1104 retry.enabled, retry.max_retries, retry.initial_delay, retry.max_delay
1105 ));
1106 if let Some(limit) = request_concurrency_limit {
1107 logging::info(format!(
1108 "Provider request concurrency cap: {} in-flight request(s)",
1109 limit
1110 ));
1111 }
1112
1113 let http_client = Self::build_http_client_with_auth_mode(
1114 &api_key,
1115 &http_headers,
1116 api_provider,
1117 &base_url,
1118 wire_format,
1119 auth_disabled,
1120 false,
1121 )?;
1122 // Always keep an HTTP/1.1 twin for automatic stream-header fallback
1123 // when H2 stalls. When CODEWHALE_FORCE_HTTP1 is set, both clients are
1124 // HTTP/1.1 and the fallback is a no-op retry path.
1125 let http1_client = Self::build_http_client_with_auth_mode(
1126 &api_key,
1127 &http_headers,
1128 api_provider,
1129 &base_url,
1130 wire_format,
1131 auth_disabled,
1132 true,
1133 )?;
1134
1135 Ok(Self {
1136 http_client,
1137 http1_client,
1138 api_key,
1139 model_bound_secret_values,
1140 base_url,
1141 api_provider,
1142 provider_identity,
1143 billing_surface,
1144 billing_mode,
1145 codex_account_id,
1146 wire_format,
1147 retry,
1148 isolated_request_state: false,
1149 default_model,
1150 connection_health: Arc::new(AsyncMutex::new(ConnectionHealth::default())),
1151 rate_limiter: Arc::new(AsyncMutex::new(TokenBucket::from_env())),
1152 request_concurrency: request_concurrency_limit.map(ProviderConcurrencyLimiter::new),
1153 path_suffix,
1154 #[cfg(test)]
1155 test_chat_transport_base_url: None,
1156 #[cfg(test)]
1157 test_messages_transport_base_url: None,
1158 reasoning_stream_style,
1159 stream_idle_timeout,
1160 })
1161 }
1162
1163 /// Transport destination for Chat Completions requests.
1164 ///
1165 /// Production always uses the semantic route base URL. Unit tests may
1166 /// substitute a local capture server without changing the endpoint/model
1167 /// identity used by exact-route request shaping.
1168 pub(super) fn chat_transport_base_url(&self) -> &str {
1169 #[cfg(test)]
1170 if let Some(base_url) = self.test_chat_transport_base_url.as_deref() {
1171 return base_url;
1172 }
1173 &self.base_url
1174 }
1175
1176 /// Redirect Chat Completions *transport* to a local capture server while
1177 /// the semantic route (`base_url`, model, endpoint identity) stays exact.
1178 ///
1179 /// Test-only, and compiled out of release builds. Route shaping reads
1180 /// [`Self::base_url`], so an exact-route matrix can capture the real
1181 /// first-turn body for `api.z.ai`, `api.moonshot.ai`, `api.kimi.com`, or
1182 /// `api.minimax.io` without ever making a live provider call.
1183 #[cfg(test)]
1184 pub(crate) fn set_test_chat_transport_base_url(&mut self, base_url: String) {
1185 self.test_chat_transport_base_url = Some(base_url);
1186 }
1187
1188 /// Transport destination for a prepared Anthropic-compatible request.
1189 /// Production sends the exact prepared endpoint; tests may redirect the
1190 /// transport while preserving that immutable endpoint for route shaping.
1191 pub(super) fn messages_transport_url(&self, prepared_url: &str) -> String {
1192 #[cfg(test)]
1193 if let Some(base_url) = self.test_messages_transport_base_url.as_deref() {
1194 return anthropic::anthropic_messages_url(base_url);
1195 }
1196 prepared_url.to_string()
1197 }
1198
1199 /// Return a request whose tool results are safe to send to an upstream
1200 /// model provider.
1201 ///
1202 /// Tool output is untrusted model-bound data: it can contain a whole
1203 /// config file, a bare credential emitted by a shell command, or a
1204 /// spillover receipt whose backing content is later persisted by the chat
1205 /// adapter. Keep this boundary above all protocol adapters so Chat,
1206 /// Anthropic Messages, and OpenAI Responses — streaming and non-streaming
1207 /// alike — receive the same sanitized payload.
1208 fn prepare_model_bound_request(&self, mut request: MessageRequest) -> MessageRequest {
1209 let repair =
1210 crate::tool_history_repair::repair_tool_call_pairs_for_provider(&mut request.messages);
1211 if !repair.is_empty() {
1212 tracing::warn!(
1213 repaired_call_ids = ?repair.repaired_call_ids,
1214 duplicate_result_ids = ?repair.duplicate_result_ids,
1215 orphan_result_ids = ?repair.orphan_result_ids,
1216 "repaired tool call/result history before provider projection"
1217 );
1218 }
1219 for message in &mut request.messages {
1220 for block in &mut message.content {
1221 if let ContentBlock::ToolResult { content, .. } = block {
1222 *content = redact_model_bound_text(content, &self.model_bound_secret_values);
1223 }
1224 }
1225 }
1226 request
1227 }
1228
1229 /// Redact configured credentials from text that has been flattened into a
1230 /// normal model-bound text block. Most requests preserve tool results as
1231 /// structured blocks and are sanitized by `prepare_model_bound_request`,
1232 /// but routing/classification prompts intentionally summarize them first.
1233 pub(crate) fn redact_model_bound_text(&self, text: &str) -> String {
1234 redact_model_bound_text(text, &self.model_bound_secret_values)
1235 }
1236
1237 /// Resolve `model` through the central route resolver and rebuild this
1238 /// client when a ModelAware provider maps it to a different wire protocol
1239 /// than the one bound at construction (#5042). `Ok(None)` means the
1240 /// existing binding is already correct for `model`. Mirrors the
1241 /// resolution in `bind_request_to_protocol`, but at a seam where the
1242 /// caller can still rebind instead of failing the first send.
1243 pub(crate) fn rebound_for_model_protocol(
1244 &self,
1245 config: Option<&Config>,
1246 model: &str,
1247 ) -> Result<Option<Self>> {
1248 let model_aware = self.api_provider.metadata().is_some_and(|provider| {
1249 provider.wire_policy() == codewhale_config::provider::WirePolicy::ModelAware
1250 });
1251 if !model_aware {
1252 return Ok(None);
1253 }
1254 static RESOLVER: OnceLock<RouteResolver> = OnceLock::new();
1255 let candidate = RESOLVER
1256 .get_or_init(RouteResolver::new)
1257 .resolve(&RouteRequest {
1258 explicit_provider: self.api_provider.kind(),
1259 model_selector: Some(LogicalModelRef::from(model)),
1260 saved_provider_model: None,
1261 base_url_override: Some(self.base_url.clone()),
1262 limit_overrides: Vec::new(),
1263 })
1264 .map_err(anyhow::Error::msg)?;
1265 if candidate.protocol() == self.wire_format {
1266 return Ok(None);
1267 }
1268 let config = config.ok_or_else(|| {
1269 anyhow::anyhow!(
1270 "{} model {:?} uses {:?}, but this client is bound to {:?} and no configuration is available to rebuild it",
1271 self.api_provider.display_name(),
1272 model,
1273 candidate.protocol(),
1274 self.wire_format
1275 )
1276 })?;
1277 Self::from_candidate(config, &candidate).map(Some)
1278 }
1279
1280 fn bind_request_to_protocol(&self, mut request: MessageRequest) -> Result<MessageRequest> {
1281 let model_aware = self.api_provider.metadata().is_some_and(|provider| {
1282 provider.wire_policy() == codewhale_config::provider::WirePolicy::ModelAware
1283 });
1284 if !model_aware {
1285 return Ok(request);
1286 }
1287
1288 static RESOLVER: OnceLock<RouteResolver> = OnceLock::new();
1289 let candidate = RESOLVER
1290 .get_or_init(RouteResolver::new)
1291 .resolve(&RouteRequest {
1292 explicit_provider: self.api_provider.kind(),
1293 model_selector: Some(LogicalModelRef::from(request.model.as_str())),
1294 saved_provider_model: None,
1295 base_url_override: Some(self.base_url.clone()),
1296 limit_overrides: Vec::new(),
1297 })
1298 .map_err(anyhow::Error::msg)?;
1299 if candidate.protocol() != self.wire_format {
1300 bail!(
1301 "{} model {:?} uses {:?}, but this client is bound to {:?}; resolve a new model route before sending",
1302 self.api_provider.display_name(),
1303 request.model,
1304 candidate.protocol(),
1305 self.wire_format
1306 );
1307 }
1308 request.model = candidate.wire_model_id().as_str().to_string();
1309 Ok(request)
1310 }
1311
1312 #[cfg(test)]
1313 fn build_http_client(
1314 api_key: &str,
1315 extra_headers: &HashMap<String, String>,
1316 api_provider: ApiProvider,
1317 base_url: &str,
1318 ) -> Result<reqwest::Client> {
1319 Self::build_http_client_with_auth_mode(
1320 api_key,
1321 extra_headers,
1322 api_provider,
1323 base_url,
1324 provider_default_wire_format(api_provider),
1325 false,
1326 false,
1327 )
1328 }
1329
1330 fn build_http_client_with_auth_mode(
1331 api_key: &str,
1332 extra_headers: &HashMap<String, String>,
1333 api_provider: ApiProvider,
1334 base_url: &str,
1335 wire_format: WireFormat,
1336 auth_disabled: bool,
1337 force_http1: bool,
1338 ) -> Result<reqwest::Client> {
1339 let headers = build_default_headers(
1340 api_key,
1341 extra_headers,
1342 api_provider,
1343 base_url,
1344 wire_format,
1345 auth_disabled,
1346 )?;
1347 let mut builder = crate::tls::reqwest_client_builder()
1348 .default_headers(headers)
1349 .user_agent(client_user_agent(api_provider))
1350 .connect_timeout(Duration::from_secs(30))
1351 .tcp_keepalive(Some(Duration::from_secs(30)))
1352 .http2_keep_alive_interval(Some(Duration::from_secs(15)))
1353 .http2_keep_alive_timeout(Duration::from_secs(20))
1354 .min_tls_version(reqwest::tls::Version::TLS_1_2);
1355 let pin_http1 = force_http1 || force_http1_from_env();
1356 if pin_http1 {
1357 if force_http1_from_env() && !force_http1 {
1358 logging::info("CODEWHALE_FORCE_HTTP1=1 — pinning HTTP client to HTTP/1.1");
1359 }
1360 builder = builder.http1_only();
1361 }
1362 if let Ok(cert_path) = std::env::var("SSL_CERT_FILE")
1363 && !cert_path.is_empty()
1364 {
1365 builder = add_extra_root_certs(builder, &cert_path);
1366 }
1367 builder.build().map_err(Into::into)
1368 }
1369
1370 /// HTTP/1.1 client for automatic stream-header fallback.
1371 #[must_use]
1372 pub(crate) fn http1_fallback_client(&self) -> &reqwest::Client {
1373 &self.http1_client
1374 }
1375
1376 #[cfg(test)]
1377 fn default_headers(
1378 api_key: &str,
1379 extra_headers: &HashMap<String, String>,
1380 ) -> Result<HeaderMap> {
1381 build_default_headers(
1382 api_key,
1383 extra_headers,
1384 ApiProvider::Deepseek,
1385 crate::config::DEFAULT_DEEPSEEK_BASE_URL,
1386 WireFormat::ChatCompletions,
1387 false,
1388 )
1389 }
1390
1391 #[cfg(test)]
1392 fn default_headers_for_provider(
1393 api_key: &str,
1394 extra_headers: &HashMap<String, String>,
1395 api_provider: ApiProvider,
1396 base_url: &str,
1397 ) -> Result<HeaderMap> {
1398 build_default_headers(
1399 api_key,
1400 extra_headers,
1401 api_provider,
1402 base_url,
1403 provider_default_wire_format(api_provider),
1404 false,
1405 )
1406 }
1407
1408 #[cfg(test)]
1409 fn default_headers_for_provider_with_auth_disabled(
1410 api_key: &str,
1411 extra_headers: &HashMap<String, String>,
1412 api_provider: ApiProvider,
1413 base_url: &str,
1414 ) -> Result<HeaderMap> {
1415 build_default_headers(
1416 api_key,
1417 extra_headers,
1418 api_provider,
1419 base_url,
1420 provider_default_wire_format(api_provider),
1421 true,
1422 )
1423 }
1424 }
1425
1426 fn build_default_headers(
1427 api_key: &str,
1428 extra_headers: &HashMap<String, String>,
1429 api_provider: ApiProvider,
1430 base_url: &str,
1431 wire_format: WireFormat,
1432 auth_disabled: bool,
1433 ) -> Result<HeaderMap> {
1434 let mut headers = HeaderMap::new();
1435 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
1436 let api_key = api_key.trim();
1437 let uses_anthropic_messages = wire_format == WireFormat::AnthropicMessages;
1438 if uses_anthropic_messages {
1439 // #3014: most Messages API routes authenticate with `x-api-key`.
1440 // OpenModel also supports Bearer auth for Messages, and its `/models`
1441 // endpoint requires it, so the header chooser below keeps OpenModel on
1442 // Bearer while still pinning the Anthropic wire contract here.
1443 headers.insert(
1444 HeaderName::from_static("anthropic-version"),
1445 HeaderValue::from_static("2023-06-01"),
1446 );
1447 }
1448 let auth_header_name = if auth_disabled {
1449 None
1450 } else if !api_key.is_empty()
1451 && uses_anthropic_messages
1452 && api_provider != ApiProvider::Openmodel
1453 {
1454 Some(HeaderName::from_static("x-api-key"))
1455 } else if !api_key.is_empty()
1456 && api_provider == ApiProvider::XiaomiMimo
1457 && (xiaomi_mimo_base_url_uses_token_plan(base_url)
1458 || xiaomi_mimo_api_key_uses_token_plan(api_key))
1459 {
1460 Some(HeaderName::from_static("api-key"))
1461 } else if !api_key.is_empty() {
1462 Some(AUTHORIZATION)
1463 } else {
1464 None
1465 };
1466 if let Some(header_name) = auth_header_name.as_ref() {
1467 let header_value = if *header_name == AUTHORIZATION {
1468 HeaderValue::from_str(&format!("Bearer {api_key}"))?
1469 } else {
1470 HeaderValue::from_str(api_key)?
1471 };
1472 headers.insert(header_name.clone(), header_value);
1473 }
1474 for (name, value) in extra_headers {
1475 let name = name.trim();
1476 let value = value.trim();
1477 if name.is_empty() || value.is_empty() {
1478 continue;
1479 }
1480 if auth_disabled && is_upstream_auth_header(name) {
1481 continue;
1482 }
1483 let header_name = HeaderName::from_bytes(name.as_bytes())?;
1484 if header_name == AUTHORIZATION
1485 || header_name == CONTENT_TYPE
1486 || auth_header_name.as_ref() == Some(&header_name)
1487 || (auth_header_name.is_some() && is_auth_dialect_header(&header_name))
1488 {
1489 continue;
1490 }
1491 headers.insert(header_name, HeaderValue::from_str(value)?);
1492 }
1493 Ok(headers)
1494 }
1495
1496 fn is_auth_dialect_header(header_name: &HeaderName) -> bool {
1497 header_name == AUTHORIZATION
1498 || header_name == HeaderName::from_static("api-key")
1499 || header_name == HeaderName::from_static("x-api-key")
1500 }
1501
1502 fn provider_default_wire_format(api_provider: ApiProvider) -> WireFormat {
1503 provider_wire_format_for_config(api_provider, None)
1504 }
1505
1506 /// Resolve the wire dialect for a dual-protocol vendor.
1507 ///
1508 /// Power-user toggle: `providers.<id>.wire = "openai" | "anthropic"`.
1509 /// Legacy dialect kinds (`*Anthropic`) still force Messages. Everyone else
1510 /// keeps the descriptor's fixed policy (or Chat Completions).
1511 fn provider_wire_format_for_config(
1512 api_provider: ApiProvider,
1513 config: Option<&crate::config::Config>,
1514 ) -> WireFormat {
1515 let catalog = api_provider.catalog_identity();
1516 let wire = config
1517 .and_then(|cfg| cfg.provider_config_for(catalog))
1518 .and_then(|entry| entry.wire.as_deref());
1519 let prefers_anthropic = matches!(
1520 api_provider,
1521 ApiProvider::DeepseekAnthropic
1522 | ApiProvider::MinimaxAnthropic
1523 | ApiProvider::ModelstudioTokenPlanAnthropic
1524 | ApiProvider::ModelstudioCodingPlanAnthropic
1525 ) || wire_config_prefers_anthropic(wire);
1526
1527 if prefers_anthropic
1528 && matches!(
1529 catalog,
1530 ApiProvider::Deepseek
1531 | ApiProvider::Minimax
1532 | ApiProvider::ModelstudioTokenPlan
1533 | ApiProvider::DeepseekAnthropic
1534 | ApiProvider::MinimaxAnthropic
1535 | ApiProvider::ModelstudioTokenPlanAnthropic
1536 | ApiProvider::ModelstudioCodingPlan
1537 | ApiProvider::ModelstudioCodingPlanAnthropic
1538 )
1539 {
1540 return WireFormat::AnthropicMessages;
1541 }
1542
1543 api_provider
1544 .kind()
1545 .and_then(|kind| {
1546 codewhale_config::provider::provider_for_kind(kind)
1547 .wire_policy()
1548 .fixed()
1549 })
1550 .unwrap_or_else(|| {
1551 if api_provider == ApiProvider::OpencodeZen {
1552 WireFormat::Responses
1553 } else {
1554 WireFormat::ChatCompletions
1555 }
1556 })
1557 }
1558
1559 fn wire_config_prefers_anthropic(wire: Option<&str>) -> bool {
1560 let Some(raw) = wire.map(str::trim).filter(|value| !value.is_empty()) else {
1561 return false;
1562 };
1563 let normalized = raw.to_ascii_lowercase().replace(['_', ' '], "-");
1564 matches!(
1565 normalized.as_str(),
1566 "anthropic"
1567 | "anthropic-messages"
1568 | "messages"
1569 | "claude"
1570 | "anthropic-compatible"
1571 | "anthropic-compat"
1572 )
1573 }
1574
1575 fn api_provider_skips_models_probe(api_provider: ApiProvider) -> bool {
1576 matches!(api_provider, ApiProvider::DeepseekAnthropic)
1577 }
1578
1579 #[must_use]
1580 #[cfg(test)]
1581 pub(crate) fn provider_api_key_verification_is_observed(api_provider: ApiProvider) -> bool {
1582 !api_provider_skips_models_probe(api_provider)
1583 }
1584
1585 /// Verify a provider API key by hitting the `/models` endpoint
1586 /// (#3875). Builds a minimal HTTP client with the canonical auth
1587 /// headers for `provider`, issues a single GET, and returns
1588 /// `Ok(())` on a 2xx response or `Err(reason)` on any failure.
1589 ///
1590 /// This is intentionally a one-shot call — no retry, no rate-limit
1591 /// wait — so a bad key is surfaced immediately.
1592 pub async fn verify_provider_api_key(
1593 provider: ApiProvider,
1594 api_key: &str,
1595 base_url: &str,
1596 ) -> Result<(), String> {
1597 if api_provider_skips_models_probe(provider) {
1598 // Providers without a /models endpoint can't be verified this
1599 // way; accept the key optimistically (same as health_check).
1600 return Ok(());
1601 }
1602 let headers = build_default_headers(
1603 api_key,
1604 &Default::default(),
1605 provider,
1606 base_url,
1607 provider_default_wire_format(provider),
1608 false,
1609 )
1610 .map_err(|err| format!("failed to build auth headers: {err:#}"))?;
1611 let client = crate::tls::reqwest_client_builder()
1612 .default_headers(headers)
1613 .user_agent(concat!(
1614 "Mozilla/5.0 (compatible; codewhale/",
1615 env!("CARGO_PKG_VERSION"),
1616 "; +https://github.com/Hmbown/CodeWhale)"
1617 ))
1618 .connect_timeout(Duration::from_secs(10))
1619 .timeout(Duration::from_secs(15))
1620 .build()
1621 .map_err(|err| format!("failed to build HTTP client: {err:#}"))?;
1622 let url = api_url(base_url, "models");
1623 let response = client
1624 .get(&url)
1625 .send()
1626 .await
1627 .map_err(|err| format!("request failed: {err:#}"))?;
1628 let status = response.status();
1629 if status.is_success() {
1630 // TelecomJS verification already returns the key-scoped model roster.
1631 // Publish it before returning so the guided model picker can render the
1632 // live choices in this session instead of requiring a restart. A valid
1633 // 2xx response remains sufficient to verify the key even if the body is
1634 // malformed; in that case failure-preserving catalog semantics keep the
1635 // existing/static rows.
1636 let body = response.text().await.unwrap_or_default();
1637 if provider == ApiProvider::Telecomjs
1638 && let Ok(offerings) = telecomjs_catalog_offerings_from_body(
1639 &body,
1640 provider.as_str(),
1641 &base_url_fingerprint(base_url),
1642 now_unix(),
1643 )
1644 {
1645 crate::provider_lake::merge_live_offerings(offerings);
1646 }
1647 Ok(())
1648 } else {
1649 let body = response.text().await.unwrap_or_default();
1650 let summary = if body.chars().count() > 200 {
1651 format!("{}...", body.chars().take(200).collect::<String>())
1652 } else {
1653 body
1654 };
1655 Err(format!("HTTP {status}: {summary}"))
1656 }
1657 }
1658
1659 fn translation_system_prompt(target_language: &str) -> String {
1660 format!(
1661 "You are a professional translator. Your ONLY task is to translate text to {target_language}. \
1662 Rules:\n\
1663 1. Output ONLY the translation, nothing else — no explanations, no notes, no quotes.\n\
1664 2. Preserve all code blocks (```...```), URLs, file paths, command names, \
1665 and technical terms like API names, function names, and library names untranslated.\n\
1666 3. Keep Markdown formatting (headings, lists, bold, italics, links) intact.\n\
1667 4. Translate all natural-language prose naturally and professionally.\n\
1668 5. Do NOT add any prefix, suffix, or commentary.\n\
1669 6. If the input is already in {target_language} or contains no prose to translate, \
1670 return it as-is."
1671 )
1672 }
1673
1674 fn translation_message_request(text: &str, model: String, target_language: &str) -> MessageRequest {
1675 MessageRequest {
1676 model,
1677 messages: vec![Message {
1678 role: "user".to_string(),
1679 content: vec![ContentBlock::Text {
1680 text: text.to_string(),
1681 cache_control: None,
1682 }],
1683 }],
1684 max_tokens: 4096,
1685 system: Some(SystemPrompt::Text(translation_system_prompt(
1686 target_language,
1687 ))),
1688 tools: None,
1689 tool_choice: None,
1690 metadata: None,
1691 thinking: None,
1692 reasoning_effort: Some("off".to_string()),
1693 stream: Some(false),
1694 temperature: Some(0.1),
1695 top_p: None,
1696 }
1697 }
1698
1699 fn translation_text_from_response(response: &MessageResponse) -> Result<String> {
1700 let translated = response
1701 .content
1702 .iter()
1703 .filter_map(|block| match block {
1704 ContentBlock::Text { text, .. } => Some(text.as_str()),
1705 _ => None,
1706 })
1707 .collect::<Vec<_>>()
1708 .join("")
1709 .trim()
1710 .to_string();
1711 if translated.is_empty() {
1712 bail!("translate: Anthropic Messages response did not contain text content");
1713 }
1714 Ok(translated)
1715 }
1716
1717 fn xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool {
1718 let normalized = base_url.trim().to_ascii_lowercase();
1719 let without_scheme = normalized
1720 .strip_prefix("https://")
1721 .or_else(|| normalized.strip_prefix("http://"))
1722 .unwrap_or(&normalized);
1723 let host = without_scheme
1724 .split(['/', '?', '#'])
1725 .next()
1726 .unwrap_or_default();
1727 let host = host.split(':').next().unwrap_or(host);
1728 host.starts_with("token-plan-") && host.ends_with(".xiaomimimo.com")
1729 }
1730
1731 fn xiaomi_mimo_api_key_uses_token_plan(api_key: &str) -> bool {
1732 api_key.trim_start().starts_with("tp-")
1733 }
1734
1735 impl DeepSeekClient {
1736 /// Returns the API base URL used by this client.
1737 pub fn base_url(&self) -> &str {
1738 &self.base_url
1739 }
1740
1741 /// Prepare — but do not send — the exact outbound request for `request`.
1742 ///
1743 /// This is *the* outbound seam (#1004). Production dispatch
1744 /// (`create_message`, `create_message_stream`) and `/preview-request` both
1745 /// call it, so a preview cannot describe a request different from the one
1746 /// a turn would send.
1747 ///
1748 /// It runs, in production order:
1749 ///
1750 /// 1. tool-history repair and model-bound secret redaction
1751 /// ([`Self::prepare_model_bound_request`]);
1752 /// 2. protocol binding and route model re-resolution
1753 /// ([`Self::bind_request_to_protocol`]);
1754 /// 3. the dialect's own body builder — Chat Completions, Anthropic
1755 /// Messages, or OpenAI Responses — including every provider-specific
1756 /// sanitizer and reasoning shaper;
1757 /// 4. exact endpoint resolution for that dialect and route shape.
1758 ///
1759 /// It performs no I/O and mutates no client state.
1760 pub(crate) fn prepare_outbound_request(
1761 &self,
1762 request: MessageRequest,
1763 stream: bool,
1764 ) -> Result<PreparedOutboundRequest> {
1765 let request = self.bind_request_to_protocol(self.prepare_model_bound_request(request))?;
1766 let requested_effort = request.reasoning_effort.clone();
1767 let dialect = WireDialect::from_wire_format(self.wire_format);
1768 // `stream` is the caller's entry point, not a wire fact: each dialect
1769 // decides for itself what the body's `stream` field says.
1770 let entrypoint = CallerStreamMode::from_stream_flag(stream);
1771
1772 match self.wire_format {
1773 WireFormat::ChatCompletions => {
1774 let wire = chat::build_chat_wire_body(
1775 &request,
1776 self.api_provider,
1777 &self.base_url,
1778 stream,
1779 )?;
1780 let url = chat_completions_url(
1781 self.chat_transport_base_url(),
1782 &self.base_url,
1783 self.api_provider,
1784 self.path_suffix.as_deref(),
1785 &wire.body,
1786 );
1787 let shape = prepared::chat_route_shape(
1788 self.api_provider,
1789 &self.base_url,
1790 &wire.model,
1791 &url,
1792 );
1793 Ok(PreparedOutboundRequest::new(
1794 dialect,
1795 self.endpoint_identity(url, shape),
1796 wire.model,
1797 wire.body,
1798 requested_effort,
1799 wire.replay_input_tokens,
1800 entrypoint,
1801 ))
1802 }
1803 WireFormat::AnthropicMessages => {
1804 let body = self.build_anthropic_body(&request, stream);
1805 let url = anthropic::anthropic_messages_url(&self.base_url);
1806 let shape = if self.api_provider == ApiProvider::OpencodeZen {
1807 RouteShape::OpencodeZen
1808 } else if self.api_provider == ApiProvider::Custom {
1809 RouteShape::CustomCompatible
1810 } else {
1811 RouteShape::Standard
1812 };
1813 let wire_model = body
1814 .get("model")
1815 .and_then(Value::as_str)
1816 .unwrap_or(request.model.as_str())
1817 .to_string();
1818 Ok(PreparedOutboundRequest::new(
1819 dialect,
1820 self.endpoint_identity(url, shape),
1821 wire_model,
1822 body,
1823 requested_effort,
1824 None,
1825 entrypoint,
1826 ))
1827 }
1828 WireFormat::Responses => {
1829 let body =
1830 responses::build_responses_body_for_provider(&request, self.api_provider);
1831 let is_codex = self.api_provider == ApiProvider::OpenaiCodex;
1832 let url = if is_codex {
1833 format!("{}{}", self.base_url, responses::CODEX_RESPONSES_PATH)
1834 } else {
1835 responses_api_url(&self.base_url, self.api_provider)
1836 };
1837 let shape = if is_codex {
1838 RouteShape::CodexResponses
1839 } else if self.api_provider == ApiProvider::OpencodeZen {
1840 RouteShape::OpencodeZen
1841 } else if self.api_provider == ApiProvider::Custom {
1842 RouteShape::CustomCompatible
1843 } else {
1844 RouteShape::Standard
1845 };
1846 let wire_model = body
1847 .get("model")
1848 .and_then(Value::as_str)
1849 .unwrap_or(request.model.as_str())
1850 .to_string();
1851 Ok(PreparedOutboundRequest::new(
1852 dialect,
1853 self.endpoint_identity(url, shape),
1854 wire_model,
1855 body,
1856 requested_effort,
1857 None,
1858 entrypoint,
1859 ))
1860 }
1861 }
1862 }
1863
1864 /// Typed identity of the endpoint this client would POST to.
1865 ///
1866 /// `route_id` is left empty here on purpose: the client knows the provider
1867 /// and the URL, but only the caller's resolved turn plan knows whether the
1868 /// user reached this route through a named custom-provider entry. The
1869 /// engine attaches it with [`PreparedOutboundRequest::with_route_id`].
1870 fn endpoint_identity(&self, url: String, shape: RouteShape) -> EndpointIdentity {
1871 EndpointIdentity {
1872 provider_id: self.api_provider.as_str().to_string(),
1873 provider_display: self.api_provider.display_name().to_string(),
1874 route_id: None,
1875 url,
1876 shape,
1877 }
1878 }
1879
1880 /// Returns the active API provider for this client.
1881 pub fn api_provider(&self) -> ApiProvider {
1882 self.api_provider
1883 }
1884
1885 /// Secret-free receipt for the exact base endpoint and credential
1886 /// generation this client was constructed with.
1887 ///
1888 /// This is the only way the API key leaves `client.rs`, and it leaves as a
1889 /// one-way digest. Minting the receipt here — rather than re-reading config
1890 /// at some later lifecycle point — is what makes it immutable proof of the
1891 /// route that was actually installed for the turn.
1892 #[must_use]
1893 pub fn turn_route_receipt(
1894 &self,
1895 provider_identity: &str,
1896 ) -> crate::route_receipt::TurnRouteReceipt {
1897 crate::route_receipt::TurnRouteReceipt::new(
1898 self.api_provider,
1899 provider_identity,
1900 &self.default_model,
1901 &self.base_url,
1902 &self.api_key,
1903 )
1904 }
1905
1906 /// Capture the immutable, redacted route envelope for a request immediately
1907 /// before it is dispatched. The wire model is normalized exactly as the
1908 /// transport will normalize it; a provider-returned alias must never replace
1909 /// this billing identity later.
1910 #[must_use]
1911 pub fn effective_route_envelope(
1912 &self,
1913 requested_model: &str,
1914 dispatched_at: chrono::DateTime<chrono::Utc>,
1915 ) -> crate::cost_status::EffectiveRouteEnvelope {
1916 let model =
1917 wire_model_for_provider_route(self.api_provider, &self.base_url, requested_model);
1918 crate::cost_status::EffectiveRouteEnvelope {
1919 provider: self.api_provider,
1920 provider_identity: self.provider_identity.clone(),
1921 model,
1922 billing_surface: self.billing_surface.clone(),
1923 endpoint_fingerprint: crate::cost_status::endpoint_fingerprint(&self.base_url),
1924 billing_mode: self.billing_mode,
1925 dispatched_at,
1926 }
1927 }
1928
1929 /// Resolved in-flight provider request cap, if one is active.
1930 #[must_use]
1931 pub fn provider_request_concurrency_limit(&self) -> Option<usize> {
1932 self.request_concurrency
1933 .as_ref()
1934 .map(ProviderConcurrencyLimiter::limit)
1935 }
1936
1937 /// Number of currently active requests held by this client's shared
1938 /// provider request limiter.
1939 #[must_use]
1940 pub fn active_provider_requests(&self) -> usize {
1941 self.request_concurrency
1942 .as_ref()
1943 .map_or(0, ProviderConcurrencyLimiter::active)
1944 }
1945
1946 async fn acquire_provider_request_permit(&self) -> Option<ProviderRequestPermit> {
1947 match self.request_concurrency.as_ref() {
1948 Some(limiter) => limiter.acquire().await,
1949 None => None,
1950 }
1951 }
1952
1953 fn hold_provider_request_permit_for_stream(
1954 stream: crate::llm_client::StreamEventBox,
1955 permit: Option<ProviderRequestPermit>,
1956 ) -> crate::llm_client::StreamEventBox {
1957 Box::pin(async_stream::stream! {
1958 let _permit = permit;
1959 let mut stream = stream;
1960 while let Some(event) = stream.next().await {
1961 yield event;
1962 }
1963 })
1964 }
1965
1966 /// Translate text to the requested target language using a focused
1967 /// non-streaming chat completion call on the supplied model.
1968 ///
1969 /// This is a lightweight translation service — no tool calls, no
1970 /// streaming, no conversation history. The dedicated translation agent
1971 /// receives the source text and returns only the translated result.
1972 pub async fn translate(
1973 &self,
1974 text: &str,
1975 model: &str,
1976 target_language: &str,
1977 ) -> Result<String> {
1978 let model = wire_model_for_provider_route(self.api_provider, &self.base_url, model);
1979 if self.wire_format != WireFormat::ChatCompletions {
1980 // Non-Chat dialects reuse the prepared-request seam so translation
1981 // cannot drift from production shaping. Translation is still an
1982 // *auxiliary* call, not a primary agent turn: the Chat dialect
1983 // below builds its own small fixed body, and `/preview-request`
1984 // deliberately does not claim to describe either
1985 // (see `docs/PREVIEW_REQUEST.md`).
1986 let prepared = self.prepare_outbound_request(
1987 translation_message_request(text, model, target_language),
1988 false,
1989 )?;
1990 let response = match prepared.dialect {
1991 WireDialect::OpenAiResponses => self.handle_responses_message(&prepared).await?,
1992 WireDialect::AnthropicMessages => self.handle_anthropic_message(&prepared).await?,
1993 WireDialect::ChatCompletions => unreachable!(),
1994 };
1995 return translation_text_from_response(&response);
1996 }
1997
1998 let url = api_url_with_suffix(
1999 &self.base_url,
2000 "chat/completions",
2001 self.path_suffix.as_deref(),
2002 );
2003 let mut body = serde_json::json!({
2004 "model": model,
2005 "messages": [
2006 {
2007 "role": "system",
2008 "content": translation_system_prompt(target_language)
2009 },
2010 {
2011 "role": "user",
2012 "content": text
2013 }
2014 ],
2015 "max_tokens": 4096,
2016 "temperature": 0.1,
2017 "stream": false
2018 });
2019 chat::apply_route_reasoning_controls(
2020 &mut body,
2021 self.api_provider,
2022 &self.base_url,
2023 &model,
2024 Some("off"),
2025 );
2026
2027 let response = self.send_json_with_retry(&url, &body).await?;
2028
2029 let value: serde_json::Value = response.json().await?;
2030 let translated = value["choices"][0]["message"]["content"]
2031 .as_str()
2032 .ok_or_else(|| anyhow::anyhow!("translate: unexpected API response shape"))?
2033 .trim()
2034 .to_string();
2035
2036 Ok(translated)
2037 }
2038
2039 /// List available models from the provider.
2040 pub async fn list_models(&self) -> Result<Vec<AvailableModel>> {
2041 let url = api_url(&self.base_url, "models");
2042 let response = self.send_with_retry(|| self.http_client.get(&url)).await?;
2043
2044 let status = response.status();
2045 if !status.is_success() {
2046 let raw_error_text = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await;
2047 let error_text = sanitize_http_error_body(
2048 Some(self.api_provider.display_name()),
2049 status.as_u16(),
2050 &raw_error_text,
2051 );
2052 anyhow::bail!("Failed to list models: HTTP {status}: {error_text}");
2053 }
2054 let response_text = response
2055 .text()
2056 .await
2057 .context("Failed to read models response body")?;
2058
2059 parse_models_response(&response_text)
2060 .map(|models| apply_provider_model_cutline(self.api_provider, models))
2061 }
2062
2063 /// The catalog provider id for this client (the `ProviderKind` slug, falling
2064 /// back to the `ApiProvider` slug for legacy variants without a kind). This
2065 /// is the id used as the cache scope and `CatalogOffering.provider`.
2066 fn catalog_provider_id(&self) -> String {
2067 self.api_provider
2068 .kind()
2069 .map(|kind| kind.as_str().to_string())
2070 .unwrap_or_else(|| self.api_provider.as_str().to_string())
2071 }
2072
2073 /// Fetch the provider's live `/models` listing as a secret-free
2074 /// [`ProviderCatalogDelta`] (#3385).
2075 ///
2076 /// Uses the same URL construction and auth client as [`Self::list_models`],
2077 /// but issues a single request without `send_with_retry` so a refresh
2078 /// failure stays typed and non-fatal — bundled / saved / static rows are
2079 /// untouched. The delta is scoped to the base-URL fingerprint and stamped
2080 /// with the fetch time; the API key authorizes the request but is **never**
2081 /// persisted into the delta or cache. Unknown live rows carry no canonical
2082 /// model, capabilities, or pricing, per the #3385 contract.
2083 pub async fn fetch_catalog_delta(&self) -> Result<ProviderCatalogDelta, CatalogRefreshError> {
2084 let url = api_url(&self.base_url, "models");
2085 // A catalog refresh is non-fatal and must produce a *typed* outcome, so
2086 // it issues a single request and maps the raw status. This intentionally
2087 // does NOT route through `send_with_retry` like `list_models` does: that
2088 // path erases the HTTP status into a generic error and retries
2089 // non-retryable auth failures, neither of which suits a typed refresh.
2090 // Auth headers are baked into `http_client` (the key is used but never
2091 // persisted into the delta or cache).
2092 let response = self
2093 .http_client
2094 .get(&url)
2095 .send()
2096 .await
2097 .map_err(|_| CatalogRefreshError::Network)?;
2098
2099 let status = response.status();
2100 if !status.is_success() {
2101 return Err(match status.as_u16() {
2102 401 => CatalogRefreshError::Unauthorized,
2103 403 => CatalogRefreshError::Forbidden,
2104 404 => CatalogRefreshError::NotFound,
2105 429 => CatalogRefreshError::RateLimited,
2106 // Any other non-success (5xx, unexpected) is treated as a
2107 // transient transport-class failure.
2108 _ => CatalogRefreshError::Network,
2109 });
2110 }
2111
2112 let body = response
2113 .text()
2114 .await
2115 .map_err(|_| CatalogRefreshError::Network)?;
2116
2117 let provider = self.catalog_provider_id();
2118 let fingerprint = base_url_fingerprint(&self.base_url);
2119 let fetched_at = now_unix();
2120
2121 // OpenRouter returns extended capability metadata in its /models
2122 // response (#3385). Capture limits, pricing, reasoning, and modalities
2123 // from the live API instead of leaving them unknown.
2124 let offerings: Vec<CatalogOffering> = if provider == "openrouter" {
2125 let or_models = parse_openrouter_models_response(&body)?;
2126 if or_models.is_empty() {
2127 return Err(CatalogRefreshError::EmptyList);
2128 }
2129 or_models
2130 .iter()
2131 .map(|item| {
2132 openrouter_to_catalog_offering(item, &provider, &fingerprint, fetched_at)
2133 })
2134 .collect()
2135 } else if provider == "telecomjs" {
2136 telecomjs_catalog_offerings_from_body(&body, &provider, &fingerprint, fetched_at)?
2137 } else {
2138 let models = apply_provider_model_cutline(
2139 self.api_provider,
2140 parse_models_response(&body).map_err(|_| CatalogRefreshError::InvalidResponse)?,
2141 );
2142 if models.is_empty() {
2143 return Err(CatalogRefreshError::EmptyList);
2144 }
2145 models
2146 .into_iter()
2147 .map(|model| CatalogOffering {
2148 provider: provider.clone(),
2149 wire_model_id: model.id,
2150 canonical_model: None,
2151 endpoint_key: "chat".to_string(),
2152 default_for_provider: false,
2153 family: None,
2154 limit: None,
2155 cost: None,
2156 modalities: None,
2157 attachment: None,
2158 reasoning: None,
2159 tool_call: None,
2160 structured_output: None,
2161 reasoning_options: Vec::new(),
2162 source: CatalogSource::Live {
2163 base_url_fingerprint: fingerprint.clone(),
2164 fetched_at,
2165 },
2166 })
2167 .collect()
2168 };
2169
2170 Ok(ProviderCatalogDelta {
2171 provider,
2172 base_url_fingerprint: fingerprint,
2173 fetched_at,
2174 offerings,
2175 })
2176 }
2177
2178 /// Refresh `cache` for this client's provider + base URL, recording either a
2179 /// success or a typed failure (#3385). Returns the resulting status so the UI
2180 /// can surface a visible "fresh / failed(reason)" chip without inspecting the
2181 /// cache internals. A failed refresh preserves any previously cached rows.
2182 pub async fn refresh_catalog_cache(
2183 &self,
2184 cache: &mut ProviderCatalogCache,
2185 ttl_secs: u64,
2186 ) -> CatalogStatus {
2187 match self.fetch_catalog_delta().await {
2188 Ok(delta) => {
2189 cache.record_success(delta, ttl_secs);
2190 publish_provider_lake_snapshot(cache);
2191 CatalogStatus::Fresh
2192 }
2193 Err(reason) => {
2194 cache.record_failure(
2195 &self.catalog_provider_id(),
2196 &base_url_fingerprint(&self.base_url),
2197 reason,
2198 );
2199 publish_provider_lake_snapshot(cache);
2200 CatalogStatus::Failed { reason }
2201 }
2202 }
2203 }
2204
2205 /// Best-effort background refresh of the active provider's own `/v1/models`
2206 /// catalog, merging results into the provider lake (#3385).
2207 ///
2208 /// Unlike `models_dev_live::spawn_background_refresh` (which fetches the
2209 /// cross-provider Models.dev catalog), this calls the provider's own
2210 /// `/v1/models` endpoint and merges the results into the existing live
2211 /// snapshot via `provider_lake::merge_live_offerings`, preserving rows
2212 /// from other sources.
2213 ///
2214 /// Currently activated for providers whose model list is not covered by the
2215 /// Models.dev catalog (e.g. TelecomJS TokenHub). The refresh is non-fatal:
2216 /// on failure, existing/bundled rows remain available.
2217 pub fn spawn_active_provider_catalog_refresh(config: &Config) {
2218 let provider = config.api_provider();
2219 // Only refresh for providers that serve their own model list and are
2220 // not already covered by the Models.dev catalog.
2221 if !matches!(provider, ApiProvider::Telecomjs) {
2222 return;
2223 }
2224
2225 let client = match DeepSeekClient::new(config) {
2226 Ok(client) => client,
2227 Err(err) => {
2228 tracing::debug!(
2229 target: "provider_catalog",
2230 error = %err,
2231 "skipping provider catalog refresh: client creation failed"
2232 );
2233 return;
2234 }
2235 };
2236
2237 tokio::spawn(async move {
2238 match client.fetch_catalog_delta().await {
2239 Ok(delta) => {
2240 let count = delta.offerings.len();
2241 crate::provider_lake::merge_live_offerings(delta.offerings);
2242 tracing::debug!(
2243 target: "provider_catalog",
2244 offering_count = count,
2245 "provider catalog refresh merged {count} offerings into provider lake"
2246 );
2247 }
2248 Err(err) => {
2249 tracing::debug!(
2250 target: "provider_catalog",
2251 error = ?err,
2252 "provider catalog refresh failed; keeping existing rows"
2253 );
2254 }
2255 }
2256 });
2257 }
2258
2259 /// Generate speech with Xiaomi MiMo TTS models.
2260 ///
2261 /// The spoken text is placed in an `assistant` message because Xiaomi
2262 /// MiMo's TTS chat-completions surface expects that shape. The optional
2263 /// `instruction` is a `user` message that controls style, voice design, or
2264 /// voice-clone performance and is not spoken verbatim.
2265 pub async fn synthesize_speech(
2266 &self,
2267 request: SpeechSynthesisRequest,
2268 ) -> Result<SpeechSynthesisResponse> {
2269 if self.api_provider != crate::config::ApiProvider::XiaomiMimo {
2270 anyhow::bail!(
2271 "speech synthesis requires provider 'xiaomi-mimo' (current: {})",
2272 self.api_provider.as_str()
2273 );
2274 }
2275
2276 let model = request.model.trim().to_string();
2277 if model.is_empty() {
2278 anyhow::bail!("Speech model cannot be empty");
2279 }
2280 let text = request.text.trim().to_string();
2281 if text.is_empty() {
2282 anyhow::bail!("Speech text cannot be empty");
2283 }
2284
2285 let audio_format = normalize_audio_format(&request.audio_format);
2286 let model = wire_model_for_provider_route(self.api_provider, &self.base_url, &model);
2287 let model_lower = model.to_ascii_lowercase();
2288 let instruction = request
2289 .instruction
2290 .as_deref()
2291 .map(str::trim)
2292 .filter(|value| !value.is_empty());
2293 let voice = request
2294 .voice
2295 .as_deref()
2296 .map(str::trim)
2297 .filter(|value| !value.is_empty())
2298 .map(str::to_string);
2299
2300 if model_lower.contains("voicedesign") && instruction.is_none() {
2301 anyhow::bail!(
2302 "Model '{model}' requires a voice design prompt. Pass --voice-prompt or --instruction."
2303 );
2304 }
2305 if model_lower.contains("voiceclone") && voice.is_none() {
2306 anyhow::bail!(
2307 "Model '{model}' requires cloned voice data. Pass --clone-voice <mp3|wav> or --voice <data-uri>."
2308 );
2309 }
2310
2311 let mut audio = json!({
2312 "format": audio_format.clone(),
2313 });
2314 if let Some(voice) = voice.as_deref() {
2315 audio["voice"] = json!(voice);
2316 }
2317
2318 let body = build_speech_synthesis_body(&model, &text, instruction, audio);
2319
2320 let url = api_url(&self.base_url, "chat/completions");
2321 let response = self.send_json_with_retry(&url, &body).await?;
2322 let status = response.status();
2323 if !status.is_success() {
2324 let raw_error_text = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await;
2325 let error_text = sanitize_http_error_body(
2326 Some(self.api_provider.display_name()),
2327 status.as_u16(),
2328 &raw_error_text,
2329 );
2330 anyhow::bail!("Speech synthesis failed: HTTP {status}: {error_text}");
2331 }
2332
2333 let response_text = response
2334 .text()
2335 .await
2336 .context("Failed to read speech synthesis response body")?;
2337 let payload: Value = serde_json::from_str(&response_text)
2338 .context("Failed to parse speech synthesis response JSON")?;
2339 let (audio_bytes, transcript) = parse_speech_audio_response(&payload)?;
2340
2341 Ok(SpeechSynthesisResponse {
2342 model,
2343 audio_format,
2344 audio_bytes,
2345 transcript,
2346 voice,
2347 })
2348 }
2349
2350 async fn wait_for_rate_limit(&self) {
2351 let maybe_delay = {
2352 let mut limiter = self.rate_limiter.lock().await;
2353 limiter.delay_until_available(1.0)
2354 };
2355 if let Some(delay) = maybe_delay {
2356 tokio::time::sleep(delay).await;
2357 }
2358 }
2359
2360 async fn mark_request_success(&self) {
2361 let mut health = self.connection_health.lock().await;
2362 if apply_request_success(&mut health, Instant::now()) {
2363 logging::info("Connection recovered");
2364 }
2365 }
2366
2367 async fn mark_request_failure(&self, reason: &str) {
2368 let mut health = self.connection_health.lock().await;
2369 apply_request_failure(&mut health, Instant::now());
2370 logging::warn(format!(
2371 "Connection degraded (failures={}): {}",
2372 health.consecutive_failures, reason
2373 ));
2374 }
2375
2376 async fn maybe_probe_recovery(&self) {
2377 let should_probe = {
2378 let mut health = self.connection_health.lock().await;
2379 mark_recovery_probe_if_due(&mut health, Instant::now())
2380 };
2381 if !should_probe {
2382 return;
2383 }
2384 if api_provider_skips_models_probe(self.api_provider) {
2385 self.mark_request_success().await;
2386 logging::info("Skipping /models recovery probe for provider without a models endpoint");
2387 return;
2388 }
2389 let health_url = api_url(&self.base_url, "models");
2390 let probe = self.http_client.get(health_url).send().await;
2391 match probe {
2392 Ok(resp) if resp.status().is_success() => {
2393 // Consume the response body so the connection can be returned to the pool.
2394 let _ = resp.text().await;
2395 self.mark_request_success().await;
2396 logging::info("Recovery probe succeeded");
2397 }
2398 Ok(resp) => {
2399 self.mark_request_failure(&format!("probe status={}", resp.status()))
2400 .await;
2401 }
2402 Err(err) => {
2403 self.mark_request_failure(&format!("probe error={err}"))
2404 .await;
2405 }
2406 }
2407 }
2408
2409 pub(super) async fn send_with_retry<F>(&self, mut build: F) -> Result<reqwest::Response>
2410 where
2411 F: FnMut() -> reqwest::RequestBuilder,
2412 {
2413 if self.isolated_request_state {
2414 return self.send_with_isolated_retry(build).await;
2415 }
2416 let retry_cfg: LlmRetryConfig = self.retry.clone().into();
2417 let request_result = with_retry(
2418 &retry_cfg,
2419 || {
2420 let request = build();
2421 async move {
2422 // Sleep in bounded slices rather than the full remaining
2423 // window: the pause is process-global, so a concurrent
2424 // `clear_rate_limit()` (or a shortened deadline) must
2425 // release requests that are already waiting instead of
2426 // stranding them for the whole original window.
2427 while let Some(delay) = crate::retry_status::rate_limit_remaining() {
2428 tokio::time::sleep(delay.min(RATE_LIMIT_PAUSE_RECHECK_INTERVAL)).await;
2429 }
2430 self.wait_for_rate_limit().await;
2431 let response = request
2432 .send()
2433 .await
2434 .map_err(|err| LlmError::from_reqwest(&err))?;
2435 let status = response.status();
2436 if status.is_success() {
2437 return Ok(response);
2438 }
2439 let retry_after = extract_retry_after(response.headers());
2440 let body = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await;
2441 let body = sanitize_http_error_body(
2442 Some(self.api_provider.display_name()),
2443 status.as_u16(),
2444 &body,
2445 );
2446 Err(LlmError::from_http_response_with_retry_after(
2447 status.as_u16(),
2448 &body,
2449 retry_after,
2450 ))
2451 }
2452 },
2453 Some(Box::new(|err, attempt, delay| {
2454 let (reason_label, human_reason) = retry_reason_label_and_human(err);
2455 logging::warn(format!(
2456 "HTTP retry reason={} attempt={} delay={:.2}s",
2457 reason_label,
2458 attempt + 1,
2459 delay.as_secs_f64(),
2460 ));
2461 if matches!(err, LlmError::RateLimited { .. }) {
2462 crate::retry_status::note_rate_limit(delay);
2463 }
2464 crate::retry_status::start(attempt + 1, delay, human_reason);
2465 })),
2466 )
2467 .await;
2468
2469 match request_result {
2470 Ok(response) => {
2471 crate::retry_status::succeeded();
2472 self.mark_request_success().await;
2473 Ok(response)
2474 }
2475 Err(err) => {
2476 if let LlmError::RateLimited { retry_after, .. } = &err.last_error {
2477 crate::retry_status::note_rate_limit(
2478 retry_after
2479 .unwrap_or_else(|| retry_cfg.delay_for_attempt(retry_cfg.max_retries)),
2480 );
2481 }
2482 let last = err.last_error.to_string();
2483 if err.attempts > 1 {
2484 crate::retry_status::failed(last.clone());
2485 } else {
2486 crate::retry_status::clear();
2487 }
2488 self.mark_request_failure(&last).await;
2489 self.maybe_probe_recovery().await;
2490 // Keep the structured `LlmError` downcastable so failure
2491 // surfaces can classify auth/rate-limit/invalid-request
2492 // instead of reporting an opaque string (#3884).
2493 Err(anyhow::Error::new(err.last_error))
2494 }
2495 }
2496 }
2497
2498 /// The same bounded transport retry policy without process-global retry
2499 /// banners, provider-wide pause cells, or shared connection-health writes.
2500 /// Used only by the Auto classifier during read-only request inspection.
2501 async fn send_with_isolated_retry<F>(&self, mut build: F) -> Result<reqwest::Response>
2502 where
2503 F: FnMut() -> reqwest::RequestBuilder,
2504 {
2505 let retry_cfg: LlmRetryConfig = self.retry.clone().into();
2506 let request_result = with_retry(
2507 &retry_cfg,
2508 || {
2509 let request = build();
2510 async move {
2511 self.wait_for_rate_limit().await;
2512 let response = request
2513 .send()
2514 .await
2515 .map_err(|err| LlmError::from_reqwest(&err))?;
2516 let status = response.status();
2517 if status.is_success() {
2518 return Ok(response);
2519 }
2520 let retry_after = extract_retry_after(response.headers());
2521 let body = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await;
2522 let body = sanitize_http_error_body(
2523 Some(self.api_provider.display_name()),
2524 status.as_u16(),
2525 &body,
2526 );
2527 Err(LlmError::from_http_response_with_retry_after(
2528 status.as_u16(),
2529 &body,
2530 retry_after,
2531 ))
2532 }
2533 },
2534 Some(Box::new(|err, attempt, delay| {
2535 let (reason_label, _) = retry_reason_label_and_human(err);
2536 logging::warn(format!(
2537 "Isolated HTTP retry reason={} attempt={} delay={:.2}s",
2538 reason_label,
2539 attempt + 1,
2540 delay.as_secs_f64(),
2541 ));
2542 })),
2543 )
2544 .await;
2545
2546 request_result.map_err(|err| anyhow::Error::new(err.last_error))
2547 }
2548
2549 pub(super) async fn send_json_with_retry(
2550 &self,
2551 url: &str,
2552 body: &serde_json::Value,
2553 ) -> Result<reqwest::Response> {
2554 let request_body =
2555 serde_json::to_vec(body).context("Failed to serialize JSON request body")?;
2556 self.send_with_retry(|| {
2557 self.http_client
2558 .post(url)
2559 .header(CONTENT_TYPE, "application/json")
2560 .body(request_body.clone())
2561 })
2562 .await
2563 }
2564 }
2565
2566 /// Record that a request was routed to `provider` and came back with `status`.
2567 ///
2568 /// Called at every provider response site, **before** the error is built: an
2569 /// `LlmError` carries the raw provider body verbatim, so the status class has
2570 /// to be taken from the response itself.
2571 ///
2572 /// The provider is recorded as a `ProviderKind` by value. Every accessor that
2573 /// looks like the natural seam here — the persistence identity, the stream
2574 /// meta's `provider_id`, the planned route's effective label — returns the
2575 /// customer's own `[providers.<name>]` table key when the route is custom.
2576 /// `ProviderKind::Custom` yields the literal `"custom"` and nothing else, and
2577 /// no model id is sent for any provider.
2578 pub(crate) fn record_provider_response(provider: crate::config::ApiProvider, status: u16) {
2579 let counters = codewhale_telemetry::session_counters();
2580 if let Some(kind) = provider.kind() {
2581 counters.record_provider(kind);
2582 }
2583 if let Some(counter) = codewhale_telemetry::counters::http_status_counter(status) {
2584 counters.bump_error(counter);
2585 }
2586 }
2587
2588 /// Translate the structured `LlmError` into both a categorical label
2589 /// (for structured logs / metrics) and a short human reason string
2590 /// (for the retry banner). Returning both from one match avoids the
2591 /// double-classification we had before.
2592 fn retry_reason_label_and_human(err: &LlmError) -> (&'static str, String) {
2593 // The variant, never the payload. Every `LlmError` variant carries the raw
2594 // provider HTTP body verbatim, and a 400 from a content filter routinely
2595 // echoes the prompt.
2596 if matches!(err, LlmError::NetworkError(_) | LlmError::Timeout(_)) {
2597 codewhale_telemetry::session_counters()
2598 .bump_error(codewhale_telemetry::ErrorCounter::NetworkError);
2599 }
2600 match err {
2601 LlmError::RateLimited { retry_after, .. } => {
2602 let human = if let Some(after) = retry_after {
2603 format!("rate limited (Retry-After {}s)", after.as_secs())
2604 } else {
2605 "rate limited".to_string()
2606 };
2607 ("rate_limited", human)
2608 }
2609 LlmError::ServerError { status, .. } => ("server_error", format!("upstream {status}")),
2610 LlmError::NetworkError(_) => ("network_error", "network error".to_string()),
2611 LlmError::Timeout(_) => ("timeout", "timeout".to_string()),
2612 _ => ("other", "other".to_string()),
2613 }
2614 }
2615
2616 impl DeepSeekClient {
2617 /// Execute a non-streaming request without consulting or updating the
2618 /// process-global response cache.
2619 ///
2620 /// Request previews use this only for Auto's auxiliary router classifier:
2621 /// the classifier may call its configured provider, but an inspection must
2622 /// not perturb later production routing through shared cache state.
2623 pub(crate) async fn create_message_without_response_cache(
2624 &self,
2625 request: MessageRequest,
2626 ) -> Result<MessageResponse> {
2627 let mut isolated = self.clone();
2628 isolated.isolated_request_state = true;
2629 // The ordinary clone shares its provider token bucket so concurrent
2630 // production calls observe one rate budget. Request inspection is an
2631 // auxiliary classifier call, however: it must neither consume nor
2632 // inherit that mutable foreground state.
2633 isolated.rate_limiter = Arc::new(AsyncMutex::new(TokenBucket::from_env()));
2634 let _permit = isolated.acquire_provider_request_permit().await;
2635 let prepared = isolated.prepare_outbound_request(request, false)?;
2636 match prepared.dialect {
2637 WireDialect::OpenAiResponses => isolated.handle_responses_message(&prepared).await,
2638 WireDialect::AnthropicMessages => isolated.handle_anthropic_message(&prepared).await,
2639 WireDialect::ChatCompletions => isolated.create_message_chat(&prepared, false).await,
2640 }
2641 }
2642 }
2643
2644 impl LlmClient for DeepSeekClient {
2645 fn provider_name(&self) -> &'static str {
2646 self.api_provider.as_str()
2647 }
2648
2649 fn model(&self) -> &str {
2650 &self.default_model
2651 }
2652
2653 fn billing_base_url(&self) -> Option<&str> {
2654 Some(&self.base_url)
2655 }
2656
2657 fn effective_route_envelope(
2658 &self,
2659 requested_model: &str,
2660 dispatched_at: chrono::DateTime<chrono::Utc>,
2661 ) -> crate::cost_status::EffectiveRouteEnvelope {
2662 DeepSeekClient::effective_route_envelope(self, requested_model, dispatched_at)
2663 }
2664
2665 async fn health_check(&self) -> Result<bool> {
2666 if api_provider_skips_models_probe(self.api_provider) {
2667 self.mark_request_success().await;
2668 return Ok(true);
2669 }
2670 let health_url = api_url(&self.base_url, "models");
2671 self.wait_for_rate_limit().await;
2672 let response = self.http_client.get(health_url).send().await;
2673 match response {
2674 Ok(resp) if resp.status().is_success() => {
2675 // Consume the response body so the connection can be returned to the pool.
2676 let _ = resp.text().await;
2677 self.mark_request_success().await;
2678 Ok(true)
2679 }
2680 Ok(resp) => {
2681 self.mark_request_failure(&format!("health status={}", resp.status()))
2682 .await;
2683 Ok(false)
2684 }
2685 Err(err) => {
2686 self.mark_request_failure(&format!("health error={err}"))
2687 .await;
2688 Ok(false)
2689 }
2690 }
2691 }
2692
2693 async fn create_message(&self, request: MessageRequest) -> Result<MessageResponse> {
2694 let _permit = self.acquire_provider_request_permit().await;
2695 // Cacheability is a property of the caller's request, not of the wire
2696 // body, so it is read before the request is consumed by the seam.
2697 let cacheable = crate::llm_response_cache::request_is_cacheable(&request);
2698 let prepared = self.prepare_outbound_request(request, false)?;
2699 match prepared.dialect {
2700 WireDialect::OpenAiResponses => self.handle_responses_message(&prepared).await,
2701 WireDialect::AnthropicMessages => self.handle_anthropic_message(&prepared).await,
2702 WireDialect::ChatCompletions => self.create_message_chat(&prepared, cacheable).await,
2703 }
2704 }
2705
2706 async fn create_message_stream(
2707 &self,
2708 request: MessageRequest,
2709 ) -> Result<crate::llm_client::StreamEventBox> {
2710 let permit = self.acquire_provider_request_permit().await;
2711 let prepared = self.prepare_outbound_request(request, true)?;
2712 let stream = match prepared.dialect {
2713 WireDialect::OpenAiResponses => self.handle_responses_stream(&prepared).await?,
2714 WireDialect::AnthropicMessages => self.handle_anthropic_stream(&prepared).await?,
2715 WireDialect::ChatCompletions => self.handle_chat_completion_stream(prepared).await?,
2716 };
2717 Ok(Self::hold_provider_request_permit_for_stream(
2718 stream, permit,
2719 ))
2720 }
2721 }
2722
2723 #[derive(Debug, Deserialize)]
2724 struct ModelsListResponse {
2725 data: Vec<ModelListItem>,
2726 }
2727
2728 #[derive(Debug, Deserialize)]
2729 struct OpenRouterModelsResponse {
2730 data: Vec<OpenRouterModelItem>,
2731 }
2732
2733 #[derive(Debug, Deserialize)]
2734 struct ModelListItem {
2735 id: String,
2736 #[serde(default)]
2737 owned_by: Option<String>,
2738 #[serde(default)]
2739 created: Option<u64>,
2740 }
2741
2742 /// OpenRouter `/models` response item with full capability metadata (#3385).
2743 #[derive(Debug, Deserialize)]
2744 struct OpenRouterModelItem {
2745 id: String,
2746 // Captured from OpenRouter for future display/deprecation surfaces. The
2747 // current CatalogOffering shape has no honest fields for these yet.
2748 #[allow(dead_code)]
2749 #[serde(default)]
2750 name: Option<String>,
2751 #[allow(dead_code)]
2752 #[serde(default)]
2753 created: Option<u64>,
2754 #[serde(default)]
2755 context_length: Option<u32>,
2756 #[serde(default)]
2757 pricing: Option<OpenRouterPricing>,
2758 #[serde(default)]
2759 top_provider: Option<OpenRouterTopProvider>,
2760 #[serde(default)]
2761 supported_parameters: Option<Vec<String>>,
2762 #[serde(default)]
2763 architecture: Option<OpenRouterArchitecture>,
2764 #[allow(dead_code)]
2765 #[serde(default)]
2766 expiration_date: Option<String>,
2767 }
2768
2769 #[derive(Debug, Deserialize)]
2770 struct OpenRouterPricing {
2771 #[serde(default)]
2772 prompt: Option<String>,
2773 #[serde(default)]
2774 completion: Option<String>,
2775 #[serde(default)]
2776 input_cache_read: Option<String>,
2777 /// Per-token cache-write (cache-creation) price. OpenRouter publishes this
2778 /// for the upstreams that charge a write premium (Anthropic, Qwen, …);
2779 /// dropping it undercounted every cache-creation turn on those routes.
2780 #[serde(default)]
2781 input_cache_write: Option<String>,
2782 }
2783
2784 #[derive(Debug, Deserialize)]
2785 struct OpenRouterTopProvider {
2786 #[serde(default)]
2787 context_length: Option<u32>,
2788 #[serde(default)]
2789 max_completion_tokens: Option<u32>,
2790 }
2791
2792 #[derive(Debug, Deserialize)]
2793 struct OpenRouterArchitecture {
2794 #[serde(default)]
2795 modality: Option<String>,
2796 #[serde(default)]
2797 input_modalities: Option<Vec<String>>,
2798 #[serde(default)]
2799 output_modalities: Option<Vec<String>>,
2800 }
2801
2802 pub(super) fn parse_models_response(payload: &str) -> Result<Vec<AvailableModel>> {
2803 let parsed: ModelsListResponse =
2804 serde_json::from_str(payload).context("Failed to parse model list JSON")?;
2805
2806 let mut models = parsed
2807 .data
2808 .into_iter()
2809 .map(|item| AvailableModel {
2810 id: item.id,
2811 owned_by: item.owned_by,
2812 created: item.created,
2813 })
2814 .collect::<Vec<_>>();
2815 models.sort_by(|a, b| a.id.cmp(&b.id));
2816 models.dedup_by(|a, b| a.id == b.id);
2817 Ok(models)
2818 }
2819
2820 /// Apply provider-owned protocol cutlines to a live `/models` response.
2821 ///
2822 /// OpenCode Go mixes OpenAI Chat Completions and Anthropic Messages models in
2823 /// one roster. Codewhale's `OpencodeGo` route is intentionally Chat-only, so
2824 /// both `/models` consumers must share this filter before publishing choices.
2825 fn apply_provider_model_cutline(
2826 provider: ApiProvider,
2827 models: Vec<AvailableModel>,
2828 ) -> Vec<AvailableModel> {
2829 if provider != ApiProvider::OpencodeGo {
2830 return models;
2831 }
2832
2833 let mut models: Vec<_> = models
2834 .into_iter()
2835 .filter_map(|mut model| {
2836 let canonical = crate::config::opencode_go_chat_model_id(&model.id)?;
2837 model.id = canonical.to_string();
2838 Some(model)
2839 })
2840 .collect();
2841 models.sort_by(|left, right| left.id.cmp(&right.id));
2842 models.dedup_by(|left, right| left.id == right.id);
2843 models
2844 }
2845
2846 /// Convert TelecomJS's bare `/models` response into truthful provider-scoped
2847 /// catalog rows. Matching model ids on other providers prove no capabilities,
2848 /// limits, or prices; only an explicit same-provider bundled row may enrich a
2849 /// live offering.
2850 fn telecomjs_catalog_offerings_from_body(
2851 body: &str,
2852 provider: &str,
2853 fingerprint: &str,
2854 fetched_at: u64,
2855 ) -> Result<Vec<CatalogOffering>, CatalogRefreshError> {
2856 let models = parse_models_response(body).map_err(|_| CatalogRefreshError::InvalidResponse)?;
2857 if models.is_empty() {
2858 return Err(CatalogRefreshError::EmptyList);
2859 }
2860
2861 let bundled = codewhale_config::catalog::bundled_catalog_offerings();
2862 let default_model_id = codewhale_config::ProviderKind::Telecomjs
2863 .provider()
2864 .default_model();
2865 Ok(models
2866 .into_iter()
2867 .map(|model| {
2868 let is_default = model.id.eq_ignore_ascii_case(default_model_id);
2869 let same_provider_match = bundled.iter().find(|offering| {
2870 offering.provider.eq_ignore_ascii_case(provider)
2871 && offering.wire_model_id.eq_ignore_ascii_case(&model.id)
2872 });
2873 if let Some(matched) = same_provider_match {
2874 CatalogOffering {
2875 provider: provider.to_string(),
2876 wire_model_id: model.id,
2877 canonical_model: matched.canonical_model.clone(),
2878 endpoint_key: "chat".to_string(),
2879 default_for_provider: is_default,
2880 family: matched.family.clone(),
2881 limit: matched.limit.clone(),
2882 cost: matched.cost.clone(),
2883 modalities: matched.modalities.clone(),
2884 attachment: matched.attachment,
2885 reasoning: matched.reasoning,
2886 tool_call: matched.tool_call,
2887 structured_output: matched.structured_output,
2888 reasoning_options: matched.reasoning_options.clone(),
2889 source: CatalogSource::Live {
2890 base_url_fingerprint: fingerprint.to_string(),
2891 fetched_at,
2892 },
2893 }
2894 } else {
2895 CatalogOffering {
2896 provider: provider.to_string(),
2897 wire_model_id: model.id,
2898 canonical_model: None,
2899 endpoint_key: "chat".to_string(),
2900 default_for_provider: is_default,
2901 family: None,
2902 limit: None,
2903 cost: None,
2904 modalities: None,
2905 attachment: None,
2906 reasoning: None,
2907 tool_call: None,
2908 structured_output: None,
2909 reasoning_options: Vec::new(),
2910 source: CatalogSource::Live {
2911 base_url_fingerprint: fingerprint.to_string(),
2912 fetched_at,
2913 },
2914 }
2915 }
2916 })
2917 .collect())
2918 }
2919
2920 /// Parse an OpenRouter `/models` response, preserving server-side ordering and
2921 /// capturing full capability metadata (#3385).
2922 fn parse_openrouter_models_response(
2923 payload: &str,
2924 ) -> Result<Vec<OpenRouterModelItem>, CatalogRefreshError> {
2925 let parsed: OpenRouterModelsResponse =
2926 serde_json::from_str(payload).map_err(|_| CatalogRefreshError::InvalidResponse)?;
2927 let mut seen = std::collections::HashSet::new();
2928 let models: Vec<_> = parsed
2929 .data
2930 .into_iter()
2931 .filter(|item| seen.insert(item.id.clone()))
2932 .collect();
2933 Ok(models)
2934 }
2935
2936 fn publish_provider_lake_snapshot(cache: &ProviderCatalogCache) {
2937 // Publish fresh *and* stale/prior rows so pickers keep live catalog coverage
2938 // after TTL expiry or a failed refresh (#4139). An empty cache publishes
2939 // nothing: it must not erase a provider-scoped layer populated by another
2940 // refresh path.
2941 let offerings = cache.all_visible_offerings(now_unix());
2942 if !offerings.is_empty() {
2943 crate::provider_lake::set_live_snapshot(
2944 CatalogSnapshot { offerings },
2945 crate::provider_lake::LiveSource::PerProvider,
2946 );
2947 }
2948 }
2949
2950 /// Convert an OpenRouter model item into a [`CatalogOffering`] with live-sourced
2951 /// limits, pricing, reasoning, and modalities (#3385).
2952 fn openrouter_to_catalog_offering(
2953 item: &OpenRouterModelItem,
2954 provider: &str,
2955 base_url_fingerprint: &str,
2956 fetched_at: u64,
2957 ) -> CatalogOffering {
2958 use codewhale_config::models_dev::{ModelsDevCost, ModelsDevLimit, ModelsDevModalities};
2959
2960 let context_length = item
2961 .top_provider
2962 .as_ref()
2963 .and_then(|tp| tp.context_length)
2964 .or(item.context_length);
2965
2966 let max_output = item
2967 .top_provider
2968 .as_ref()
2969 .and_then(|tp| tp.max_completion_tokens);
2970
2971 let limit = if context_length.is_some() || max_output.is_some() {
2972 Some(ModelsDevLimit {
2973 context: context_length.map(u64::from),
2974 input: context_length.map(u64::from),
2975 output: max_output.map(u64::from),
2976 })
2977 } else {
2978 None
2979 };
2980
2981 let cost = item.pricing.as_ref().map(|p| {
2982 // OpenRouter quotes per-token USD strings; ModelsDevCost is per million.
2983 let parse_price = |s: &Option<String>| -> Option<f64> {
2984 s.as_ref()
2985 .and_then(|v| v.parse::<f64>().ok())
2986 .map(|price_per_token| price_per_token * 1_000_000.0)
2987 };
2988 ModelsDevCost {
2989 input: parse_price(&p.prompt),
2990 output: parse_price(&p.completion),
2991 cache_read: parse_price(&p.input_cache_read),
2992 cache_write: parse_price(&p.input_cache_write),
2993 }
2994 });
2995
2996 let reasoning = item.supported_parameters.as_ref().map(|params| {
2997 params
2998 .iter()
2999 .any(|p| p == "reasoning" || p == "include_reasoning" || p.contains("reasoning"))
3000 });
3001
3002 let tool_call = item.supported_parameters.as_ref().map(|params| {
3003 params
3004 .iter()
3005 .any(|p| p == "tools" || p == "tool_choice" || p == "functions" || p.contains("tool"))
3006 });
3007
3008 let modalities = item.architecture.as_ref().map(|arch| {
3009 let mut input = arch.input_modalities.clone().unwrap_or_default();
3010 let mut output = arch.output_modalities.clone().unwrap_or_default();
3011 if input.is_empty()
3012 && output.is_empty()
3013 && let Some((left, right)) = arch
3014 .modality
3015 .as_deref()
3016 .and_then(|value| value.split_once("->"))
3017 {
3018 input.extend(
3019 left.split('+')
3020 .map(str::trim)
3021 .filter(|value| !value.is_empty())
3022 .map(str::to_string),
3023 );
3024 output.extend(
3025 right
3026 .split('+')
3027 .map(str::trim)
3028 .filter(|value| !value.is_empty())
3029 .map(str::to_string),
3030 );
3031 }
3032 ModelsDevModalities { input, output }
3033 });
3034
3035 CatalogOffering {
3036 provider: provider.to_string(),
3037 wire_model_id: item.id.clone(),
3038 canonical_model: None,
3039 endpoint_key: "chat".to_string(),
3040 default_for_provider: false,
3041 family: None,
3042 limit,
3043 cost,
3044 modalities,
3045 attachment: None,
3046 reasoning,
3047 tool_call,
3048 structured_output: None,
3049 reasoning_options: Vec::new(),
3050 source: CatalogSource::Live {
3051 base_url_fingerprint: base_url_fingerprint.to_string(),
3052 fetched_at,
3053 },
3054 }
3055 }
3056
3057 pub(super) fn system_to_instructions(system: Option<SystemPrompt>) -> Option<String> {
3058 match system {
3059 Some(SystemPrompt::Text(text)) => Some(text),
3060 Some(SystemPrompt::Blocks(blocks)) => {
3061 let joined = blocks
3062 .into_iter()
3063 .map(|b| b.text)
3064 .collect::<Vec<_>>()
3065 .join("\n\n---\n\n");
3066 if joined.trim().is_empty() {
3067 None
3068 } else {
3069 Some(joined)
3070 }
3071 }
3072 None => None,
3073 }
3074 }
3075
3076 pub(super) fn apply_reasoning_effort(
3077 body: &mut Value,
3078 effort: Option<&str>,
3079 provider: ApiProvider,
3080 ) {
3081 let Some(effort) = effort else {
3082 return;
3083 };
3084 let normalized = effort.trim().to_ascii_lowercase();
3085 match normalized.as_str() {
3086 "off" | "disabled" | "none" | "false" => match provider {
3087 ApiProvider::Deepseek
3088 | ApiProvider::DeepseekCN
3089 | ApiProvider::Openrouter
3090 | ApiProvider::XiaomiMimo
3091 | ApiProvider::Novita
3092 | ApiProvider::Siliconflow
3093 | ApiProvider::SiliconflowCn
3094 | ApiProvider::Sglang
3095 | ApiProvider::Volcengine
3096 | ApiProvider::Deepinfra
3097 | ApiProvider::Together
3098 | ApiProvider::Atlascloud
3099 | ApiProvider::Zai => {
3100 body["thinking"] = json!({ "type": "disabled" });
3101 }
3102 // TelecomJS TokenHub: the gateway's OpenAI Chat Completions API
3103 // (POST /v1/chat/completions) does not document `reasoning_effort`
3104 // or `thinking` as supported parameters. The `thinking` field is
3105 // only available on the Anthropic Messages API (POST /v1/messages)
3106 // with a different shape ({"type":"enabled","budget_tokens":N}).
3107 // Since CodeWhale routes TelecomJS through the Chat Completions
3108 // path, we must NOT inject these fields — the gateway may silently
3109 // ignore them or reject the request, and not every gateway model
3110 // (qwen-max, deepseek-chat, gpt-4o, claude, etc.) accepts the same
3111 // reasoning dialect (#4188 review: verify against actual behavior).
3112 ApiProvider::Telecomjs => {}
3113 // Model Studio (DashScope): its top-level controls are route- AND
3114 // model-specific, so the provider enum alone cannot decide them —
3115 // a custom `base_url` on the same identity is an arbitrary
3116 // gateway. `apply_modelstudio_route_reasoning_controls` in
3117 // client::chat is the sole writer; it strips these fields for all
3118 // four variants and re-adds them only on a verified Alibaba host.
3119 // Source: <https://www.alibabacloud.com/help/en/model-studio/deep-thinking>
3120 ApiProvider::ModelstudioTokenPlan
3121 | ApiProvider::ModelstudioTokenPlanAnthropic
3122 | ApiProvider::ModelstudioCodingPlan
3123 | ApiProvider::ModelstudioCodingPlanAnthropic => {}
3124 ApiProvider::OpenaiCodex => {
3125 // OpenAI Codex uses Responses API — thinking handled differently
3126 }
3127 ApiProvider::Fireworks => {}
3128 // vLLM is an OpenAI-protocol server, not an Anthropic-protocol one.
3129 // For Qwen3 / DeepSeek-R1 / other reasoning models hosted via vLLM,
3130 // the canonical OpenAI extension to disable thinking is
3131 // `chat_template_kwargs.enable_thinking`. The old
3132 // `thinking: {type: disabled}` field is Anthropic-native and
3133 // silently ignored by vLLM — the model still emits a full
3134 // reasoning trace into the `reasoning` field (which this client
3135 // doesn't surface), causing 10+ seconds of perceived "freeze"
3136 // before the first content token (PR #1480 by @h3c-hexin).
3137 ApiProvider::Vllm => {
3138 body["chat_template_kwargs"] = json!({
3139 "enable_thinking": false,
3140 });
3141 }
3142 ApiProvider::Openai
3143 | ApiProvider::WanjieArk
3144 | ApiProvider::Qianfan
3145 | ApiProvider::Arcee
3146 | ApiProvider::Huggingface
3147 | ApiProvider::Custom => {}
3148 ApiProvider::Moonshot => {
3149 // #3024: Kimi models accept thinking enable/disable.
3150 body["thinking"] = json!({ "type": "disabled" });
3151 }
3152 ApiProvider::Ollama => {
3153 // #3024: Ollama OpenAI-compat endpoint accepts think param.
3154 body["think"] = json!(false);
3155 }
3156 ApiProvider::Anthropic
3157 | ApiProvider::DeepseekAnthropic
3158 | ApiProvider::MinimaxAnthropic
3159 | ApiProvider::Openmodel => {
3160 // Thinking shaping happens in the Messages adapter, which
3161 // applies each provider's supported control fields.
3162 }
3163 ApiProvider::NvidiaNim => {
3164 body["chat_template_kwargs"] = json!({
3165 "thinking": false,
3166 });
3167 }
3168 ApiProvider::Minimax => {}
3169 ApiProvider::Stepfun => {}
3170 ApiProvider::Sakana => {}
3171 ApiProvider::LongCat => {}
3172 ApiProvider::OpencodeGo | ApiProvider::OpencodeZen => {}
3173 ApiProvider::Meta => {}
3174 ApiProvider::Xai => {}
3175 },
3176 "low" | "minimal" | "medium" | "mid" | "high" | "" => match provider {
3177 // DeepSeek first-party Chat Completions: the wire documents
3178 // exactly three `reasoning_effort` values — `low`, `high`, `max`
3179 // (https://api-docs.deepseek.com/api/create-chat-completion) —
3180 // plus the `thinking` on/off toggle. There is no `medium` on the
3181 // wire, so the honest ladder is:
3182 // low/minimal → "low" (a real cheaper tier; it used to be
3183 // collapsed onto high, so no tier below
3184 // high existed — FINISH-0.9.4 #52)
3185 // medium/mid → "high" (nearest documented tier; the wire has
3186 // no medium and the server default in
3187 // thinking mode is also high)
3188 // high/"" → "high"
3189 ApiProvider::Deepseek | ApiProvider::DeepseekCN => {
3190 let value = match normalized.as_str() {
3191 "low" | "minimal" => "low",
3192 _ => "high",
3193 };
3194 body["reasoning_effort"] = json!(value);
3195 body["thinking"] = json!({ "type": "enabled" });
3196 }
3197 // DeepSeek-compatible hosted routes: low/medium both map to high.
3198 // Their own wire contracts are not verified here, so the historic
3199 // collapse stays rather than inventing unsupported wire values.
3200 ApiProvider::Siliconflow
3201 | ApiProvider::SiliconflowCn
3202 | ApiProvider::Sglang
3203 | ApiProvider::Volcengine
3204 | ApiProvider::Deepinfra
3205 | ApiProvider::Atlascloud => {
3206 body["reasoning_effort"] = json!("high");
3207 body["thinking"] = json!({ "type": "enabled" });
3208 }
3209 // TelecomJS: see comment in the "off" branch above — the gateway's
3210 // Chat Completions API does not support reasoning_effort or thinking.
3211 ApiProvider::Telecomjs => {}
3212 // Model Studio: see the "off" branch — the route- and model-aware
3213 // shaper in client::chat is the sole writer of these fields.
3214 ApiProvider::ModelstudioTokenPlan
3215 | ApiProvider::ModelstudioTokenPlanAnthropic
3216 | ApiProvider::ModelstudioCodingPlan
3217 | ApiProvider::ModelstudioCodingPlanAnthropic => {}
3218 // OpenRouter/Novita/Together: pass through the actual user-chosen value.
3219 // OpenRouter's unified scale is none/minimal/low/medium/high/xhigh;
3220 // DeepSeek models hosted there accept those directly.
3221 ApiProvider::Openrouter | ApiProvider::Novita | ApiProvider::Together => {
3222 let value = match normalized.as_str() {
3223 "low" | "minimal" => "low",
3224 "medium" | "mid" => "medium",
3225 _ => "high",
3226 };
3227 body["reasoning_effort"] = json!(value);
3228 body["thinking"] = json!({ "type": "enabled" });
3229 }
3230 ApiProvider::XiaomiMimo => {
3231 body["thinking"] = json!({ "type": "enabled" });
3232 }
3233 ApiProvider::Arcee | ApiProvider::Huggingface => {
3234 let value = match normalized.as_str() {
3235 "minimal" => "minimal",
3236 "low" => "low",
3237 "medium" | "mid" => "medium",
3238 _ => "high",
3239 };
3240 body["reasoning_effort"] = json!(value);
3241 }
3242 ApiProvider::Fireworks => {
3243 body["reasoning_effort"] = json!("high");
3244 }
3245 ApiProvider::Vllm => {
3246 body["chat_template_kwargs"] = json!({
3247 "enable_thinking": true,
3248 });
3249 // vLLM supports low/medium/high natively — pass through the
3250 // user-chosen value instead of hard-coding "high".
3251 let value = match normalized.as_str() {
3252 "low" | "minimal" => "low",
3253 "medium" | "mid" => "medium",
3254 _ => "high",
3255 };
3256 body["reasoning_effort"] = json!(value);
3257 }
3258 ApiProvider::Openai
3259 | ApiProvider::WanjieArk
3260 | ApiProvider::Qianfan
3261 | ApiProvider::OpenaiCodex
3262 | ApiProvider::Custom => {}
3263 ApiProvider::Moonshot => {
3264 // #3024: Kimi models accept thinking enable.
3265 body["thinking"] = json!({ "type": "enabled" });
3266 }
3267 ApiProvider::Ollama => {
3268 // #3024: Ollama think param.
3269 body["think"] = json!(true);
3270 }
3271 ApiProvider::Anthropic
3272 | ApiProvider::DeepseekAnthropic
3273 | ApiProvider::MinimaxAnthropic
3274 | ApiProvider::Openmodel => {
3275 // Thinking shaping happens in the Messages adapter, which
3276 // applies each provider's supported control fields.
3277 }
3278 ApiProvider::NvidiaNim => {
3279 body["chat_template_kwargs"] = json!({
3280 "thinking": true,
3281 "reasoning_effort": "high",
3282 });
3283 }
3284 ApiProvider::Minimax => {}
3285 ApiProvider::Zai => {
3286 body["thinking"] = json!({
3287 "type": "enabled",
3288 "clear_thinking": false,
3289 });
3290 }
3291 ApiProvider::Stepfun => {}
3292 ApiProvider::Sakana => {}
3293 ApiProvider::LongCat => {}
3294 ApiProvider::OpencodeGo | ApiProvider::OpencodeZen => {}
3295 ApiProvider::Meta => {}
3296 ApiProvider::Xai => {}
3297 },
3298 "xhigh" | "max" | "highest" | "ultracode" => match provider {
3299 ApiProvider::Deepseek
3300 | ApiProvider::DeepseekCN
3301 | ApiProvider::Siliconflow
3302 | ApiProvider::SiliconflowCn
3303 | ApiProvider::Sglang
3304 | ApiProvider::Volcengine
3305 | ApiProvider::Deepinfra
3306 | ApiProvider::Atlascloud => {
3307 body["reasoning_effort"] = json!("max");
3308 body["thinking"] = json!({ "type": "enabled" });
3309 }
3310 // TelecomJS: see comment in the "off" branch above — the gateway's
3311 // Chat Completions API does not support reasoning_effort or thinking.
3312 ApiProvider::Telecomjs => {}
3313 // Model Studio: see the "off" branch — the route- and model-aware
3314 // shaper in client::chat is the sole writer of these fields.
3315 ApiProvider::ModelstudioTokenPlan
3316 | ApiProvider::ModelstudioTokenPlanAnthropic
3317 | ApiProvider::ModelstudioCodingPlan
3318 | ApiProvider::ModelstudioCodingPlanAnthropic => {}
3319 ApiProvider::Openrouter | ApiProvider::Novita | ApiProvider::Together => {
3320 body["reasoning_effort"] = json!("xhigh");
3321 body["thinking"] = json!({ "type": "enabled" });
3322 }
3323 ApiProvider::XiaomiMimo => {
3324 body["thinking"] = json!({ "type": "enabled" });
3325 }
3326 ApiProvider::Arcee | ApiProvider::Huggingface => {
3327 body["reasoning_effort"] = json!("high");
3328 }
3329 ApiProvider::Fireworks => {
3330 body["reasoning_effort"] = json!("max");
3331 }
3332 ApiProvider::Vllm => {
3333 body["chat_template_kwargs"] = json!({
3334 "enable_thinking": true,
3335 });
3336 // vLLM only supports none/low/medium/high — downgrade
3337 // "max" to "high" instead of sending an invalid value.
3338 body["reasoning_effort"] = json!("high");
3339 }
3340 ApiProvider::Openai
3341 | ApiProvider::WanjieArk
3342 | ApiProvider::Qianfan
3343 | ApiProvider::OpenaiCodex
3344 | ApiProvider::Custom => {}
3345 ApiProvider::Moonshot => {
3346 // #3024: Kimi models accept thinking enable.
3347 body["thinking"] = json!({ "type": "enabled" });
3348 }
3349 ApiProvider::Ollama => {
3350 // #3024: Ollama think param.
3351 body["think"] = json!(true);
3352 }
3353 ApiProvider::Anthropic
3354 | ApiProvider::DeepseekAnthropic
3355 | ApiProvider::MinimaxAnthropic
3356 | ApiProvider::Openmodel => {
3357 // Thinking shaping happens in the Messages adapter, which
3358 // applies each provider's supported control fields.
3359 }
3360 ApiProvider::NvidiaNim => {
3361 body["chat_template_kwargs"] = json!({
3362 "thinking": true,
3363 "reasoning_effort": "max",
3364 });
3365 }
3366 ApiProvider::Minimax => {}
3367 ApiProvider::Zai => {
3368 body["thinking"] = json!({
3369 "type": "enabled",
3370 "clear_thinking": false,
3371 });
3372 }
3373 ApiProvider::Stepfun => {}
3374 ApiProvider::Sakana => {}
3375 ApiProvider::LongCat => {}
3376 ApiProvider::OpencodeGo | ApiProvider::OpencodeZen => {}
3377 ApiProvider::Meta => {}
3378 ApiProvider::Xai => {}
3379 },
3380 _ => {}
3381 }
3382 }
3383
3384 pub(super) fn saturating_u32(value: u64) -> u32 {
3385 u32::try_from(value).unwrap_or(u32::MAX)
3386 }
3387
3388 pub(super) fn parse_usage(usage: Option<&Value>) -> Usage {
3389 let input_tokens = usage
3390 .and_then(|u| u.get("input_tokens").or_else(|| u.get("prompt_tokens")))
3391 .and_then(Value::as_u64)
3392 .unwrap_or(0);
3393 let mut output_tokens = usage
3394 .and_then(|u| {
3395 u.get("output_tokens")
3396 .or_else(|| u.get("completion_tokens"))
3397 })
3398 .and_then(Value::as_u64)
3399 .unwrap_or(0);
3400 let total_tokens = usage
3401 .and_then(|u| u.get("total_tokens"))
3402 .and_then(Value::as_u64);
3403 let reasoning_tokens_raw = usage
3404 .and_then(|u| u.get("completion_tokens_details"))
3405 .and_then(|details| details.get("reasoning_tokens"))
3406 .and_then(Value::as_u64);
3407 if output_tokens == 0
3408 && let Some(reasoning_tokens) = reasoning_tokens_raw
3409 {
3410 output_tokens = reasoning_tokens;
3411 } else if output_tokens == 0
3412 && let Some(total_tokens) = total_tokens
3413 {
3414 output_tokens = total_tokens.saturating_sub(input_tokens);
3415 }
3416 let cached_tokens = usage
3417 .and_then(|u| u.get("prompt_tokens_details"))
3418 .and_then(|details| details.get("cached_tokens"))
3419 .and_then(Value::as_u64);
3420 let prompt_cache_hit_tokens = usage
3421 .and_then(|u| u.get("prompt_cache_hit_tokens"))
3422 .and_then(Value::as_u64)
3423 .or(cached_tokens)
3424 .map(saturating_u32);
3425 let prompt_cache_miss_tokens = usage
3426 .and_then(|u| u.get("prompt_cache_miss_tokens"))
3427 .and_then(Value::as_u64)
3428 .or_else(|| prompt_cache_hit_tokens.map(|hit| input_tokens.saturating_sub(u64::from(hit))))
3429 .map(saturating_u32);
3430 // Reasoning tokens are a *subset* of the completion count every provider
3431 // bills, so they are never added to output. A payload claiming more
3432 // reasoning than output contradicts that invariant, which makes the figure
3433 // invalid telemetry rather than extra billable output: drop it instead of
3434 // letting a bad number reach the cost surfaces (#4318).
3435 let reasoning_tokens = reasoning_tokens_raw
3436 .filter(|reasoning| *reasoning <= output_tokens)
3437 .map(saturating_u32);
3438
3439 let server_tool_use = usage.and_then(|u| u.get("server_tool_use")).map(|server| {
3440 let code_execution_requests = server
3441 .get("code_execution_requests")
3442 .and_then(Value::as_u64)
3443 .map(saturating_u32);
3444 let tool_search_requests = server
3445 .get("tool_search_requests")
3446 .and_then(Value::as_u64)
3447 .map(saturating_u32);
3448 ServerToolUsage {
3449 code_execution_requests,
3450 tool_search_requests,
3451 }
3452 });
3453
3454 Usage {
3455 input_tokens: saturating_u32(input_tokens),
3456 output_tokens: saturating_u32(output_tokens),
3457 prompt_cache_hit_tokens,
3458 prompt_cache_miss_tokens,
3459 prompt_cache_write_tokens: None,
3460 reasoning_tokens,
3461 reasoning_replay_tokens: None,
3462 server_tool_use,
3463 }
3464 }
3465
3466 impl DeepSeekClient {
3467 /// Call the DeepSeek `/beta/completions` FIM endpoint.
3468 pub async fn fim_completion(
3469 &self,
3470 model: &str,
3471 prompt: &str,
3472 suffix: &str,
3473 max_tokens: u32,
3474 ) -> anyhow::Result<String> {
3475 if self.api_provider == ApiProvider::OpencodeZen
3476 || self.wire_format != WireFormat::ChatCompletions
3477 {
3478 bail!(
3479 "FIM completion is not supported for {} because the route has no proven FIM wire contract ({:?})",
3480 self.api_provider.display_name(),
3481 self.wire_format
3482 );
3483 }
3484 let url = api_url_with_suffix(&self.base_url, "beta/completions", None);
3485 let model = wire_model_for_provider_route(self.api_provider, &self.base_url, model);
3486 let body = json!({
3487 "model": model,
3488 "prompt": prompt,
3489 "suffix": suffix,
3490 "max_tokens": max_tokens,
3491 });
3492 let response = self.send_json_with_retry(&url, &body).await?;
3493 let status = response.status();
3494 if !status.is_success() {
3495 let raw_error_text = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await;
3496 let error_text = sanitize_http_error_body(
3497 Some(self.api_provider.display_name()),
3498 status.as_u16(),
3499 &raw_error_text,
3500 );
3501 anyhow::bail!("FIM API error: HTTP {status}: {error_text}");
3502 }
3503 let response_text = response
3504 .text()
3505 .await
3506 .context("Failed to read FIM API response body")?;
3507 let value: serde_json::Value =
3508 serde_json::from_str(&response_text).context("Failed to parse FIM API response")?;
3509 let text = value
3510 .pointer("/choices/0/text")
3511 .and_then(serde_json::Value::as_str)
3512 .ok_or_else(|| anyhow::anyhow!("FIM response missing choices[0].text"))?;
3513 Ok(text.to_string())
3514 }
3515 }
3516
3517 mod anthropic;
3518 mod chat;
3519 mod prepared;
3520 mod provider_native_search;
3521 mod responses;
3522 mod stream_entry;
3523
3524 fn extract_sse_data_value(line: &str) -> Option<&str> {
3525 line.strip_prefix("data:")
3526 .map(|value| value.strip_prefix(' ').unwrap_or(value))
3527 }
3528
3529 /// Take the next COMPLETE line (up to the first `\n`) off a raw byte buffer,
3530 /// draining it, and return it trimmed. Returns `None` when no full line is
3531 /// buffered yet. Decoding only complete lines (never an arbitrary network-read
3532 /// boundary) means a multi-byte UTF-8 char — CJK, emoji, accented letter —
3533 /// split across two reads is never corrupted to U+FFFD, since the `\n`
3534 /// delimiter is ASCII and can never fall inside a multi-byte sequence.
3535 fn take_sse_line(buffer: &mut Vec<u8>) -> Option<String> {
3536 let line_end = buffer.iter().position(|&b| b == b'\n')?;
3537 let line = String::from_utf8_lossy(&buffer[..line_end])
3538 .trim()
3539 .to_string();
3540 buffer.drain(..=line_end);
3541 Some(line)
3542 }
3543
3544 pub(crate) use chat::{CacheWarmupKey, PromptInspection};
3545 pub(crate) use prepared::{
3546 CallerStreamMode, EndpointIdentity, PreparedOutboundRequest, RouteShape, WireBodyView,
3547 WireDialect, canonical_json,
3548 };
3549 pub(crate) use provider_native_search::{ProviderNativeSearchClient, ProviderNativeSearchRequest};
3550
3551 pub(crate) fn inspect_prompt_for_request(request: &MessageRequest) -> PromptInspection {
3552 chat::inspect_prompt_for_request(request)
3553 }
3554
3555 pub(crate) fn build_cache_warmup_request(request: &MessageRequest) -> MessageRequest {
3556 chat::build_cache_warmup_request(request)
3557 }
3558
3559 #[cfg(test)]
3560 mod tests {
3561 use super::*;
3562 use crate::client::chat::{
3563 build_chat_messages, build_chat_messages_for_request,
3564 build_chat_messages_for_request_and_provider, count_reasoning_replay_chars,
3565 parse_chat_message, parse_sse_chunk, sanitize_thinking_mode_messages, tool_to_chat,
3566 tool_to_chat_for_base_url,
3567 };
3568 use crate::client::responses::build_responses_body;
3569 use crate::config::{DEFAULT_TELECOMJS_MODEL, ProviderConfig, ProvidersConfig};
3570 use crate::models::{
3571 ContentBlock, ContentBlockStart, Delta, Message, MessageRequest, MessageResponse,
3572 StreamEvent, Tool,
3573 };
3574 use crate::tools::apply_patch::ApplyPatchTool;
3575 use crate::tools::spec::ToolSpec;
3576 use crate::tools::{ToolContext, ToolRegistryBuilder};
3577 use codewhale_protocol::runtime::DynamicToolSpec;
3578 use serde_json::json;
3579 use wiremock::matchers::{header, method, path};
3580 use wiremock::{Mock, MockServer, ResponseTemplate};
3581
3582 #[test]
3583 fn openrouter_pricing_maps_cache_write_per_token_to_per_million() {
3584 let payload = r#"{"data":[{
3585 "id":"anthropic/claude-sonnet-4-6",
3586 "pricing":{
3587 "prompt":"0.000003",
3588 "completion":"0.000015",
3589 "input_cache_read":"0.0000003",
3590 "input_cache_write":"0.00000375"
3591 }
3592 },{
3593 "id":"some/no-write-row",
3594 "pricing":{"prompt":"0.000001","completion":"0.000002"}
3595 }]}"#;
3596
3597 let items = parse_openrouter_models_response(payload).expect("parses");
3598 let priced = openrouter_to_catalog_offering(&items[0], "openrouter", "fp", 42);
3599 let cost = priced.cost.as_ref().expect("pricing row");
3600 assert_eq!(cost.input, Some(3.0));
3601 assert_eq!(cost.output, Some(15.0));
3602 assert_eq!(cost.cache_read, Some(0.3));
3603 assert_eq!(cost.cache_write, Some(3.75));
3604
3605 // A cache-write premium must actually reach the estimator: the same
3606 // tokens cost more when they are cache-creation rather than cache-read.
3607 let pricing = codewhale_config::pricing::OfferingPricing::from_catalog_offering(&priced)
3608 .expect("priced offering");
3609 let write = codewhale_config::pricing::TokenUsage {
3610 cache_write: 1_000_000,
3611 ..Default::default()
3612 };
3613 assert_eq!(pricing.estimate_cost(&write), Some(3.75));
3614 assert!(pricing.unpriced_used_classes(&write).is_empty());
3615
3616 // A row without a published write rate stays unknown, not zero, and
3617 // fails closed for cache-creation turns.
3618 let unwritten = openrouter_to_catalog_offering(&items[1], "openrouter", "fp", 42);
3619 assert_eq!(
3620 unwritten.cost.as_ref().and_then(|cost| cost.cache_write),
3621 None
3622 );
3623 let unwritten =
3624 codewhale_config::pricing::OfferingPricing::from_catalog_offering(&unwritten)
3625 .expect("priced offering");
3626 assert_eq!(unwritten.estimate_cost(&write), None);
3627 assert_eq!(
3628 unwritten.unpriced_used_classes(&write),
3629 vec![codewhale_config::pricing::TokenClass::CacheWrite]
3630 );
3631 }
3632
3633 fn test_tool(name: &str) -> Tool {
3634 Tool {
3635 tool_type: None,
3636 name: name.to_string(),
3637 description: format!("{name} test tool"),
3638 input_schema: json!({
3639 "type": "object",
3640 "properties": {},
3641 }),
3642 allowed_callers: None,
3643 defer_loading: Some(false),
3644 input_examples: None,
3645 strict: Some(true),
3646 cache_control: None,
3647 }
3648 }
3649
3650 fn apply_patch_request_tool() -> Tool {
3651 let spec = ApplyPatchTool;
3652 Tool {
3653 tool_type: None,
3654 name: spec.name().to_string(),
3655 description: spec.description().to_string(),
3656 input_schema: spec.input_schema(),
3657 allowed_callers: None,
3658 defer_loading: Some(false),
3659 input_examples: None,
3660 strict: None,
3661 cache_control: None,
3662 }
3663 }
3664
3665 fn deferred_dynamic_request_tool() -> Tool {
3666 let registry = ToolRegistryBuilder::new()
3667 .with_dynamic_tools(&[DynamicToolSpec {
3668 namespace: Some("capture".to_string()),
3669 name: "deferred_lookup".to_string(),
3670 description: "Look up a record after deferred loading".to_string(),
3671 input_schema: json!({
3672 "type": "object",
3673 "properties": {
3674 "mode": {"type": "string", "const": "fast"},
3675 "query": {
3676 "anyOf": [
3677 {"type": "string"},
3678 {"type": "null"}
3679 ]
3680 }
3681 },
3682 "required": ["mode"]
3683 }),
3684 defer_loading: true,
3685 }])
3686 .build(ToolContext::new(
3687 std::env::temp_dir().join("codewhale-k3-deferred-capture"),
3688 ));
3689 registry
3690 .to_api_tools()
3691 .into_iter()
3692 .find(|tool| tool.name == "deferred_lookup")
3693 .expect("dynamic tool remains model-visible")
3694 }
3695
3696 fn value_contains_key(value: &Value, needle: &str) -> bool {
3697 match value {
3698 Value::Object(object) => {
3699 object.contains_key(needle)
3700 || object
3701 .values()
3702 .any(|child| value_contains_key(child, needle))
3703 }
3704 Value::Array(values) => values.iter().any(|child| value_contains_key(child, needle)),
3705 _ => false,
3706 }
3707 }
3708
3709 fn captured_function<'a>(body: &'a Value, name: &str) -> &'a Value {
3710 body["tools"]
3711 .as_array()
3712 .and_then(|tools| tools.iter().find(|tool| tool["function"]["name"] == name))
3713 .map(|tool| &tool["function"])
3714 .unwrap_or_else(|| panic!("captured tool catalog is missing {name}: {body}"))
3715 }
3716
3717 fn moonshot_request_boundary_client(
3718 route_base_url: &str,
3719 model: &str,
3720 transport_base_url: String,
3721 ) -> DeepSeekClient {
3722 let mut client = DeepSeekClient::new(&Config {
3723 provider: Some("moonshot".to_string()),
3724 providers: Some(ProvidersConfig {
3725 moonshot: ProviderConfig {
3726 api_key: Some("moonshot-request-boundary-key".to_string()),
3727 base_url: Some(route_base_url.to_string()),
3728 model: Some(model.to_string()),
3729 ..ProviderConfig::default()
3730 },
3731 ..ProvidersConfig::default()
3732 }),
3733 ..Config::default()
3734 })
3735 .expect("Moonshot request-boundary client");
3736 assert_eq!(client.base_url, route_base_url);
3737 client.test_chat_transport_base_url = Some(transport_base_url);
3738 client
3739 }
3740
3741 fn zai_request_boundary_client(
3742 route_base_url: &str,
3743 model: &str,
3744 transport_base_url: String,
3745 ) -> DeepSeekClient {
3746 let _ = rustls::crypto::ring::default_provider().install_default();
3747 let mut client = DeepSeekClient::new(&Config {
3748 provider: Some("zai".to_string()),
3749 providers: Some(ProvidersConfig {
3750 zai: ProviderConfig {
3751 api_key: Some("zai-request-boundary-key".to_string()),
3752 base_url: Some(route_base_url.to_string()),
3753 model: Some(model.to_string()),
3754 ..ProviderConfig::default()
3755 },
3756 ..ProvidersConfig::default()
3757 }),
3758 ..Config::default()
3759 })
3760 .expect("Z.ai request-boundary client");
3761 assert_eq!(client.base_url, route_base_url);
3762 client.test_chat_transport_base_url = Some(transport_base_url);
3763 client
3764 }
3765
3766 fn minimax_request_boundary_client(
3767 route_base_url: &str,
3768 model: &str,
3769 transport_base_url: String,
3770 ) -> DeepSeekClient {
3771 let _ = rustls::crypto::ring::default_provider().install_default();
3772 let mut client = DeepSeekClient::new(&Config {
3773 provider: Some("minimax".to_string()),
3774 providers: Some(ProvidersConfig {
3775 minimax: ProviderConfig {
3776 api_key: Some("minimax-request-boundary-key".to_string()),
3777 base_url: Some(route_base_url.to_string()),
3778 model: Some(model.to_string()),
3779 ..ProviderConfig::default()
3780 },
3781 ..ProvidersConfig::default()
3782 }),
3783 ..Config::default()
3784 })
3785 .expect("MiniMax request-boundary client");
3786 assert_eq!(client.base_url, route_base_url);
3787 client.test_chat_transport_base_url = Some(transport_base_url);
3788 client
3789 }
3790
3791 fn deepseek_request_boundary_client(
3792 route_base_url: &str,
3793 transport_base_url: String,
3794 ) -> DeepSeekClient {
3795 let mut client = DeepSeekClient::new(&Config {
3796 provider: Some("deepseek".to_string()),
3797 api_key: Some("deepseek-request-boundary-key".to_string()),
3798 base_url: Some(route_base_url.to_string()),
3799 default_text_model: Some("deepseek-v4-pro".to_string()),
3800 ..Config::default()
3801 })
3802 .expect("DeepSeek request-boundary client");
3803 client.test_chat_transport_base_url = Some(transport_base_url);
3804 client
3805 }
3806
3807 async fn capture_deepseek_chat_request(
3808 route_base_url: &str,
3809 strict: bool,
3810 streaming: bool,
3811 ) -> (String, Value) {
3812 let server = MockServer::start().await;
3813 let response = if streaming {
3814 ResponseTemplate::new(200)
3815 .insert_header("content-type", "text/event-stream")
3816 .set_body_string("data: [DONE]\n\n")
3817 } else {
3818 ResponseTemplate::new(200).set_body_json(json!({
3819 "id": "chatcmpl-deepseek-request-boundary",
3820 "object": "chat.completion",
3821 "model": "deepseek-v4-pro",
3822 "choices": [{
3823 "index": 0,
3824 "message": {"role": "assistant", "content": "ok"},
3825 "finish_reason": "stop"
3826 }],
3827 "usage": {
3828 "prompt_tokens": 1,
3829 "completion_tokens": 1,
3830 "total_tokens": 2
3831 }
3832 }))
3833 };
3834 Mock::given(method("POST"))
3835 .respond_with(response)
3836 .expect(1)
3837 .mount(&server)
3838 .await;
3839
3840 let mut tool = test_tool("lookup");
3841 if !strict {
3842 tool.strict = None;
3843 }
3844 let request = MessageRequest {
3845 model: "deepseek-v4-pro".to_string(),
3846 messages: vec![Message {
3847 role: "user".to_string(),
3848 content: vec![ContentBlock::Text {
3849 text: "provider-free DeepSeek route fixture".to_string(),
3850 cache_control: None,
3851 }],
3852 }],
3853 max_tokens: 64,
3854 system: None,
3855 tools: Some(vec![tool]),
3856 tool_choice: Some(json!(if strict { "required" } else { "auto" })),
3857 metadata: None,
3858 thinking: None,
3859 reasoning_effort: Some("off".to_string()),
3860 stream: Some(streaming),
3861 temperature: None,
3862 top_p: None,
3863 };
3864 let client = deepseek_request_boundary_client(route_base_url, server.uri());
3865
3866 if streaming {
3867 let mut stream = client
3868 .create_message_stream(request)
3869 .await
3870 .expect("streaming request succeeds");
3871 while let Some(event) = stream.next().await {
3872 event.expect("captured SSE response remains valid");
3873 }
3874 } else {
3875 client
3876 .create_message(request)
3877 .await
3878 .expect("non-streaming request succeeds");
3879 }
3880
3881 let requests = server.received_requests().await.expect("recorded request");
3882 assert_eq!(requests.len(), 1);
3883 let path = requests[0].url.path().to_string();
3884 let body = serde_json::from_slice(&requests[0].body).expect("captured request JSON");
3885 (path, body)
3886 }
3887
3888 // This synchronous guard deliberately spans every await: the assertions
3889 // require exclusive access to process-global retry state for the full call.
3890 #[allow(clippy::await_holding_lock)]
3891 #[tokio::test(flavor = "current_thread")]
3892 async fn cache_free_message_call_neither_reads_nor_writes_global_cache() {
3893 let _retry_guard = crate::retry_status::test_guard();
3894 crate::retry_status::clear();
3895 crate::retry_status::clear_rate_limit();
3896 crate::retry_status::start(7, Duration::from_secs(60), "foreground sentinel");
3897 crate::retry_status::note_rate_limit(Duration::from_secs(60));
3898 let server = MockServer::start().await;
3899 Mock::given(method("POST"))
3900 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3901 "id": "chatcmpl-cache-free-provider",
3902 "object": "chat.completion",
3903 "model": "deepseek-v4-pro",
3904 "choices": [{
3905 "index": 0,
3906 "message": {"role": "assistant", "content": "provider result"},
3907 "finish_reason": "stop"
3908 }],
3909 "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
3910 })))
3911 .expect(1)
3912 .mount(&server)
3913 .await;
3914
3915 let client = deepseek_request_boundary_client("https://api.deepseek.com/v1", server.uri());
3916 let request = MessageRequest {
3917 model: "deepseek-v4-pro".to_string(),
3918 messages: vec![Message {
3919 role: "user".to_string(),
3920 content: vec![ContentBlock::Text {
3921 text: "preview-router-cache-isolation-regression".to_string(),
3922 cache_control: None,
3923 }],
3924 }],
3925 max_tokens: 128,
3926 system: None,
3927 tools: None,
3928 tool_choice: None,
3929 metadata: None,
3930 thinking: None,
3931 reasoning_effort: Some("off".to_string()),
3932 stream: Some(false),
3933 temperature: Some(0.0),
3934 top_p: None,
3935 };
3936 let prepared = client
3937 .prepare_outbound_request(request.clone(), false)
3938 .expect("request prepares");
3939 let wire_body = serde_json::to_vec(&prepared.body).expect("wire body serializes");
3940 let cache_key = crate::llm_response_cache::ResponseCache::make_key(
3941 client.api_provider.as_str(),
3942 &client.base_url,
3943 client.path_suffix.as_deref(),
3944 &client.api_key,
3945 &wire_body,
3946 );
3947 crate::llm_response_cache::response_cache().put(
3948 cache_key,
3949 MessageResponse {
3950 id: "cached-sentinel-must-survive".to_string(),
3951 r#type: "message".to_string(),
3952 role: "assistant".to_string(),
3953 content: Vec::new(),
3954 model: "deepseek-v4-pro".to_string(),
3955 stop_reason: Some("end_turn".to_string()),
3956 stop_sequence: None,
3957 container: None,
3958 usage: Default::default(),
3959 },
3960 );
3961
3962 let response = client
3963 .create_message_without_response_cache(request)
3964 .await
3965 .expect("cache-free provider call succeeds");
3966 assert_eq!(response.id, "chatcmpl-cache-free-provider");
3967 assert_eq!(
3968 crate::llm_response_cache::response_cache()
3969 .get(&cache_key)
3970 .expect("sentinel remains")
3971 .id,
3972 "cached-sentinel-must-survive"
3973 );
3974 match crate::retry_status::snapshot() {
3975 crate::retry_status::RetryState::Active(banner) => {
3976 assert_eq!(banner.attempt, 7);
3977 assert_eq!(banner.reason, "foreground sentinel");
3978 }
3979 state => panic!("isolated success mutated retry state: {state:?}"),
3980 }
3981 assert!(
3982 crate::retry_status::rate_limit_remaining().is_some(),
3983 "isolated success must not clear the foreground provider pause"
3984 );
3985 crate::retry_status::clear();
3986 crate::retry_status::clear_rate_limit();
3987 }
3988
3989 // This synchronous guard deliberately spans every await: the assertions
3990 // require exclusive access to process-global retry state for the full call.
3991 #[allow(clippy::await_holding_lock)]
3992 #[tokio::test(flavor = "current_thread")]
3993 async fn cache_free_classifier_429_does_not_publish_global_retry_or_rate_limit_state() {
3994 let _retry_guard = crate::retry_status::test_guard();
3995 crate::retry_status::clear();
3996 crate::retry_status::clear_rate_limit();
3997 crate::retry_status::start(9, Duration::from_secs(60), "foreground sentinel 429");
3998 crate::retry_status::note_rate_limit(Duration::from_secs(60));
3999
4000 let server = MockServer::start().await;
4001 Mock::given(method("POST"))
4002 .respond_with(
4003 ResponseTemplate::new(429)
4004 .insert_header("retry-after", "120")
4005 .set_body_string("rate limited"),
4006 )
4007 .expect(1)
4008 .mount(&server)
4009 .await;
4010 let mut client =
4011 deepseek_request_boundary_client("https://api.deepseek.com/v1", server.uri());
4012 client.retry.enabled = false;
4013 client.retry.max_retries = 0;
4014 let request = MessageRequest {
4015 model: "deepseek-v4-pro".to_string(),
4016 messages: vec![Message {
4017 role: "user".to_string(),
4018 content: vec![ContentBlock::Text {
4019 text: "preview-router-429-isolation-regression".to_string(),
4020 cache_control: None,
4021 }],
4022 }],
4023 max_tokens: 64,
4024 system: None,
4025 tools: None,
4026 tool_choice: None,
4027 metadata: None,
4028 thinking: None,
4029 reasoning_effort: Some("off".to_string()),
4030 stream: Some(false),
4031 temperature: Some(0.0),
4032 top_p: None,
4033 };
4034 let error = client
4035 .create_message_without_response_cache(request)
4036 .await
4037 .expect_err("429 must fail when isolated retries are disabled");
4038 assert!(
4039 matches!(
4040 error.downcast_ref::<LlmError>(),
4041 Some(LlmError::RateLimited { .. })
4042 ),
4043 "{error:#}"
4044 );
4045 match crate::retry_status::snapshot() {
4046 crate::retry_status::RetryState::Active(banner) => {
4047 assert_eq!(banner.attempt, 9);
4048 assert_eq!(banner.reason, "foreground sentinel 429");
4049 }
4050 state => panic!("isolated 429 mutated retry state: {state:?}"),
4051 }
4052 let remaining =
4053 crate::retry_status::rate_limit_remaining().expect("foreground provider pause remains");
4054 assert!(
4055 remaining < Duration::from_secs(70),
4056 "classifier Retry-After must not extend the global pause: {remaining:?}"
4057 );
4058 crate::retry_status::clear();
4059 crate::retry_status::clear_rate_limit();
4060 }
4061
4062 async fn assert_deepseek_strict_request_route_boundary(streaming: bool) {
4063 for (route_base_url, strict, expected_path, expected_wire_strict) in [
4064 (
4065 "https://api.deepseek.com/beta",
4066 false,
4067 "/v1/chat/completions",
4068 None,
4069 ),
4070 (
4071 "https://api.deepseek.com/beta",
4072 true,
4073 "/beta/chat/completions",
4074 Some(true),
4075 ),
4076 (
4077 "https://api.deepseek.com/v1",
4078 true,
4079 "/v1/chat/completions",
4080 None,
4081 ),
4082 ] {
4083 let (captured_path, body) =
4084 capture_deepseek_chat_request(route_base_url, strict, streaming).await;
4085 assert_eq!(captured_path, expected_path, "{route_base_url} {body}");
4086 assert_eq!(
4087 body.pointer("/tools/0/function/strict")
4088 .and_then(Value::as_bool),
4089 expected_wire_strict,
4090 "{route_base_url} {body}"
4091 );
4092 }
4093 }
4094
4095 fn k3_request_fixture(model: &str, effort: Option<&str>, stream: bool) -> MessageRequest {
4096 MessageRequest {
4097 model: model.to_string(),
4098 messages: vec![Message {
4099 role: "user".to_string(),
4100 content: vec![ContentBlock::Text {
4101 text: "request-boundary fixture".to_string(),
4102 cache_control: None,
4103 }],
4104 }],
4105 max_tokens: 64,
4106 system: None,
4107 tools: None,
4108 tool_choice: None,
4109 metadata: None,
4110 thinking: None,
4111 reasoning_effort: effort.map(str::to_string),
4112 stream: Some(stream),
4113 temperature: Some(0.25),
4114 top_p: Some(0.75),
4115 }
4116 }
4117
4118 async fn capture_moonshot_chat_request(
4119 route_base_url: &str,
4120 model: &str,
4121 effort: Option<&str>,
4122 streaming: bool,
4123 ) -> Value {
4124 let request = k3_request_fixture(model, effort, streaming);
4125 capture_moonshot_chat_request_body(route_base_url, model, request).await
4126 }
4127
4128 async fn capture_moonshot_chat_request_body(
4129 route_base_url: &str,
4130 model: &str,
4131 request: MessageRequest,
4132 ) -> Value {
4133 let streaming = request.stream == Some(true);
4134 let server = MockServer::start().await;
4135 let response = if streaming {
4136 ResponseTemplate::new(200)
4137 .insert_header("content-type", "text/event-stream")
4138 .set_body_string("data: [DONE]\n\n")
4139 } else {
4140 ResponseTemplate::new(200).set_body_json(json!({
4141 "id": "chatcmpl-k3-request-boundary",
4142 "object": "chat.completion",
4143 "model": model,
4144 "choices": [{
4145 "index": 0,
4146 "message": {"role": "assistant", "content": "ok"},
4147 "finish_reason": "stop"
4148 }],
4149 "usage": {
4150 "prompt_tokens": 1,
4151 "completion_tokens": 1,
4152 "total_tokens": 2
4153 }
4154 }))
4155 };
4156 Mock::given(method("POST"))
4157 .and(path("/v1/chat/completions"))
4158 .respond_with(response)
4159 .expect(1)
4160 .mount(&server)
4161 .await;
4162
4163 let client = moonshot_request_boundary_client(route_base_url, model, server.uri());
4164
4165 if streaming {
4166 let mut stream = client
4167 .create_message_stream(request)
4168 .await
4169 .expect("streaming request succeeds");
4170 while let Some(event) = stream.next().await {
4171 event.expect("captured SSE response remains valid");
4172 }
4173 } else {
4174 client
4175 .create_message(request)
4176 .await
4177 .expect("non-streaming request succeeds");
4178 }
4179
4180 let requests = server.received_requests().await.expect("recorded request");
4181 assert_eq!(requests.len(), 1);
4182 serde_json::from_slice(&requests[0].body).expect("captured request JSON")
4183 }
4184
4185 async fn capture_route_chat_request_body(
4186 model: &str,
4187 request: MessageRequest,
4188 client_for_transport: impl FnOnce(String) -> DeepSeekClient,
4189 ) -> (String, Value) {
4190 let streaming = request.stream == Some(true);
4191 let server = MockServer::start().await;
4192 let response = if streaming {
4193 ResponseTemplate::new(200)
4194 .insert_header("content-type", "text/event-stream")
4195 .set_body_string("data: [DONE]\n\n")
4196 } else {
4197 ResponseTemplate::new(200).set_body_json(json!({
4198 "id": "chatcmpl-provider-request-boundary",
4199 "object": "chat.completion",
4200 "model": model,
4201 "choices": [{
4202 "index": 0,
4203 "message": {"role": "assistant", "content": "ok"},
4204 "finish_reason": "stop"
4205 }],
4206 "usage": {
4207 "prompt_tokens": 1,
4208 "completion_tokens": 1,
4209 "total_tokens": 2
4210 }
4211 }))
4212 };
4213 Mock::given(method("POST"))
4214 .and(path("/v1/chat/completions"))
4215 .respond_with(response)
4216 .expect(1)
4217 .mount(&server)
4218 .await;
4219
4220 let client = client_for_transport(server.uri());
4221 if streaming {
4222 let mut stream = client
4223 .create_message_stream(request)
4224 .await
4225 .expect("streaming request succeeds");
4226 while let Some(event) = stream.next().await {
4227 event.expect("captured SSE response remains valid");
4228 }
4229 } else {
4230 client
4231 .create_message(request)
4232 .await
4233 .expect("non-streaming request succeeds");
4234 }
4235
4236 let requests = server.received_requests().await.expect("recorded request");
4237 assert_eq!(requests.len(), 1);
4238 (
4239 requests[0].url.path().to_string(),
4240 serde_json::from_slice(&requests[0].body).expect("captured request JSON"),
4241 )
4242 }
4243
4244 async fn capture_zai_chat_request(
4245 route_base_url: &str,
4246 model: &str,
4247 effort: Option<&str>,
4248 streaming: bool,
4249 ) -> (String, Value) {
4250 capture_route_chat_request_body(
4251 model,
4252 k3_request_fixture(model, effort, streaming),
4253 |uri| zai_request_boundary_client(route_base_url, model, uri),
4254 )
4255 .await
4256 }
4257
4258 async fn capture_minimax_chat_request(
4259 route_base_url: &str,
4260 model: &str,
4261 effort: Option<&str>,
4262 streaming: bool,
4263 ) -> (String, Value) {
4264 capture_route_chat_request_body(
4265 model,
4266 k3_request_fixture(model, effort, streaming),
4267 |uri| minimax_request_boundary_client(route_base_url, model, uri),
4268 )
4269 .await
4270 }
4271
4272 fn modelstudio_request_boundary_client(
4273 route_base_url: &str,
4274 model: &str,
4275 transport_base_url: String,
4276 ) -> DeepSeekClient {
4277 let _ = rustls::crypto::ring::default_provider().install_default();
4278 let mut client = DeepSeekClient::new(&Config {
4279 provider: Some("modelstudio-token-plan".to_string()),
4280 providers: Some(ProvidersConfig {
4281 modelstudio_token_plan: ProviderConfig {
4282 api_key: Some("modelstudio-request-boundary-key".to_string()),
4283 base_url: Some(route_base_url.to_string()),
4284 model: Some(model.to_string()),
4285 ..ProviderConfig::default()
4286 },
4287 ..ProvidersConfig::default()
4288 }),
4289 ..Config::default()
4290 })
4291 .expect("Model Studio request-boundary client");
4292 assert_eq!(client.base_url, route_base_url);
4293 client.test_chat_transport_base_url = Some(transport_base_url);
4294 client
4295 }
4296
4297 async fn capture_modelstudio_chat_request(
4298 route_base_url: &str,
4299 model: &str,
4300 effort: Option<&str>,
4301 streaming: bool,
4302 ) -> (String, Value) {
4303 capture_route_chat_request_body(
4304 model,
4305 k3_request_fixture(model, effort, streaming),
4306 |uri| modelstudio_request_boundary_client(route_base_url, model, uri),
4307 )
4308 .await
4309 }
4310
4311 async fn assert_modelstudio_request_truth(streaming: bool) {
4312 // Token Plan and Coding Plan share DashScope's reasoning controls on
4313 // their OpenAI-compatible Chat Completions endpoints — but the fields
4314 // are model-specific, not provider-wide.
4315 for base_url in [
4316 crate::config::DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL,
4317 crate::config::DEFAULT_MODELSTUDIO_CODING_PLAN_BASE_URL,
4318 ] {
4319 // The default model, qwen3.8-max, is thinking-only: the bundled
4320 // catalog records it as `thinking: always_on`, and
4321 // qwen3.8-max-preview has effort/budget options with no toggle.
4322 // Neither accepts an enable/disable switch, so CodeWhale must not
4323 // send one — not even `false` for an explicit `off`. This assertion
4324 // used to pin the opposite; PR #5233 caught it.
4325 for effort in [None, Some("off"), Some("high"), Some("max")] {
4326 let (path, body) = capture_modelstudio_chat_request(
4327 base_url,
4328 crate::config::DEFAULT_MODELSTUDIO_TOKEN_PLAN_MODEL,
4329 effort,
4330 streaming,
4331 )
4332 .await;
4333 assert_eq!(path, "/v1/chat/completions");
4334 assert!(
4335 body.get("enable_thinking").is_none(),
4336 "{base_url} {effort:?}: {body}"
4337 );
4338 assert!(
4339 body.get("thinking").is_none(),
4340 "{base_url} {effort:?}: {body}"
4341 );
4342 assert!(
4343 body.get("reasoning_effort").is_none(),
4344 "{base_url} {effort:?}: {body}"
4345 );
4346 }
4347
4348 // A hybrid model does get the documented switch, plus
4349 // `preserve_thinking` so the next turn keeps its trace.
4350 for (effort, enabled) in [(None, true), (Some("high"), true), (Some("off"), false)] {
4351 let (_, body) =
4352 capture_modelstudio_chat_request(base_url, "qwen3.7-plus", effort, streaming)
4353 .await;
4354 assert_eq!(
4355 body["enable_thinking"],
4356 json!(enabled),
4357 "{base_url} {effort:?}: {body}"
4358 );
4359 assert_eq!(
4360 body["preserve_thinking"],
4361 json!(enabled),
4362 "{base_url} {effort:?}: {body}"
4363 );
4364 // The hybrid Qwen families have no effort ladder on the wire.
4365 assert!(
4366 body.get("reasoning_effort").is_none(),
4367 "{base_url} {effort:?}: {body}"
4368 );
4369 }
4370
4371 // DeepSeek-V4 is one of the two families with a documented effort
4372 // ladder (`high` / `max`).
4373 let (_, deepseek) = capture_modelstudio_chat_request(
4374 base_url,
4375 "deepseek-v4-pro",
4376 Some("xhigh"),
4377 streaming,
4378 )
4379 .await;
4380 assert_eq!(
4381 deepseek["enable_thinking"],
4382 json!(true),
4383 "{base_url}: {deepseek}"
4384 );
4385 assert_eq!(
4386 deepseek["reasoning_effort"],
4387 json!("max"),
4388 "{base_url}: {deepseek}"
4389 );
4390 }
4391
4392 // Fail closed: the same provider identity pointed at a custom gateway
4393 // must not be handed Alibaba's dialect.
4394 let (_, proxied) = capture_modelstudio_chat_request(
4395 "https://proxy.example/v1",
4396 "qwen3.7-plus",
4397 Some("high"),
4398 streaming,
4399 )
4400 .await;
4401 assert!(proxied.get("enable_thinking").is_none(), "{proxied}");
4402 assert!(proxied.get("preserve_thinking").is_none(), "{proxied}");
4403 assert!(proxied.get("reasoning_effort").is_none(), "{proxied}");
4404 }
4405
4406 async fn assert_zai_request_truth(streaming: bool) {
4407 for base_url in [
4408 crate::config::DEFAULT_ZAI_BASE_URL,
4409 "https://api.z.ai/api/paas/v4",
4410 ] {
4411 let (high_path, high) = capture_zai_chat_request(
4412 base_url,
4413 crate::config::ZAI_GLM_5_2_MODEL,
4414 Some("high"),
4415 streaming,
4416 )
4417 .await;
4418 let (max_path, max) = capture_zai_chat_request(
4419 base_url,
4420 crate::config::ZAI_GLM_5_2_MODEL,
4421 Some("max"),
4422 streaming,
4423 )
4424 .await;
4425 assert_eq!(high_path, "/v1/chat/completions");
4426 assert_eq!(max_path, "/v1/chat/completions");
4427 assert_eq!(high["reasoning_effort"], "high", "{base_url}: {high}");
4428 assert_eq!(max["reasoning_effort"], "max", "{base_url}: {max}");
4429 for body in [&high, &max] {
4430 assert_eq!(
4431 body["thinking"],
4432 json!({"type": "enabled", "clear_thinking": false}),
4433 "{base_url}: {body}"
4434 );
4435 assert_eq!(body["model"], crate::config::ZAI_GLM_5_2_MODEL);
4436 }
4437 let mut high_without_effort = high.clone();
4438 let mut max_without_effort = max.clone();
4439 high_without_effort
4440 .as_object_mut()
4441 .expect("object")
4442 .remove("reasoning_effort");
4443 max_without_effort
4444 .as_object_mut()
4445 .expect("object")
4446 .remove("reasoning_effort");
4447 assert_eq!(high_without_effort, max_without_effort);
4448
4449 for model in [
4450 crate::config::ZAI_GLM_5_1_MODEL,
4451 crate::config::ZAI_GLM_5_TURBO_MODEL,
4452 ] {
4453 for requested in ["high", "max"] {
4454 let (_, toggle_only) =
4455 capture_zai_chat_request(base_url, model, Some(requested), streaming).await;
4456 assert!(
4457 toggle_only.get("reasoning_effort").is_none(),
4458 "{model}: {toggle_only}"
4459 );
4460 assert_eq!(
4461 toggle_only["thinking"],
4462 json!({"type": "enabled", "clear_thinking": false}),
4463 "{model}: {toggle_only}"
4464 );
4465 }
4466 }
4467
4468 let (_, unknown) =
4469 capture_zai_chat_request(base_url, "glm-future-unknown", Some("max"), streaming)
4470 .await;
4471 assert!(unknown.get("reasoning_effort").is_none(), "{unknown}");
4472 assert!(unknown.get("thinking").is_none(), "{unknown}");
4473 }
4474
4475 let (_, gateway) = capture_zai_chat_request(
4476 "https://gateway.example/v1",
4477 crate::config::ZAI_GLM_5_2_MODEL,
4478 Some("max"),
4479 streaming,
4480 )
4481 .await;
4482 assert!(gateway.get("reasoning_effort").is_none(), "{gateway}");
4483 assert!(gateway.get("thinking").is_none(), "{gateway}");
4484
4485 let (_, gateway_turbo) = capture_zai_chat_request(
4486 "https://gateway.example/v1",
4487 crate::config::ZAI_GLM_5_TURBO_MODEL,
4488 Some("max"),
4489 streaming,
4490 )
4491 .await;
4492 assert!(
4493 gateway_turbo.get("reasoning_effort").is_none(),
4494 "{gateway_turbo}"
4495 );
4496 assert!(gateway_turbo.get("thinking").is_none(), "{gateway_turbo}");
4497 }
4498
4499 async fn assert_minimax_request_truth(streaming: bool) {
4500 for base_url in [
4501 crate::config::DEFAULT_MINIMAX_BASE_URL,
4502 "https://api.minimaxi.com/v1",
4503 ] {
4504 for (effort, expected_thinking) in [
4505 ("off", json!({"type": "disabled"})),
4506 ("high", json!({"type": "adaptive"})),
4507 ("max", json!({"type": "adaptive"})),
4508 ] {
4509 let (_, body) = capture_minimax_chat_request(
4510 base_url,
4511 crate::config::DEFAULT_MINIMAX_MODEL,
4512 Some(effort),
4513 streaming,
4514 )
4515 .await;
4516 assert_eq!(
4517 body["max_completion_tokens"], 64,
4518 "{base_url} {effort}: {body}"
4519 );
4520 assert!(
4521 body.get("max_tokens").is_none(),
4522 "{base_url} {effort}: {body}"
4523 );
4524 assert_eq!(body["reasoning_split"], true, "{base_url}: {body}");
4525 assert_eq!(
4526 body["thinking"], expected_thinking,
4527 "{base_url} {effort}: {body}"
4528 );
4529 }
4530 }
4531
4532 for (base_url, model) in [
4533 (crate::config::DEFAULT_MINIMAX_BASE_URL, "MiniMax-M2"),
4534 (
4535 "https://gateway.example/v1",
4536 crate::config::DEFAULT_MINIMAX_MODEL,
4537 ),
4538 ] {
4539 for effort in ["off", "high", "max"] {
4540 let (_, body) =
4541 capture_minimax_chat_request(base_url, model, Some(effort), streaming).await;
4542 assert_eq!(
4543 body["max_tokens"], 64,
4544 "{base_url} {model} {effort}: {body}"
4545 );
4546 assert!(
4547 body.get("max_completion_tokens").is_none(),
4548 "{base_url} {model} {effort}: {body}"
4549 );
4550 assert!(
4551 body.get("reasoning_split").is_none(),
4552 "{base_url} {model} {effort}: {body}"
4553 );
4554 assert!(
4555 body.get("thinking").is_none(),
4556 "{base_url} {model} {effort}: {body}"
4557 );
4558 }
4559 }
4560 }
4561
4562 async fn assert_k3_request_json_route_boundaries(streaming: bool) {
4563 for (requested, expected) in [("off", "low"), ("high", "high"), ("max", "max")] {
4564 let body = capture_moonshot_chat_request(
4565 crate::config::DEFAULT_MOONSHOT_BASE_URL,
4566 crate::config::MOONSHOT_KIMI_K3_MODEL,
4567 Some(requested),
4568 streaming,
4569 )
4570 .await;
4571 assert_eq!(body["reasoning_effort"], json!(expected), "{body}");
4572 assert!(body.get("thinking").is_none(), "{body}");
4573 assert_eq!(body["max_completion_tokens"], json!(64), "{body}");
4574 assert!(body.get("max_tokens").is_none(), "{body}");
4575 assert!(body.get("temperature").is_none(), "{body}");
4576 assert!(body.get("top_p").is_none(), "{body}");
4577 assert_eq!(
4578 body.get("stream").and_then(Value::as_bool),
4579 streaming.then_some(true)
4580 );
4581 }
4582
4583 for (requested, expected) in [
4584 ("off", Some(json!({"type": "enabled", "effort": "low"}))),
4585 ("max", Some(json!({"type": "enabled", "effort": "max"}))),
4586 ] {
4587 let membership = capture_moonshot_chat_request(
4588 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
4589 crate::config::KIMI_CODE_K3_MODEL,
4590 Some(requested),
4591 streaming,
4592 )
4593 .await;
4594 match expected {
4595 Some(thinking) => assert_eq!(membership["thinking"], thinking, "{membership}"),
4596 None => assert!(membership.get("thinking").is_none(), "{membership}"),
4597 }
4598 assert!(membership.get("reasoning_effort").is_none(), "{membership}");
4599 assert_eq!(membership["max_tokens"], json!(64), "{membership}");
4600 assert!(
4601 membership.get("max_completion_tokens").is_none(),
4602 "{membership}"
4603 );
4604 assert_eq!(membership["temperature"], json!(0.25), "{membership}");
4605 assert_eq!(membership["top_p"], json!(0.75), "{membership}");
4606 }
4607
4608 let provider_default = capture_moonshot_chat_request(
4609 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
4610 crate::config::KIMI_CODE_K3_MODEL,
4611 None,
4612 streaming,
4613 )
4614 .await;
4615 assert!(
4616 provider_default.get("thinking").is_none(),
4617 "only a genuinely omitted effort leaves the provider default in control: {provider_default}"
4618 );
4619 assert!(provider_default.get("reasoning_effort").is_none());
4620
4621 let neighbor = capture_moonshot_chat_request(
4622 "https://proxy.example/v1",
4623 crate::config::MOONSHOT_KIMI_K3_MODEL,
4624 Some("max"),
4625 streaming,
4626 )
4627 .await;
4628 assert_eq!(
4629 neighbor["thinking"],
4630 json!({"type": "enabled"}),
4631 "{neighbor}"
4632 );
4633 assert!(neighbor.get("reasoning_effort").is_none(), "{neighbor}");
4634 assert!(neighbor.pointer("/thinking/effort").is_none(), "{neighbor}");
4635 assert_eq!(neighbor["max_tokens"], json!(64), "{neighbor}");
4636 assert!(
4637 neighbor.get("max_completion_tokens").is_none(),
4638 "{neighbor}"
4639 );
4640 assert_eq!(neighbor["temperature"], json!(0.25), "{neighbor}");
4641 assert_eq!(neighbor["top_p"], json!(0.75), "{neighbor}");
4642 }
4643
4644 async fn assert_kimi_code_raw_off_replays_tool_history(streaming: bool) {
4645 let mut request =
4646 k3_request_fixture(crate::config::KIMI_CODE_K3_MODEL, Some("off"), streaming);
4647 request.messages = vec![
4648 Message {
4649 role: "assistant".to_string(),
4650 content: vec![
4651 ContentBlock::Thinking {
4652 thinking: "Inspect the saved tool state".to_string(),
4653 signature: None,
4654 },
4655 ContentBlock::ToolUse {
4656 id: "call-k3-replay".to_string(),
4657 name: "read_file".to_string(),
4658 input: json!({"path": "src/lib.rs"}),
4659 caller: None,
4660 },
4661 ],
4662 },
4663 Message {
4664 role: "user".to_string(),
4665 content: vec![ContentBlock::ToolResult {
4666 tool_use_id: "call-k3-replay".to_string(),
4667 content: "file contents".to_string(),
4668 is_error: None,
4669 content_blocks: None,
4670 }],
4671 },
4672 ];
4673
4674 let body = capture_moonshot_chat_request_body(
4675 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
4676 crate::config::KIMI_CODE_K3_MODEL,
4677 request,
4678 )
4679 .await;
4680 assert_eq!(
4681 body["thinking"],
4682 json!({"type": "enabled", "effort": "low"}),
4683 "raw Off must still normalize to K3's always-thinking low tier: {body}"
4684 );
4685 let assistant = body["messages"]
4686 .as_array()
4687 .and_then(|messages| {
4688 messages
4689 .iter()
4690 .find(|message| message["role"] == "assistant")
4691 })
4692 .expect("captured assistant tool-call history");
4693 assert_eq!(
4694 assistant["reasoning_content"],
4695 json!("Inspect the saved tool state"),
4696 "exact membership K3 must replay reasoning even for a stale raw Off caller: {body}"
4697 );
4698 assert!(assistant["tool_calls"].is_array(), "{assistant}");
4699 }
4700
4701 async fn assert_kimi_code_apply_patch_schema_is_mfjs_compatible(streaming: bool) {
4702 let mut request =
4703 k3_request_fixture(crate::config::KIMI_CODE_K3_MODEL, Some("low"), streaming);
4704 request.tools = Some(vec![apply_patch_request_tool()]);
4705
4706 let body = capture_moonshot_chat_request_body(
4707 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
4708 crate::config::KIMI_CODE_K3_MODEL,
4709 request,
4710 )
4711 .await;
4712 let function = &body["tools"][0]["function"];
4713 let parameters = &function["parameters"];
4714 assert_eq!(parameters["type"], "object", "{parameters}");
4715 assert!(parameters.get("oneOf").is_none(), "{parameters}");
4716 assert!(parameters.get("anyOf").is_none(), "{parameters}");
4717 assert!(parameters.get("allOf").is_none(), "{parameters}");
4718 assert_eq!(parameters["properties"]["patch"]["type"], "string");
4719 assert_eq!(parameters["properties"]["replace"]["type"], "array");
4720 assert_eq!(parameters["properties"]["changes"]["type"], "array");
4721 assert!(
4722 function["description"]
4723 .as_str()
4724 .is_some_and(|description| description
4725 .contains("Exactly one of these parameter groups must be provided")),
4726 "the relaxed wire schema must preserve the runtime constraint in its description: {function}"
4727 );
4728 }
4729
4730 async fn assert_kimi_code_invalid_root_ref_fails_before_transport(streaming: bool) {
4731 let server = MockServer::start().await;
4732 let client = moonshot_request_boundary_client(
4733 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
4734 crate::config::KIMI_CODE_K3_MODEL,
4735 server.uri(),
4736 );
4737 let mut request =
4738 k3_request_fixture(crate::config::KIMI_CODE_K3_MODEL, Some("low"), streaming);
4739 let mut tool = test_tool("private_schema_tool");
4740 tool.input_schema = json!({
4741 "$ref": "#/$defs/private-root-name-3158",
4742 "$defs": {}
4743 });
4744 request.tools = Some(vec![tool]);
4745
4746 let error = if streaming {
4747 match client.create_message_stream(request).await {
4748 Ok(_) => panic!("invalid streaming parameters reached transport"),
4749 Err(error) => error,
4750 }
4751 } else {
4752 match client.create_message(request).await {
4753 Ok(_) => panic!("invalid non-streaming parameters reached transport"),
4754 Err(error) => error,
4755 }
4756 };
4757 let diagnostic = error.to_string();
4758 assert!(
4759 diagnostic.contains("failed safe compatibility validation"),
4760 "{diagnostic}"
4761 );
4762 assert!(
4763 diagnostic.contains("unresolved internal root reference"),
4764 "{diagnostic}"
4765 );
4766 assert!(!diagnostic.contains("private-root-name-3158"));
4767 assert!(
4768 server
4769 .received_requests()
4770 .await
4771 .expect("request log")
4772 .is_empty(),
4773 "invalid parameters must fail before transport"
4774 );
4775 }
4776
4777 async fn assert_kimi_code_untyped_default_fails_before_transport(streaming: bool) {
4778 let server = MockServer::start().await;
4779 let client = moonshot_request_boundary_client(
4780 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
4781 crate::config::KIMI_CODE_K3_MODEL,
4782 server.uri(),
4783 );
4784 let mut request =
4785 k3_request_fixture(crate::config::KIMI_CODE_K3_MODEL, Some("low"), streaming);
4786 let mut tool = test_tool("private_default_tool");
4787 tool.input_schema = json!({
4788 "type": "object",
4789 "properties": {
4790 "private-field-4401": {
4791 "default": "private-default-value-4402"
4792 }
4793 }
4794 });
4795 request.tools = Some(vec![tool]);
4796
4797 let error = if streaming {
4798 match client.create_message_stream(request).await {
4799 Ok(_) => panic!("untyped streaming parameters reached transport"),
4800 Err(error) => error,
4801 }
4802 } else {
4803 match client.create_message(request).await {
4804 Ok(_) => panic!("untyped non-streaming parameters reached transport"),
4805 Err(error) => error,
4806 }
4807 };
4808 let diagnostic = error.to_string();
4809 assert!(
4810 diagnostic.contains("failed safe compatibility validation"),
4811 "{diagnostic}"
4812 );
4813 assert!(
4814 diagnostic.contains("without a concrete type"),
4815 "{diagnostic}"
4816 );
4817 assert!(!diagnostic.contains("private-field-4401"));
4818 assert!(!diagnostic.contains("private-default-value-4402"));
4819 assert!(
4820 server
4821 .received_requests()
4822 .await
4823 .expect("request log")
4824 .is_empty(),
4825 "untyped parameters must fail before transport"
4826 );
4827 }
4828
4829 async fn assert_kimi_code_streams_mfjs_safe_deferred_dynamic_tool() {
4830 let tool = deferred_dynamic_request_tool();
4831 assert_eq!(tool.defer_loading, Some(true));
4832 assert_eq!(
4833 tool.input_schema["properties"]["query"]["nullable"], true,
4834 "ToolRegistry must exercise the provider-neutral nullable collapse"
4835 );
4836 assert!(
4837 tool.input_schema["properties"]["query"]
4838 .get("anyOf")
4839 .is_none()
4840 );
4841 assert_eq!(tool.input_schema["properties"]["mode"]["const"], "fast");
4842
4843 let mut request = k3_request_fixture(crate::config::KIMI_CODE_K3_MODEL, Some("low"), true);
4844 request.tools = Some(vec![tool]);
4845 let body = capture_moonshot_chat_request_body(
4846 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
4847 crate::config::KIMI_CODE_K3_MODEL,
4848 request,
4849 )
4850 .await;
4851
4852 assert_eq!(
4853 body["stream"], true,
4854 "this must exercise the SSE path: {body}"
4855 );
4856 let parameters = &captured_function(&body, "deferred_lookup")["parameters"];
4857 assert_eq!(parameters["properties"]["mode"]["enum"], json!(["fast"]));
4858 assert!(
4859 parameters["properties"]["mode"].get("const").is_none(),
4860 "{parameters}"
4861 );
4862 assert_eq!(
4863 parameters["properties"]["query"]["anyOf"],
4864 json!([{"type": "string"}, {"type": "null"}])
4865 );
4866 assert!(
4867 parameters["properties"]["query"].get("nullable").is_none(),
4868 "{parameters}"
4869 );
4870 crate::tools::schema_sanitize::validate_mfjs_parameters(parameters).unwrap();
4871 }
4872
4873 async fn assert_kimi_code_captures_exact_general_child_catalog() {
4874 let tools = crate::tools::subagent::kimi_general_child_request_tools_fixture();
4875 let source_len = tools.len();
4876 assert!(source_len > 20, "expected a real General child catalog");
4877
4878 // Name the offending first-party tool in test-only diagnostics while
4879 // production errors remain fixed and non-secret.
4880 for tool in &tools {
4881 let mut parameters = tool.input_schema.clone();
4882 crate::tools::schema_sanitize::sanitize_for_kimi_parameters(&mut parameters)
4883 .unwrap_or_else(|error| panic!("General child tool {}: {error}", tool.name));
4884 }
4885
4886 let mut request = k3_request_fixture(crate::config::KIMI_CODE_K3_MODEL, Some("low"), false);
4887 request.tools = Some(tools);
4888 let body = capture_moonshot_chat_request_body(
4889 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
4890 crate::config::KIMI_CODE_K3_MODEL,
4891 request,
4892 )
4893 .await;
4894
4895 let captured = body["tools"].as_array().expect("captured tool catalog");
4896 assert_eq!(captured.len(), source_len);
4897 assert!(captured_function(&body, "get_goal").is_object());
4898 assert!(
4899 captured
4900 .iter()
4901 .all(|tool| tool["function"]["name"] != "create_goal")
4902 );
4903 assert!(
4904 captured
4905 .iter()
4906 .all(|tool| tool["function"]["name"] != "update_goal")
4907 );
4908
4909 for tool in captured {
4910 let parameters = &tool["function"]["parameters"];
4911 for unsupported in ["const", "nullable", "oneOf", "allOf"] {
4912 assert!(
4913 !value_contains_key(parameters, unsupported),
4914 "captured {} still contains {unsupported}: {parameters}",
4915 tool["function"]["name"]
4916 );
4917 }
4918 crate::tools::schema_sanitize::validate_mfjs_parameters(parameters).unwrap();
4919 }
4920
4921 let handle_read = &captured_function(&body, "handle_read")["parameters"];
4922 assert!(
4923 handle_read.to_string().contains("var_handle"),
4924 "real nested const fixture must survive as an enum: {handle_read}"
4925 );
4926 assert!(value_contains_key(handle_read, "enum"), "{handle_read}");
4927 }
4928
4929 #[tokio::test]
4930 async fn create_message_request_json_honors_exact_k3_route_boundaries() {
4931 assert_k3_request_json_route_boundaries(false).await;
4932 }
4933
4934 #[tokio::test]
4935 async fn create_message_stream_request_json_honors_exact_k3_route_boundaries() {
4936 assert_k3_request_json_route_boundaries(true).await;
4937 }
4938
4939 /// v0.9.1 kimi-k3 dogfood report: the id the user selects has to be the id on the wire. A
4940 /// dogfood user selecting `kimi-k3` was served `kimi-k2.7-code`, so this
4941 /// asserts the wire `model` field for each K3 product on its own endpoint,
4942 /// and that neither one's request carries the other's id.
4943 #[tokio::test]
4944 async fn selected_moonshot_k3_model_is_the_model_on_the_wire() {
4945 let platform = capture_moonshot_chat_request(
4946 crate::config::DEFAULT_MOONSHOT_BASE_URL,
4947 crate::config::MOONSHOT_KIMI_K3_MODEL,
4948 Some("high"),
4949 false,
4950 )
4951 .await;
4952 assert_eq!(
4953 platform["model"],
4954 json!(crate::config::MOONSHOT_KIMI_K3_MODEL),
4955 "the direct platform route must send the id the user named: {platform}"
4956 );
4957 assert_ne!(
4958 platform["model"],
4959 json!(crate::config::DEFAULT_MOONSHOT_MODEL),
4960 "an explicit selection is never replaced by the provider default: {platform}"
4961 );
4962 assert_ne!(
4963 platform["model"],
4964 json!(crate::config::KIMI_CODE_K3_MODEL),
4965 "the coding-plan id must not leak onto the platform route: {platform}"
4966 );
4967
4968 let membership = capture_moonshot_chat_request(
4969 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
4970 crate::config::KIMI_CODE_K3_MODEL,
4971 Some("high"),
4972 false,
4973 )
4974 .await;
4975 assert_eq!(
4976 membership["model"],
4977 json!(crate::config::KIMI_CODE_K3_MODEL),
4978 "the Kimi Code membership route must send bare `k3`: {membership}"
4979 );
4980 assert_ne!(
4981 membership["model"],
4982 json!(crate::config::MOONSHOT_KIMI_K3_MODEL),
4983 "the platform id must not leak onto the coding-plan route: {membership}"
4984 );
4985 }
4986
4987 #[tokio::test]
4988 async fn create_message_request_json_keeps_zai_effort_route_exact() {
4989 assert_zai_request_truth(false).await;
4990 }
4991
4992 #[tokio::test]
4993 async fn create_message_stream_request_json_keeps_zai_effort_route_exact() {
4994 assert_zai_request_truth(true).await;
4995 }
4996
4997 #[tokio::test]
4998 async fn create_message_request_json_keeps_minimax_token_dialect_exact() {
4999 assert_minimax_request_truth(false).await;
5000 }
5001
5002 #[tokio::test]
5003 async fn create_message_stream_request_json_keeps_minimax_token_dialect_exact() {
5004 assert_minimax_request_truth(true).await;
5005 }
5006
5007 #[tokio::test]
5008 async fn create_message_request_json_keeps_modelstudio_enable_thinking_exact() {
5009 assert_modelstudio_request_truth(false).await;
5010 }
5011
5012 #[tokio::test]
5013 async fn create_message_stream_request_json_keeps_modelstudio_enable_thinking_exact() {
5014 assert_modelstudio_request_truth(true).await;
5015 }
5016
5017 #[tokio::test]
5018 async fn create_message_routes_only_strict_deepseek_tools_to_beta() {
5019 assert_deepseek_strict_request_route_boundary(false).await;
5020 }
5021
5022 #[tokio::test]
5023 async fn create_message_stream_routes_only_strict_deepseek_tools_to_beta() {
5024 assert_deepseek_strict_request_route_boundary(true).await;
5025 }
5026
5027 #[tokio::test]
5028 async fn create_message_request_replays_kimi_code_history_for_raw_off() {
5029 assert_kimi_code_raw_off_replays_tool_history(false).await;
5030 }
5031
5032 #[tokio::test]
5033 async fn create_message_stream_replays_kimi_code_history_for_raw_off() {
5034 assert_kimi_code_raw_off_replays_tool_history(true).await;
5035 }
5036
5037 #[tokio::test]
5038 async fn create_message_request_sends_mfjs_compatible_apply_patch_schema() {
5039 assert_kimi_code_apply_patch_schema_is_mfjs_compatible(false).await;
5040 }
5041
5042 #[tokio::test]
5043 async fn create_message_stream_sends_mfjs_compatible_apply_patch_schema() {
5044 assert_kimi_code_apply_patch_schema_is_mfjs_compatible(true).await;
5045 }
5046
5047 #[tokio::test]
5048 async fn create_message_request_rejects_invalid_kimi_root_ref_before_transport() {
5049 assert_kimi_code_invalid_root_ref_fails_before_transport(false).await;
5050 }
5051
5052 #[tokio::test]
5053 async fn create_message_stream_rejects_invalid_kimi_root_ref_before_transport() {
5054 assert_kimi_code_invalid_root_ref_fails_before_transport(true).await;
5055 }
5056
5057 #[tokio::test]
5058 async fn create_message_request_rejects_untyped_kimi_default_before_transport() {
5059 assert_kimi_code_untyped_default_fails_before_transport(false).await;
5060 }
5061
5062 #[tokio::test]
5063 async fn create_message_stream_rejects_untyped_kimi_default_before_transport() {
5064 assert_kimi_code_untyped_default_fails_before_transport(true).await;
5065 }
5066
5067 #[tokio::test]
5068 async fn create_message_stream_sends_mfjs_safe_deferred_dynamic_tool() {
5069 assert_kimi_code_streams_mfjs_safe_deferred_dynamic_tool().await;
5070 }
5071
5072 #[tokio::test]
5073 async fn create_message_captures_exact_mfjs_safe_general_child_catalog() {
5074 assert_kimi_code_captures_exact_general_child_catalog().await;
5075 }
5076
5077 fn opencode_zen_client(server: &MockServer, model: &str) -> DeepSeekClient {
5078 let config = Config {
5079 provider: Some("opencode-zen".to_string()),
5080 providers: Some(ProvidersConfig {
5081 opencode_zen: ProviderConfig {
5082 api_key: Some("zen-test-key".to_string()),
5083 base_url: Some(server.uri()),
5084 model: Some(model.to_string()),
5085 ..ProviderConfig::default()
5086 },
5087 ..ProvidersConfig::default()
5088 }),
5089 ..Config::default()
5090 };
5091 DeepSeekClient::new(&config).expect("OpenCode Zen client should resolve its model route")
5092 }
5093
5094 fn minimal_zen_request(model: &str) -> MessageRequest {
5095 translation_message_request("hello", model.to_string(), "English")
5096 }
5097
5098 fn assert_zen_bearer_without_codex_headers(request: &wiremock::Request) {
5099 assert_eq!(
5100 request
5101 .headers
5102 .get(AUTHORIZATION)
5103 .and_then(|value| value.to_str().ok()),
5104 Some("Bearer zen-test-key")
5105 );
5106 for forbidden in [
5107 "openai-beta",
5108 "originator",
5109 "chatgpt-account-id",
5110 "x-api-key",
5111 ] {
5112 assert!(
5113 request.headers.get(forbidden).is_none(),
5114 "Zen request must not include {forbidden}"
5115 );
5116 }
5117 }
5118
5119 fn assert_zen_messages_api_key_without_bearer(request: &wiremock::Request) {
5120 assert_eq!(
5121 request
5122 .headers
5123 .get("x-api-key")
5124 .and_then(|value| value.to_str().ok()),
5125 Some("zen-test-key")
5126 );
5127 assert!(
5128 request.headers.get(AUTHORIZATION).is_none(),
5129 "Zen Messages request must not include Authorization"
5130 );
5131 for forbidden in ["openai-beta", "originator", "chatgpt-account-id"] {
5132 assert!(
5133 request.headers.get(forbidden).is_none(),
5134 "Zen request must not include {forbidden}"
5135 );
5136 }
5137 }
5138
5139 #[tokio::test]
5140 async fn opencode_zen_responses_request_uses_responses_route_without_oauth_headers() {
5141 let server = MockServer::start().await;
5142 Mock::given(method("POST"))
5143 .and(path("/v1/responses"))
5144 .respond_with(
5145 ResponseTemplate::new(200)
5146 .insert_header("Content-Type", "text/event-stream")
5147 .set_body_string("data: [DONE]\n\n"),
5148 )
5149 .expect(1)
5150 .mount(&server)
5151 .await;
5152
5153 let client = opencode_zen_client(&server, "gpt-5.5");
5154 assert_eq!(client.wire_format, WireFormat::Responses);
5155 let mut stream = client
5156 .create_message_stream(minimal_zen_request("gpt-5.5"))
5157 .await
5158 .expect("Zen Responses request should start");
5159 while let Some(event) = stream.next().await {
5160 event.expect("Zen Responses stream event");
5161 }
5162
5163 let requests = server.received_requests().await.expect("recorded request");
5164 assert_eq!(requests.len(), 1);
5165 assert_zen_bearer_without_codex_headers(&requests[0]);
5166 let body: Value = serde_json::from_slice(&requests[0].body).expect("Responses JSON body");
5167 assert_eq!(body.get("model").and_then(Value::as_str), Some("gpt-5.5"));
5168 assert!(body.get("input").is_some(), "Responses body: {body}");
5169 assert!(body.get("messages").is_none(), "Responses body: {body}");
5170 }
5171
5172 #[tokio::test]
5173 async fn opencode_zen_messages_request_shape_uses_api_key_anthropic_route() {
5174 let server = MockServer::start().await;
5175 Mock::given(method("POST"))
5176 .and(path("/v1/messages"))
5177 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
5178 "id": "msg_zen",
5179 "type": "message",
5180 "role": "assistant",
5181 "content": [{"type": "text", "text": "ok"}],
5182 "model": "claude-sonnet-4-6",
5183 "stop_reason": "end_turn",
5184 "stop_sequence": null,
5185 "usage": {"input_tokens": 1, "output_tokens": 1}
5186 })))
5187 .expect(1)
5188 .mount(&server)
5189 .await;
5190
5191 let client = opencode_zen_client(&server, "claude-sonnet-4-6");
5192 assert_eq!(client.wire_format, WireFormat::AnthropicMessages);
5193 client
5194 .create_message(minimal_zen_request("claude-sonnet-4-6"))
5195 .await
5196 .expect("Zen Messages request should succeed");
5197
5198 let requests = server.received_requests().await.expect("recorded request");
5199 assert_eq!(requests.len(), 1);
5200 assert_zen_messages_api_key_without_bearer(&requests[0]);
5201 assert_eq!(
5202 requests[0]
5203 .headers
5204 .get("anthropic-version")
5205 .and_then(|value| value.to_str().ok()),
5206 Some("2023-06-01")
5207 );
5208 }
5209
5210 #[tokio::test]
5211 async fn opencode_zen_chat_request_uses_chat_completions_route() {
5212 let server = MockServer::start().await;
5213 Mock::given(method("POST"))
5214 .and(path("/v1/chat/completions"))
5215 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
5216 "id": "chatcmpl_zen",
5217 "object": "chat.completion",
5218 "model": "deepseek-v4-pro",
5219 "choices": [{
5220 "index": 0,
5221 "message": {"role": "assistant", "content": "ok"},
5222 "finish_reason": "stop"
5223 }],
5224 "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
5225 })))
5226 .expect(1)
5227 .mount(&server)
5228 .await;
5229
5230 let client = opencode_zen_client(&server, "deepseek-v4-pro");
5231 assert_eq!(client.wire_format, WireFormat::ChatCompletions);
5232 client
5233 .create_message(minimal_zen_request("deepseek-v4-pro"))
5234 .await
5235 .expect("Zen Chat Completions request should succeed");
5236
5237 let requests = server.received_requests().await.expect("recorded request");
5238 assert_eq!(requests.len(), 1);
5239 assert_zen_bearer_without_codex_headers(&requests[0]);
5240 assert!(requests[0].headers.get("anthropic-version").is_none());
5241 }
5242
5243 #[tokio::test]
5244 async fn opencode_zen_client_fails_closed_when_request_model_changes_protocol() {
5245 let server = MockServer::start().await;
5246 let client = opencode_zen_client(&server, "gpt-5.5");
5247
5248 let error = client
5249 .create_message(minimal_zen_request("claude-sonnet-4-6"))
5250 .await
5251 .expect_err("a Responses-bound client must not send a Messages model");
5252 assert!(format!("{error:#}").contains("resolve a new model route"));
5253 assert!(
5254 server
5255 .received_requests()
5256 .await
5257 .expect("recorded requests")
5258 .is_empty()
5259 );
5260 }
5261
5262 const CONFIG_SECRET_SENTINELS: [&str; 8] = [
5263 "deepseek-config-secret-001",
5264 "arcee-config-secret-002",
5265 "moonshot-config-secret-003",
5266 "openrouter-config-secret-004",
5267 "together-config-secret-005",
5268 "xiaomi-config-secret-006",
5269 "zai-active-config-secret-007",
5270 "sakana-config-secret-008",
5271 ];
5272
5273 #[test]
5274 fn codex_client_uses_one_coherent_external_credential_snapshot() {
5275 let _env = crate::test_support::lock_test_env();
5276 let temp = tempfile::tempdir().expect("credential fixture");
5277 let path = temp
5278 .path()
5279 .canonicalize()
5280 .expect("canonical temp root")
5281 .join("auth.json");
5282 let token_a = crate::test_support::future_test_jwt("a");
5283 std::fs::write(
5284 &path,
5285 serde_json::to_vec(&serde_json::json!({
5286 "tokens": {"access_token": token_a.clone(), "account_id": "account-a"}
5287 }))
5288 .expect("serialize fixture"),
5289 )
5290 .expect("write fixture");
5291 let _auth_path = crate::test_support::EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &path);
5292 let _access = crate::test_support::EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
5293 let _legacy_access = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
5294 let config = Config {
5295 provider: Some(ApiProvider::OpenaiCodex.as_str().to_string()),
5296 providers: Some(ProvidersConfig {
5297 openai_codex: ProviderConfig {
5298 auth_mode: Some("oauth".to_string()),
5299 external_credentials: Some(
5300 codewhale_config::ExternalCredentialConsentToml::read_only(
5301 codewhale_config::ProviderKind::OpenaiCodex,
5302 codewhale_config::ExternalCredentialSource::CodexCli,
5303 path.clone(),
5304 ),
5305 ),
5306 ..ProviderConfig::default()
5307 },
5308 ..ProvidersConfig::default()
5309 }),
5310 ..Config::default()
5311 };
5312
5313 crate::external_credentials::reset_side_effect_trap();
5314 let client = DeepSeekClient::new(&config).expect("Codex client");
5315 assert_eq!(client.api_key, token_a);
5316 assert_eq!(client.codex_account_id.as_deref(), Some("account-a"));
5317 assert_eq!(
5318 crate::external_credentials::side_effect_trap_counts(),
5319 (1, 1),
5320 "bearer and account id must come from one secure open/read"
5321 );
5322
5323 // An owner rotation after construction cannot splice account B into
5324 // the already-resolved bearer snapshot.
5325 std::fs::write(
5326 &path,
5327 serde_json::to_string(&serde_json::json!({"tokens": {"access_token": crate::test_support::future_test_jwt("b"), "account_id": "account-b"}})).expect("serialize rotated fixture"),
5328 )
5329 .expect("rotate fixture");
5330 assert_eq!(client.api_key, token_a);
5331 assert_eq!(client.codex_account_id.as_deref(), Some("account-a"));
5332 }
5333
5334 fn client_with_config_secret_sentinels() -> DeepSeekClient {
5335 let _ = rustls::crypto::ring::default_provider().install_default();
5336 DeepSeekClient::new(&Config {
5337 provider: Some("zai".to_string()),
5338 api_key: Some(CONFIG_SECRET_SENTINELS[0].to_string()),
5339 providers: Some(ProvidersConfig {
5340 arcee: ProviderConfig {
5341 api_key: Some(CONFIG_SECRET_SENTINELS[1].to_string()),
5342 ..ProviderConfig::default()
5343 },
5344 moonshot: ProviderConfig {
5345 api_key: Some(CONFIG_SECRET_SENTINELS[2].to_string()),
5346 ..ProviderConfig::default()
5347 },
5348 openrouter: ProviderConfig {
5349 api_key: Some(CONFIG_SECRET_SENTINELS[3].to_string()),
5350 ..ProviderConfig::default()
5351 },
5352 together: ProviderConfig {
5353 api_key: Some(CONFIG_SECRET_SENTINELS[4].to_string()),
5354 ..ProviderConfig::default()
5355 },
5356 xiaomi_mimo: ProviderConfig {
5357 api_key: Some(CONFIG_SECRET_SENTINELS[5].to_string()),
5358 ..ProviderConfig::default()
5359 },
5360 zai: ProviderConfig {
5361 api_key: Some(CONFIG_SECRET_SENTINELS[6].to_string()),
5362 ..ProviderConfig::default()
5363 },
5364 sakana: ProviderConfig {
5365 api_key: Some(CONFIG_SECRET_SENTINELS[7].to_string()),
5366 ..ProviderConfig::default()
5367 },
5368 ..ProvidersConfig::default()
5369 }),
5370 ..Config::default()
5371 })
5372 .expect("client with secret sentinels")
5373 }
5374
5375 fn request_with_tool_result(content: impl Into<String>) -> MessageRequest {
5376 MessageRequest {
5377 model: "glm-5.2".to_string(),
5378 messages: vec![
5379 Message {
5380 role: "assistant".to_string(),
5381 content: vec![ContentBlock::ToolUse {
5382 id: "call-secret-test".to_string(),
5383 name: "read_file".to_string(),
5384 input: json!({"path": "config.toml"}),
5385 caller: None,
5386 }],
5387 },
5388 Message {
5389 role: "user".to_string(),
5390 content: vec![ContentBlock::ToolResult {
5391 tool_use_id: "call-secret-test".to_string(),
5392 content: content.into(),
5393 is_error: None,
5394 content_blocks: None,
5395 }],
5396 },
5397 ],
5398 max_tokens: 128,
5399 system: None,
5400 tools: None,
5401 tool_choice: None,
5402 metadata: None,
5403 thinking: None,
5404 reasoning_effort: None,
5405 stream: None,
5406 temperature: None,
5407 top_p: None,
5408 }
5409 }
5410
5411 fn tool_result_content(request: &MessageRequest) -> &str {
5412 request
5413 .messages
5414 .iter()
5415 .flat_map(|message| &message.content)
5416 .find_map(|block| match block {
5417 ContentBlock::ToolResult { content, .. } => Some(content.as_str()),
5418 _ => None,
5419 })
5420 .expect("tool result content")
5421 }
5422
5423 #[test]
5424 fn model_bound_request_repairs_dangling_tool_call_before_adapter_projection() {
5425 let client = client_with_config_secret_sentinels();
5426 let mut request = request_with_tool_result("unused");
5427 request.messages.pop();
5428
5429 let prepared = client.prepare_model_bound_request(request);
5430
5431 assert!(prepared.messages.iter().any(|message| {
5432 message.content.iter().any(|block| {
5433 matches!(
5434 block,
5435 ContentBlock::ToolResult {
5436 tool_use_id,
5437 content,
5438 is_error: Some(true),
5439 ..
5440 } if tool_use_id == "call-secret-test"
5441 && content.contains("crashed_and_repaired")
5442 )
5443 })
5444 }));
5445 assert_eq!(
5446 prepared.messages.last().expect("repaired result").role,
5447 "user"
5448 );
5449 assert!(!prepared.messages.iter().any(|message| {
5450 message.content.iter().any(|block| {
5451 matches!(
5452 block,
5453 ContentBlock::Text { text, .. }
5454 if text.contains("[tool_history_repair]")
5455 )
5456 })
5457 }));
5458 }
5459
5460 #[test]
5461 fn model_bound_request_redacts_configured_secrets_and_bare_active_key() {
5462 let client = client_with_config_secret_sentinels();
5463 let config_dump = format!(
5464 "api_key = \"{}\"\n[providers.arcee]\napi_key = \"{}\"\n\
5465 ordinary_setting = \"keep-me\"\nall bare values: {}",
5466 CONFIG_SECRET_SENTINELS[0],
5467 CONFIG_SECRET_SENTINELS[1],
5468 CONFIG_SECRET_SENTINELS.join(" ")
5469 );
5470
5471 let prepared = client.prepare_model_bound_request(request_with_tool_result(config_dump));
5472 let content = tool_result_content(&prepared);
5473
5474 for secret in CONFIG_SECRET_SENTINELS {
5475 assert!(!content.contains(secret), "secret survived redaction");
5476 }
5477 assert!(content.contains(codewhale_config::persistence::REDACTED));
5478 assert!(content.contains("ordinary_setting"));
5479 assert!(content.contains("keep-me"));
5480 }
5481
5482 #[test]
5483 fn model_bound_request_redacts_inactive_file_store_and_environment_secrets() {
5484 const FILE_STORED_INACTIVE: &str = "inactive-arcee-file-secret-901";
5485 const BUILTIN_ENV_SECRET: &str = "inactive-arcee-env-secret-902";
5486 const CUSTOM_ENV_NAME: &str = "CW_TEST_CUSTOM_PROVIDER_API_KEY";
5487 const CUSTOM_ENV_SECRET: &str = "inactive-custom-env-secret-903";
5488
5489 let _env_lock = crate::test_support::lock_test_env();
5490 let tmp = tempfile::tempdir().expect("tempdir");
5491 let codewhale_home = tmp.path().join("codewhale-home");
5492 let home = tmp.path().join("home");
5493 std::fs::create_dir_all(&home).expect("create isolated home");
5494 let _codewhale_home =
5495 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
5496 let _secret_backend =
5497 crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
5498 let _home = crate::test_support::EnvVarGuard::set("HOME", &home);
5499 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", &home);
5500 let builtin_env_name = ApiProvider::Arcee
5501 .env_vars()
5502 .first()
5503 .copied()
5504 .expect("Arcee API-key environment variable");
5505 let _builtin_env =
5506 crate::test_support::EnvVarGuard::set(builtin_env_name, BUILTIN_ENV_SECRET);
5507 let _custom_env = crate::test_support::EnvVarGuard::set(CUSTOM_ENV_NAME, CUSTOM_ENV_SECRET);
5508
5509 codewhale_secrets::Secrets::file_backed()
5510 .set("arcee", FILE_STORED_INACTIVE)
5511 .expect("write isolated inactive provider credential");
5512
5513 let _ = rustls::crypto::ring::default_provider().install_default();
5514 let client = DeepSeekClient::new(&Config {
5515 provider: Some("zai".to_string()),
5516 providers: Some(ProvidersConfig {
5517 zai: ProviderConfig {
5518 api_key: Some("active-zai-secret-900".to_string()),
5519 ..ProviderConfig::default()
5520 },
5521 custom: HashMap::from([(
5522 "example-custom".to_string(),
5523 ProviderConfig {
5524 kind: Some("openai-compatible".to_string()),
5525 api_key_env: Some(CUSTOM_ENV_NAME.to_string()),
5526 ..ProviderConfig::default()
5527 },
5528 )]),
5529 ..ProvidersConfig::default()
5530 }),
5531 ..Config::default()
5532 })
5533 .expect("client with inactive file-store credential");
5534 let prepared = client.prepare_model_bound_request(request_with_tool_result(format!(
5535 "retrieved values: {FILE_STORED_INACTIVE} {BUILTIN_ENV_SECRET} {CUSTOM_ENV_SECRET}\nordinary output survives"
5536 )));
5537 let content = tool_result_content(&prepared);
5538
5539 for secret in [FILE_STORED_INACTIVE, BUILTIN_ENV_SECRET, CUSTOM_ENV_SECRET] {
5540 assert!(
5541 !content.contains(secret),
5542 "inactive secret survived: {secret}"
5543 );
5544 }
5545 assert!(content.contains(codewhale_config::persistence::REDACTED));
5546 assert!(content.contains("ordinary output survives"));
5547 }
5548
5549 #[test]
5550 fn whitespace_codewhale_home_does_not_load_ambient_redaction_secrets() {
5551 let _env_lock = crate::test_support::lock_test_env();
5552 let tmp = tempfile::tempdir().expect("tempdir");
5553 let ambient_home = tmp.path().join("ambient-home");
5554 std::fs::create_dir_all(&ambient_home).expect("create ambient home");
5555 let _home = crate::test_support::EnvVarGuard::set("HOME", &ambient_home);
5556 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", &ambient_home);
5557 let _codewhale_home_unset = crate::test_support::EnvVarGuard::remove("CODEWHALE_HOME");
5558 let _secret_backend =
5559 crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
5560 codewhale_secrets::Secrets::file_backed()
5561 .set("arcee", "ambient-redaction-secret-sentinel")
5562 .expect("seed ambient file secret store");
5563 let _whitespace_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", " \t ");
5564 let mut values = Vec::new();
5565
5566 push_file_backed_model_bound_secrets(&mut values);
5567
5568 assert!(
5569 !values
5570 .iter()
5571 .any(|value| value == "ambient-redaction-secret-sentinel"),
5572 "whitespace must not opt tests into reading the ambient secret store"
5573 );
5574 }
5575
5576 #[test]
5577 fn model_bound_request_leaves_ordinary_tool_output_unchanged() {
5578 let client = client_with_config_secret_sentinels();
5579 let ordinary = "tests passed: 42\nREADME.md updated\n";
5580 let prepared =
5581 client.prepare_model_bound_request(request_with_tool_result(ordinary.to_string()));
5582 assert_eq!(tool_result_content(&prepared), ordinary);
5583 }
5584
5585 #[test]
5586 fn short_chat_tool_payload_is_redacted_before_wire_serialization() {
5587 let client = client_with_config_secret_sentinels();
5588 let prepared = client.prepare_model_bound_request(request_with_tool_result(format!(
5589 "active token: {}",
5590 CONFIG_SECRET_SENTINELS[6]
5591 )));
5592 let wire = build_chat_messages_for_request(&prepared);
5593 let serialized = serde_json::to_string(&wire).expect("serialize chat wire messages");
5594
5595 assert!(!serialized.contains(CONFIG_SECRET_SENTINELS[6]));
5596 assert!(serialized.contains(codewhale_config::persistence::REDACTED));
5597 }
5598
5599 #[test]
5600 fn configured_secret_redaction_reaches_all_protocol_bodies() {
5601 let client = client_with_config_secret_sentinels();
5602 let prepared = client.prepare_model_bound_request(request_with_tool_result(format!(
5603 "safe output then {}",
5604 CONFIG_SECRET_SENTINELS[6]
5605 )));
5606
5607 let chat = serde_json::to_string(&build_chat_messages_for_request(&prepared))
5608 .expect("serialize Chat Completions body");
5609 let anthropic = client.build_anthropic_body(&prepared, false).to_string();
5610 let responses = build_responses_body(&prepared).to_string();
5611
5612 for (route, body) in [
5613 ("chat", chat.as_str()),
5614 ("anthropic", anthropic.as_str()),
5615 ("responses", responses.as_str()),
5616 ] {
5617 assert!(
5618 !body.contains(CONFIG_SECRET_SENTINELS[6]),
5619 "{route} body retained the configured credential"
5620 );
5621 assert!(
5622 body.contains(codewhale_config::persistence::REDACTED),
5623 "{route} body lost the redaction marker"
5624 );
5625 }
5626 }
5627
5628 // This test deliberately serializes access to process-global spillover
5629 // state while awaiting the retrieval path.
5630 #[allow(clippy::await_holding_lock)]
5631 #[tokio::test]
5632 async fn retrieved_turn_loop_spillover_is_sanitized_before_model_wire() {
5633 let _guard = crate::tools::truncate::TEST_SPILLOVER_GUARD
5634 .lock()
5635 .unwrap_or_else(|err| err.into_inner());
5636 let tmp = tempfile::tempdir().expect("tempdir");
5637 let spillover_root = tmp.path().join(".codewhale").join("tool_outputs");
5638 let prior = crate::tools::truncate::set_test_spillover_root(Some(spillover_root.clone()));
5639 struct Restore(Option<std::path::PathBuf>);
5640 impl Drop for Restore {
5641 fn drop(&mut self) {
5642 crate::tools::truncate::set_test_spillover_root(self.0.take());
5643 }
5644 }
5645 let _restore = Restore(prior);
5646
5647 let head = (0..40)
5648 .map(|_| format!("{}\n", "safe-head".repeat(100)))
5649 .collect::<String>();
5650 let tail = (0..80)
5651 .map(|_| format!("{}\n", "safe-tail".repeat(100)))
5652 .collect::<String>();
5653 let raw = format!("{head}\n{}\n{tail}", CONFIG_SECRET_SENTINELS[6]);
5654 assert!(
5655 raw.len() > crate::tools::truncate::SPILLOVER_THRESHOLD_BYTES,
5656 "fixture must enter turn-loop spillover"
5657 );
5658
5659 let mut spilled = crate::tools::spec::ToolResult::success(raw.clone());
5660 let path = crate::tools::truncate::apply_spillover(&mut spilled, "call-local-secret")
5661 .expect("turn-loop spillover");
5662 crate::tools::truncate::publish_legacy_spillover_ownership(
5663 &path,
5664 "workspace",
5665 raw.as_bytes(),
5666 )
5667 .expect("publish compatibility ownership proof");
5668 assert_eq!(path.parent(), Some(spillover_root.as_path()));
5669 assert!(
5670 std::fs::read_to_string(&path)
5671 .expect("read local spillover")
5672 .contains(CONFIG_SECRET_SENTINELS[6]),
5673 "the full raw result remains available only in the local spillover store"
5674 );
5675 assert!(
5676 !spilled.content.contains(CONFIG_SECRET_SENTINELS[6]),
5677 "middle-only secret should not be present in retained head/tail"
5678 );
5679
5680 let context = crate::tools::spec::ToolContext::new(tmp.path().to_path_buf());
5681 let retrieved = crate::tools::spec::ToolSpec::execute(
5682 &crate::tools::tool_result_retrieval::RetrieveToolResultTool,
5683 json!({
5684 "ref": "call-local-secret",
5685 "mode": "query",
5686 "query": CONFIG_SECRET_SENTINELS[6],
5687 }),
5688 &context,
5689 )
5690 .await
5691 .expect("retrieve secret-bearing local spillover slice");
5692 assert!(retrieved.content.contains(CONFIG_SECRET_SENTINELS[6]));
5693
5694 let client = client_with_config_secret_sentinels();
5695 let prepared =
5696 client.prepare_model_bound_request(request_with_tool_result(retrieved.content));
5697 let wire = serde_json::to_string(&build_chat_messages_for_request(&prepared))
5698 .expect("serialize sanitized retrieval result");
5699 assert!(!wire.contains(CONFIG_SECRET_SENTINELS[6]));
5700 assert!(wire.contains(codewhale_config::persistence::REDACTED));
5701 }
5702
5703 #[test]
5704 fn wire_adapter_does_not_persist_sessionless_sha_spillover() {
5705 let _guard = crate::tools::truncate::TEST_SPILLOVER_GUARD
5706 .lock()
5707 .unwrap_or_else(|err| err.into_inner());
5708 let tmp = tempfile::tempdir().expect("tempdir");
5709 let prior = crate::tools::truncate::set_test_spillover_root(Some(
5710 tmp.path().join(".codewhale").join("tool_outputs"),
5711 ));
5712 struct Restore(Option<std::path::PathBuf>);
5713 impl Drop for Restore {
5714 fn drop(&mut self) {
5715 crate::tools::truncate::set_test_spillover_root(self.0.take());
5716 }
5717 }
5718 let _restore = Restore(prior);
5719
5720 let client = client_with_config_secret_sentinels();
5721 let raw = format!(
5722 "{}\ncredential={}\n{}",
5723 "ordinary output ".repeat(80),
5724 CONFIG_SECRET_SENTINELS[6],
5725 "tail ".repeat(80)
5726 );
5727 assert!(raw.len() > 1024, "fixture must enter wire dedup size class");
5728 let raw_sha = crate::hashing::sha256_hex(raw.as_bytes());
5729 let prepared = client.prepare_model_bound_request(request_with_tool_result(raw));
5730 let sanitized = tool_result_content(&prepared).to_string();
5731 let sanitized_sha = crate::hashing::sha256_hex(sanitized.as_bytes());
5732
5733 let wire = build_chat_messages_for_request(&prepared);
5734 let serialized = serde_json::to_string(&wire).expect("serialize chat wire messages");
5735 assert!(!serialized.contains(CONFIG_SECRET_SENTINELS[6]));
5736
5737 let sanitized_path = crate::tools::truncate::sha_spillover_path(&sanitized_sha)
5738 .expect("sanitized spillover path");
5739 assert!(
5740 !sanitized_path.exists(),
5741 "sessionless wire fallback must not create an ownerless SHA artifact"
5742 );
5743
5744 let raw_path =
5745 crate::tools::truncate::sha_spillover_path(&raw_sha).expect("raw spillover path");
5746 assert!(
5747 !raw_path.exists(),
5748 "unsanitized tool output must never be persisted by the wire adapter"
5749 );
5750 assert!(!serialized.contains("retrieve_tool_result ref=sha:"));
5751 }
5752
5753 fn deepseek_anthropic_client(server: &MockServer) -> DeepSeekClient {
5754 let _ = rustls::crypto::ring::default_provider().install_default();
5755 let providers = ProvidersConfig {
5756 deepseek_anthropic: ProviderConfig {
5757 api_key: Some("ds-test".to_string()),
5758 base_url: Some(server.uri()),
5759 ..ProviderConfig::default()
5760 },
5761 ..ProvidersConfig::default()
5762 };
5763 DeepSeekClient::new(&Config {
5764 provider: Some("deepseek-anthropic".to_string()),
5765 providers: Some(providers),
5766 ..Config::default()
5767 })
5768 .expect("deepseek anthropic client")
5769 }
5770
5771 fn minimax_anthropic_client_with_base_url(base_url: String) -> DeepSeekClient {
5772 let _ = rustls::crypto::ring::default_provider().install_default();
5773 let providers = ProvidersConfig {
5774 minimax_anthropic: ProviderConfig {
5775 api_key: Some("minimax-test".to_string()),
5776 base_url: Some(base_url),
5777 ..ProviderConfig::default()
5778 },
5779 ..ProvidersConfig::default()
5780 };
5781 DeepSeekClient::new(&Config {
5782 provider: Some("minimax-anthropic".to_string()),
5783 providers: Some(providers),
5784 ..Config::default()
5785 })
5786 .expect("minimax anthropic client")
5787 }
5788
5789 fn zai_client_for_test() -> DeepSeekClient {
5790 let _ = rustls::crypto::ring::default_provider().install_default();
5791 let providers = ProvidersConfig {
5792 zai: ProviderConfig {
5793 api_key: Some("zai-test".to_string()),
5794 base_url: Some("https://api.z.ai/api/coding/paas/v4".to_string()),
5795 ..ProviderConfig::default()
5796 },
5797 ..ProvidersConfig::default()
5798 };
5799 DeepSeekClient::new(&Config {
5800 provider: Some("zai".to_string()),
5801 providers: Some(providers),
5802 ..Config::default()
5803 })
5804 .expect("zai client")
5805 }
5806
5807 #[tokio::test]
5808 async fn provider_request_concurrency_limiter_is_shared_across_client_clones() {
5809 let client = zai_client_for_test();
5810 assert_eq!(
5811 client.provider_request_concurrency_limit(),
5812 Some(crate::config::DEFAULT_ZAI_PROVIDER_MAX_CONCURRENCY)
5813 );
5814
5815 let clone = client.clone();
5816 let permit = client
5817 .acquire_provider_request_permit()
5818 .await
5819 .expect("zai default should install provider request limiter");
5820
5821 assert_eq!(client.active_provider_requests(), 1);
5822 assert_eq!(clone.active_provider_requests(), 1);
5823
5824 drop(permit);
5825
5826 assert_eq!(client.active_provider_requests(), 0);
5827 assert_eq!(clone.active_provider_requests(), 0);
5828 }
5829
5830 #[tokio::test]
5831 async fn provider_request_permit_lives_until_stream_is_consumed() {
5832 let client = zai_client_for_test();
5833 let permit = client
5834 .acquire_provider_request_permit()
5835 .await
5836 .expect("zai default should install provider request limiter");
5837 let stream: crate::llm_client::StreamEventBox =
5838 Box::pin(futures_util::stream::iter(vec![Ok(
5839 StreamEvent::MessageStop,
5840 )]));
5841 let mut wrapped =
5842 DeepSeekClient::hold_provider_request_permit_for_stream(stream, Some(permit));
5843
5844 assert_eq!(client.active_provider_requests(), 1);
5845 assert!(wrapped.next().await.is_some());
5846 assert!(wrapped.next().await.is_none());
5847 assert_eq!(client.active_provider_requests(), 0);
5848 }
5849
5850 #[test]
5851 fn parse_speech_audio_response_accepts_message_audio() {
5852 let encoded = general_purpose::STANDARD.encode(b"hi");
5853 let payload = json!({
5854 "choices": [{
5855 "message": {
5856 "audio": {
5857 "data": encoded,
5858 "transcript": "hi"
5859 }
5860 }
5861 }]
5862 });
5863
5864 let (audio, transcript) = parse_speech_audio_response(&payload).unwrap();
5865 assert_eq!(audio, b"hi");
5866 assert_eq!(transcript.as_deref(), Some("hi"));
5867 }
5868
5869 #[test]
5870 fn parse_speech_audio_response_accepts_data_uri() {
5871 let encoded = general_purpose::STANDARD.encode(b"wav");
5872 let payload = json!({
5873 "audio": {
5874 "data": format!("data:audio/wav;base64,{encoded}")
5875 }
5876 });
5877
5878 let (audio, transcript) = parse_speech_audio_response(&payload).unwrap();
5879 assert_eq!(audio, b"wav");
5880 assert_eq!(transcript, None);
5881 }
5882
5883 #[test]
5884 fn speech_synthesis_body_omits_user_message_without_instruction() {
5885 let body =
5886 build_speech_synthesis_body("mimo-v2.5-tts", "hello", None, json!({"format": "wav"}));
5887 let messages = body["messages"].as_array().expect("messages array");
5888
5889 assert_eq!(messages.len(), 1);
5890 assert_eq!(messages[0]["role"], "assistant");
5891 assert_eq!(messages[0]["content"], "hello");
5892 assert!(
5893 messages
5894 .iter()
5895 .all(|message| message["content"].as_str() != Some(""))
5896 );
5897 }
5898
5899 #[test]
5900 fn speech_synthesis_body_ignores_blank_instruction() {
5901 let body = build_speech_synthesis_body(
5902 "mimo-v2.5-tts",
5903 "hello",
5904 Some(" \t\n "),
5905 json!({"format": "wav"}),
5906 );
5907 let messages = body["messages"].as_array().expect("messages array");
5908
5909 assert_eq!(messages.len(), 1);
5910 assert_eq!(messages[0]["role"], "assistant");
5911 }
5912
5913 #[test]
5914 fn speech_synthesis_body_includes_non_empty_instruction_first() {
5915 let body = build_speech_synthesis_body(
5916 "mimo-v2.5-tts-voicedesign",
5917 "hello",
5918 Some("warm and calm"),
5919 json!({"format": "wav"}),
5920 );
5921 let messages = body["messages"].as_array().expect("messages array");
5922
5923 assert_eq!(messages.len(), 2);
5924 assert_eq!(messages[0]["role"], "user");
5925 assert_eq!(messages[0]["content"], "warm and calm");
5926 assert_eq!(messages[1]["role"], "assistant");
5927 assert_eq!(messages[1]["content"], "hello");
5928 }
5929
5930 #[test]
5931 fn tool_name_roundtrip_dot() {
5932 let original = "multi_tool_use.parallel";
5933 let encoded = to_api_tool_name(original);
5934 assert_eq!(encoded, "multi_tool_use-x00002E-parallel");
5935 let decoded = from_api_tool_name(&encoded);
5936 assert_eq!(decoded, original);
5937 }
5938
5939 #[test]
5940 fn tool_name_decode_mangled_dot_prefix() {
5941 let mangled = "multi_tool_use.x00002E-parallel";
5942 let decoded = from_api_tool_name(mangled);
5943 assert_eq!(decoded, "multi_tool_use..parallel");
5944 }
5945
5946 #[test]
5947 fn tool_name_decode_bare_hex_no_trailing_dash() {
5948 let mangled = "foo_x00002Ebar";
5949 let decoded = from_api_tool_name(mangled);
5950 assert_eq!(decoded, "foo_.bar");
5951 }
5952
5953 #[test]
5954 fn tool_name_bare_hex_preserves_alnum() {
5955 let input = "foox000041bar";
5956 let decoded = from_api_tool_name(input);
5957 assert_eq!(decoded, input);
5958 }
5959
5960 #[test]
5961 fn tool_name_bare_hex_preserves_underscore() {
5962 let input = "foox00005Fbar";
5963 let decoded = from_api_tool_name(input);
5964 assert_eq!(decoded, input);
5965 }
5966
5967 #[test]
5968 fn tool_name_roundtrip_colon() {
5969 let original = "mcp__server:tool_name";
5970 let encoded = to_api_tool_name(original);
5971 let decoded = from_api_tool_name(&encoded);
5972 assert_eq!(decoded, original);
5973 }
5974
5975 #[test]
5976 fn api_url_handles_default_v1_and_beta_base_urls() {
5977 assert_eq!(
5978 api_url("https://api.deepseek.com", "chat/completions"),
5979 "https://api.deepseek.com/v1/chat/completions"
5980 );
5981 assert_eq!(
5982 api_url("https://api.deepseek.com/v1", "chat/completions"),
5983 "https://api.deepseek.com/v1/chat/completions"
5984 );
5985 // Non-beta paths from a /beta base URL route to /v1.
5986 // Only paths with an explicit beta/ prefix use the beta surface.
5987 assert_eq!(
5988 api_url("https://api.deepseek.com/beta", "chat/completions"),
5989 "https://api.deepseek.com/v1/chat/completions"
5990 );
5991 assert_eq!(
5992 api_url(
5993 "https://openai-compatible.example/api/coding/paas/v4",
5994 "chat/completions"
5995 ),
5996 "https://openai-compatible.example/api/coding/paas/v4/chat/completions"
5997 );
5998 }
5999
6000 #[test]
6001 fn api_url_routes_beta_paths_from_any_deepseek_base() {
6002 assert_eq!(
6003 api_url("https://api.deepseek.com", "beta/completions"),
6004 "https://api.deepseek.com/beta/completions"
6005 );
6006 assert_eq!(
6007 api_url("https://api.deepseek.com/v1", "beta/completions"),
6008 "https://api.deepseek.com/beta/completions"
6009 );
6010 assert_eq!(
6011 api_url("https://api.deepseek.com/beta", "beta/completions"),
6012 "https://api.deepseek.com/beta/completions"
6013 );
6014 }
6015
6016 #[test]
6017 fn api_url_routes_models_and_non_beta_paths_to_v1() {
6018 // The /models endpoint only exists at /v1/models, never at
6019 // /beta/models. Non-beta paths from a /beta base URL must
6020 // still route to /v1.
6021 assert_eq!(
6022 api_url("https://api.deepseek.com", "models"),
6023 "https://api.deepseek.com/v1/models"
6024 );
6025 assert_eq!(
6026 api_url("https://api.deepseek.com/v1", "models"),
6027 "https://api.deepseek.com/v1/models"
6028 );
6029 assert_eq!(
6030 api_url("https://api.deepseek.com/beta", "models"),
6031 "https://api.deepseek.com/v1/models"
6032 );
6033 assert_eq!(
6034 api_url("https://api.minimax.io/anthropic", "models"),
6035 "https://api.minimax.io/anthropic/v1/models"
6036 );
6037 assert_eq!(
6038 api_url("https://api.minimaxi.com/anthropic", "models"),
6039 "https://api.minimaxi.com/anthropic/v1/models"
6040 );
6041 // explicit v<N> versions other than /v1 should be preserved
6042 assert_eq!(
6043 api_url(
6044 "https://openai-compatible.example/api/coding/paas/v4",
6045 "models"
6046 ),
6047 "https://openai-compatible.example/api/coding/paas/v4/models"
6048 );
6049 }
6050
6051 #[test]
6052 fn default_headers_include_custom_headers_when_configured() {
6053 let mut extra = HashMap::new();
6054 extra.insert("X-Model-Provider-Id".to_string(), "tongyi".to_string());
6055 let headers = DeepSeekClient::default_headers("sk-test", &extra).expect("headers");
6056 assert_eq!(
6057 headers
6058 .get("x-model-provider-id")
6059 .and_then(|value| value.to_str().ok()),
6060 Some("tongyi")
6061 );
6062 }
6063
6064 #[test]
6065 fn default_headers_ignore_blank_custom_headers() {
6066 let mut extra = HashMap::new();
6067 extra.insert("X-Blank".to_string(), " ".to_string());
6068 let headers = DeepSeekClient::default_headers("sk-test", &extra).expect("headers");
6069 assert!(headers.get("x-blank").is_none());
6070 }
6071
6072 #[test]
6073 fn disabled_auth_strips_every_auth_header_dialect_at_client_sink() {
6074 let mut extra = HashMap::new();
6075 extra.insert(
6076 "aUtHoRiZaTiOn".to_string(),
6077 "Bearer configured-secret".to_string(),
6078 );
6079 extra.insert("X-API-Key".to_string(), "configured-x-key".to_string());
6080 extra.insert("Api-Key".to_string(), "configured-key".to_string());
6081 extra.insert(
6082 "Proxy-Authorization".to_string(),
6083 "Basic configured-proxy-secret".to_string(),
6084 );
6085 extra.insert(
6086 "X-Auth-Token".to_string(),
6087 "configured-auth-token".to_string(),
6088 );
6089 extra.insert(
6090 "X-Access-Token".to_string(),
6091 "configured-access-token".to_string(),
6092 );
6093 extra.insert(
6094 "X-Goog-Api-Key".to_string(),
6095 "configured-google-key".to_string(),
6096 );
6097 extra.insert("Cookie".to_string(), "session=secret".to_string());
6098 extra.insert("X-Route-Metadata".to_string(), "safe".to_string());
6099
6100 let headers = DeepSeekClient::default_headers_for_provider_with_auth_disabled(
6101 "generated-secret",
6102 &extra,
6103 ApiProvider::Deepseek,
6104 crate::config::DEFAULT_DEEPSEEK_BASE_URL,
6105 )
6106 .expect("headers");
6107
6108 for name in [
6109 "authorization",
6110 "x-api-key",
6111 "api-key",
6112 "proxy-authorization",
6113 "x-auth-token",
6114 "x-access-token",
6115 "x-goog-api-key",
6116 "cookie",
6117 ] {
6118 assert!(headers.get(name).is_none(), "disabled auth leaked {name}");
6119 }
6120 assert_eq!(
6121 headers
6122 .get("x-route-metadata")
6123 .and_then(|value| value.to_str().ok()),
6124 Some("safe")
6125 );
6126 }
6127
6128 #[test]
6129 fn build_http_client_accepts_default_tls_verification() {
6130 let client = DeepSeekClient::build_http_client(
6131 "sk-test",
6132 &HashMap::new(),
6133 ApiProvider::Deepseek,
6134 crate::config::DEFAULT_DEEPSEEK_BASE_URL,
6135 );
6136
6137 assert!(client.is_ok());
6138 }
6139
6140 #[test]
6141 fn client_new_rejects_provider_scoped_tls_skip_verify() {
6142 let mut providers = crate::config::ProvidersConfig::default();
6143 providers.openai.api_key = Some("sk-test".to_string());
6144 providers.openai.base_url = Some(crate::config::DEFAULT_OPENAI_BASE_URL.to_string());
6145 providers.openai.insecure_skip_tls_verify = Some(true);
6146 let config = Config {
6147 provider: Some("openai".to_string()),
6148 providers: Some(providers),
6149 ..Config::default()
6150 };
6151 assert!(config.insecure_skip_tls_verify());
6152
6153 let err = match DeepSeekClient::new(&config) {
6154 Ok(_) => panic!("tls skip verify should be rejected"),
6155 Err(err) => err,
6156 };
6157 let message = err.to_string();
6158 assert!(message.contains("cannot be disabled"));
6159 assert!(message.contains("SSL_CERT_FILE"));
6160 }
6161
6162 #[test]
6163 fn client_stream_idle_timeout_uses_tui_config() {
6164 let client = DeepSeekClient::new(&Config {
6165 api_key: Some("sk-test".to_string()),
6166 tui: Some(crate::config::TuiConfig {
6167 stream_chunk_timeout_secs: Some(777),
6168 ..crate::config::TuiConfig::default()
6169 }),
6170 ..Config::default()
6171 })
6172 .expect("client");
6173
6174 assert_eq!(client.stream_idle_timeout, Duration::from_secs(777));
6175 }
6176
6177 #[test]
6178 fn xiaomi_mimo_token_plan_endpoint_uses_api_key_header() {
6179 let headers = DeepSeekClient::default_headers_for_provider(
6180 "tp-test",
6181 &HashMap::new(),
6182 ApiProvider::XiaomiMimo,
6183 crate::config::DEFAULT_XIAOMI_MIMO_BASE_URL,
6184 )
6185 .expect("headers");
6186
6187 assert_eq!(
6188 headers.get("api-key").and_then(|value| value.to_str().ok()),
6189 Some("tp-test")
6190 );
6191 assert!(
6192 headers.get(AUTHORIZATION).is_none(),
6193 "Token Plan requires api-key instead of Authorization Bearer"
6194 );
6195 }
6196
6197 #[test]
6198 fn xiaomi_mimo_tp_key_uses_api_key_header_with_custom_base_url() {
6199 let mut extra = HashMap::new();
6200 extra.insert("api-key".to_string(), "wrong".to_string());
6201 extra.insert("Authorization".to_string(), "Bearer wrong".to_string());
6202 let headers = DeepSeekClient::default_headers_for_provider(
6203 "tp-custom",
6204 &extra,
6205 ApiProvider::XiaomiMimo,
6206 "https://proxy.example.test/mimo/v1",
6207 )
6208 .expect("headers");
6209
6210 assert_eq!(
6211 headers.get("api-key").and_then(|value| value.to_str().ok()),
6212 Some("tp-custom")
6213 );
6214 assert!(
6215 headers.get(AUTHORIZATION).is_none(),
6216 "tp-* Token Plan keys should use api-key auth even through custom gateways"
6217 );
6218 }
6219
6220 #[test]
6221 fn openrouter_uses_bearer_header_after_mimo_token_plan_context() {
6222 let mut extra = HashMap::new();
6223 extra.insert("api-key".to_string(), "wrong".to_string());
6224 let headers = DeepSeekClient::default_headers_for_provider(
6225 "sk-or-test",
6226 &extra,
6227 ApiProvider::Openrouter,
6228 crate::config::DEFAULT_OPENROUTER_BASE_URL,
6229 )
6230 .expect("headers");
6231
6232 assert_eq!(
6233 headers
6234 .get(AUTHORIZATION)
6235 .and_then(|value| value.to_str().ok()),
6236 Some("Bearer sk-or-test")
6237 );
6238 assert!(
6239 headers.get("api-key").is_none(),
6240 "OpenRouter must not inherit Xiaomi MiMo's api-key header dialect"
6241 );
6242 }
6243
6244 #[test]
6245 fn siliconflow_cn_uses_bearer_header_and_pins_content_type() {
6246 let mut extra = HashMap::new();
6247 extra.insert("Authorization".to_string(), "Bearer wrong".to_string());
6248 extra.insert("Content-Type".to_string(), "text/plain".to_string());
6249 let headers = DeepSeekClient::default_headers_for_provider(
6250 "sf-cn-test",
6251 &extra,
6252 ApiProvider::SiliconflowCn,
6253 crate::config::DEFAULT_SILICONFLOW_CN_BASE_URL,
6254 )
6255 .expect("headers");
6256
6257 assert_eq!(
6258 headers
6259 .get(AUTHORIZATION)
6260 .and_then(|value| value.to_str().ok()),
6261 Some("Bearer sf-cn-test")
6262 );
6263 assert_eq!(
6264 headers
6265 .get(CONTENT_TYPE)
6266 .and_then(|value| value.to_str().ok()),
6267 Some("application/json")
6268 );
6269 assert!(headers.get("api-key").is_none());
6270 }
6271
6272 #[test]
6273 fn tokenhub_openai_compatible_route_uses_bearer_header() {
6274 let mut extra = HashMap::new();
6275 extra.insert("api-key".to_string(), "wrong".to_string());
6276 extra.insert("x-api-key".to_string(), "wrong".to_string());
6277 let headers = DeepSeekClient::default_headers_for_provider(
6278 "tokenhub-test",
6279 &extra,
6280 ApiProvider::Openai,
6281 "https://tokenhub.tencentmaas.com/v1",
6282 )
6283 .expect("headers");
6284
6285 assert_eq!(
6286 headers
6287 .get(AUTHORIZATION)
6288 .and_then(|value| value.to_str().ok()),
6289 Some("Bearer tokenhub-test")
6290 );
6291 assert!(headers.get("api-key").is_none());
6292 assert!(headers.get("x-api-key").is_none());
6293 }
6294
6295 #[test]
6296 fn deepseek_anthropic_uses_anthropic_header_dialect() {
6297 let mut extra = HashMap::new();
6298 extra.insert("Authorization".to_string(), "Bearer wrong".to_string());
6299 extra.insert("api-key".to_string(), "wrong".to_string());
6300 let headers = DeepSeekClient::default_headers_for_provider(
6301 "ds-test",
6302 &extra,
6303 ApiProvider::DeepseekAnthropic,
6304 crate::config::DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL,
6305 )
6306 .expect("headers");
6307
6308 assert_eq!(
6309 headers
6310 .get("x-api-key")
6311 .and_then(|value| value.to_str().ok()),
6312 Some("ds-test")
6313 );
6314 assert_eq!(
6315 headers
6316 .get("anthropic-version")
6317 .and_then(|value| value.to_str().ok()),
6318 Some("2023-06-01")
6319 );
6320 assert!(
6321 headers.get(AUTHORIZATION).is_none(),
6322 "Anthropic-compatible DeepSeek route must not use Bearer auth"
6323 );
6324 assert!(
6325 headers.get("api-key").is_none(),
6326 "Anthropic-compatible DeepSeek route must not inherit MiMo auth headers"
6327 );
6328 }
6329
6330 #[test]
6331 fn minimax_anthropic_uses_anthropic_header_dialect() {
6332 let headers = DeepSeekClient::default_headers_for_provider(
6333 "minimax-test",
6334 &HashMap::new(),
6335 ApiProvider::MinimaxAnthropic,
6336 crate::config::DEFAULT_MINIMAX_ANTHROPIC_BASE_URL,
6337 )
6338 .expect("headers");
6339
6340 assert_eq!(
6341 headers
6342 .get("x-api-key")
6343 .and_then(|value| value.to_str().ok()),
6344 Some("minimax-test")
6345 );
6346 assert_eq!(
6347 headers
6348 .get("anthropic-version")
6349 .and_then(|value| value.to_str().ok()),
6350 Some("2023-06-01")
6351 );
6352 assert!(headers.get(AUTHORIZATION).is_none());
6353 }
6354
6355 #[test]
6356 fn openmodel_uses_bearer_auth_with_anthropic_version() {
6357 let mut extra = HashMap::new();
6358 extra.insert("Authorization".to_string(), "Bearer wrong".to_string());
6359 extra.insert("api-key".to_string(), "wrong".to_string());
6360 extra.insert("x-api-key".to_string(), "wrong".to_string());
6361 let headers = DeepSeekClient::default_headers_for_provider(
6362 "om-test",
6363 &extra,
6364 ApiProvider::Openmodel,
6365 crate::config::DEFAULT_OPENMODEL_BASE_URL,
6366 )
6367 .expect("headers");
6368
6369 assert_eq!(
6370 headers
6371 .get(AUTHORIZATION)
6372 .and_then(|value| value.to_str().ok()),
6373 Some("Bearer om-test")
6374 );
6375 assert_eq!(
6376 headers
6377 .get("anthropic-version")
6378 .and_then(|value| value.to_str().ok()),
6379 Some("2023-06-01")
6380 );
6381 assert!(
6382 headers.get("x-api-key").is_none(),
6383 "OpenModel uses Bearer auth so /v1/models and /v1/messages share one client"
6384 );
6385 assert!(
6386 headers.get("api-key").is_none(),
6387 "OpenModel Messages route must not inherit MiMo auth headers"
6388 );
6389 }
6390
6391 #[tokio::test]
6392 async fn deepseek_anthropic_translate_uses_messages_endpoint() {
6393 let server = MockServer::start().await;
6394 Mock::given(method("POST"))
6395 .and(path("/v1/messages"))
6396 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
6397 "id": "msg_1",
6398 "type": "message",
6399 "role": "assistant",
6400 "content": [{"type": "text", "text": "Hola"}],
6401 "model": "deepseek-chat",
6402 "stop_reason": "end_turn",
6403 "stop_sequence": null,
6404 "usage": {"input_tokens": 3, "output_tokens": 1}
6405 })))
6406 .expect(1)
6407 .mount(&server)
6408 .await;
6409
6410 let client = deepseek_anthropic_client(&server);
6411 let translated = client
6412 .translate("Hello", "deepseek-chat", "Spanish")
6413 .await
6414 .expect("translation succeeds");
6415
6416 assert_eq!(translated, "Hola");
6417 let requests = server.received_requests().await.expect("recorded requests");
6418 assert_eq!(requests.len(), 1);
6419 let body: Value = serde_json::from_slice(&requests[0].body).expect("json body");
6420 assert_eq!(
6421 body.get("model").and_then(Value::as_str),
6422 Some("deepseek-chat"),
6423 "custom Messages endpoints own their model ids: {body}"
6424 );
6425 assert_eq!(
6426 body.pointer("/messages/0/role").and_then(Value::as_str),
6427 Some("user")
6428 );
6429 assert_eq!(
6430 body.pointer("/messages/0/content/0/text")
6431 .and_then(Value::as_str),
6432 Some("Hello")
6433 );
6434 assert!(
6435 body.get("thinking").is_none(),
6436 "translation disables thinking: {body}"
6437 );
6438 assert!(
6439 body.get("system")
6440 .and_then(Value::as_str)
6441 .is_some_and(|system| system.contains("Spanish")),
6442 "target language should be in system prompt: {body}"
6443 );
6444 }
6445
6446 #[tokio::test]
6447 async fn deepseek_anthropic_health_check_skips_models_probe() {
6448 let server = MockServer::start().await;
6449 let client = deepseek_anthropic_client(&server);
6450
6451 assert!(client.health_check().await.expect("health check"));
6452 assert!(!provider_api_key_verification_is_observed(
6453 ApiProvider::DeepseekAnthropic
6454 ));
6455 let requests = server.received_requests().await.expect("recorded requests");
6456 assert!(
6457 requests.is_empty(),
6458 "DeepSeek Anthropic-compatible route must not probe /models"
6459 );
6460 }
6461
6462 #[tokio::test]
6463 async fn minimax_anthropic_health_check_uses_models_endpoint() {
6464 let server = MockServer::start().await;
6465 Mock::given(method("GET"))
6466 .and(path("/anthropic/v1/models"))
6467 .and(header("x-api-key", "minimax-test"))
6468 .and(header("anthropic-version", "2023-06-01"))
6469 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"data": []})))
6470 .expect(1)
6471 .mount(&server)
6472 .await;
6473 let client = minimax_anthropic_client_with_base_url(format!("{}/anthropic", server.uri()));
6474
6475 assert!(client.health_check().await.expect("health check"));
6476 }
6477
6478 #[tokio::test]
6479 async fn minimax_anthropic_request_uses_messages_endpoint() {
6480 let server = MockServer::start().await;
6481 Mock::given(method("POST"))
6482 .and(path("/anthropic/v1/messages"))
6483 .and(header("x-api-key", "minimax-test"))
6484 .and(header("anthropic-version", "2023-06-01"))
6485 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
6486 "id": "msg_1",
6487 "type": "message",
6488 "role": "assistant",
6489 "content": [{"type": "text", "text": "ok"}],
6490 "model": "MiniMax-M3",
6491 "stop_reason": "end_turn",
6492 "stop_sequence": null,
6493 "usage": {"input_tokens": 3, "output_tokens": 1}
6494 })))
6495 .expect(1)
6496 .mount(&server)
6497 .await;
6498
6499 let mut client = minimax_anthropic_client_with_base_url(
6500 crate::config::DEFAULT_MINIMAX_ANTHROPIC_BASE_URL.to_string(),
6501 );
6502 client.test_messages_transport_base_url = Some(format!("{}/anthropic", server.uri()));
6503 let response = client
6504 .create_message(MessageRequest {
6505 model: "MiniMax-M3".to_string(),
6506 messages: vec![Message {
6507 role: "user".to_string(),
6508 content: vec![ContentBlock::Text {
6509 text: "hello".to_string(),
6510 cache_control: None,
6511 }],
6512 }],
6513 max_tokens: 32,
6514 system: None,
6515 tools: None,
6516 tool_choice: None,
6517 metadata: None,
6518 thinking: None,
6519 reasoning_effort: Some("off".to_string()),
6520 stream: Some(false),
6521 temperature: None,
6522 top_p: None,
6523 })
6524 .await
6525 .expect("message succeeds");
6526
6527 assert_eq!(response.content.len(), 1);
6528 let requests = server.received_requests().await.expect("recorded requests");
6529 let body: Value = serde_json::from_slice(&requests[0].body).expect("request JSON");
6530 assert_eq!(
6531 body.pointer("/thinking/type").and_then(Value::as_str),
6532 Some("disabled")
6533 );
6534 assert!(body.get("output_config").is_none(), "{body}");
6535 }
6536
6537 #[tokio::test]
6538 async fn deepseek_anthropic_fim_fails_without_http_request() {
6539 let server = MockServer::start().await;
6540 let client = deepseek_anthropic_client(&server);
6541
6542 let err = client
6543 .fim_completion("deepseek-chat", "fn main() {", "}", 16)
6544 .await
6545 .expect_err("FIM is unsupported");
6546 let message = err.to_string();
6547 assert!(
6548 message.contains("FIM completion is not supported"),
6549 "{message}"
6550 );
6551 assert!(message.contains("no proven FIM wire contract"), "{message}");
6552 let requests = server.received_requests().await.expect("recorded requests");
6553 assert!(
6554 requests.is_empty(),
6555 "unsupported FIM should fail locally before any HTTP call"
6556 );
6557 }
6558
6559 #[test]
6560 fn custom_api_key_header_is_allowed_without_primary_provider_key() {
6561 let mut extra = HashMap::new();
6562 extra.insert("api-key".to_string(), "gateway-key".to_string());
6563 let headers = DeepSeekClient::default_headers_for_provider(
6564 "",
6565 &extra,
6566 ApiProvider::Openai,
6567 "https://gateway.example.test/v1",
6568 )
6569 .expect("headers");
6570
6571 assert_eq!(
6572 headers.get("api-key").and_then(|value| value.to_str().ok()),
6573 Some("gateway-key")
6574 );
6575 assert!(headers.get(AUTHORIZATION).is_none());
6576 }
6577
6578 #[test]
6579 fn xiaomi_mimo_pay_as_you_go_endpoint_keeps_bearer_header() {
6580 let headers = DeepSeekClient::default_headers_for_provider(
6581 "sk-test",
6582 &HashMap::new(),
6583 ApiProvider::XiaomiMimo,
6584 crate::config::XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL,
6585 )
6586 .expect("headers");
6587
6588 assert_eq!(
6589 headers
6590 .get(AUTHORIZATION)
6591 .and_then(|value| value.to_str().ok()),
6592 Some("Bearer sk-test")
6593 );
6594 assert!(headers.get("api-key").is_none());
6595 }
6596
6597 #[test]
6598 fn chat_messages_keep_current_turn_reasoning_content() {
6599 let message = Message {
6600 role: "assistant".to_string(),
6601 content: vec![
6602 ContentBlock::Thinking {
6603 signature: None,
6604 thinking: "plan".to_string(),
6605 },
6606 ContentBlock::Text {
6607 text: "done".to_string(),
6608 cache_control: None,
6609 },
6610 ],
6611 };
6612 let out = build_chat_messages(None, &[message], "deepseek-v4-pro");
6613 let assistant = out
6614 .iter()
6615 .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant"))
6616 .expect("assistant message");
6617 assert_eq!(
6618 assistant.get("content").and_then(Value::as_str),
6619 Some("done")
6620 );
6621 assert_eq!(
6622 assistant.get("reasoning_content").and_then(Value::as_str),
6623 Some("plan"),
6624 "thinking-mode models keep reasoning_content while still in the current turn"
6625 );
6626 }
6627
6628 #[test]
6629 fn generic_openai_provider_drops_reasoning_content_for_non_deepseek_models() {
6630 // #1542 intent (narrowed by #1739/#1694): a *genuine non-DeepSeek*
6631 // model on the generic openai provider must not carry DeepSeek-only
6632 // `reasoning_content`. A DeepSeek reasoning model on the openai
6633 // provider (DeepSeek-compatible endpoint) is now covered separately
6634 // and DOES replay reasoning_content — see
6635 // `deepseek_model_on_openai_provider_still_replays_reasoning_content`.
6636 let request = MessageRequest {
6637 model: "qwen3-coder".to_string(),
6638 messages: vec![Message {
6639 role: "assistant".to_string(),
6640 content: vec![
6641 ContentBlock::Thinking {
6642 signature: None,
6643 thinking: "plan".to_string(),
6644 },
6645 ContentBlock::Text {
6646 text: "done".to_string(),
6647 cache_control: None,
6648 },
6649 ],
6650 }],
6651 max_tokens: 16,
6652 system: None,
6653 tools: None,
6654 tool_choice: None,
6655 metadata: None,
6656 thinking: None,
6657 reasoning_effort: Some("max".to_string()),
6658 stream: None,
6659 temperature: None,
6660 top_p: None,
6661 };
6662
6663 let openai = build_chat_messages_for_request_and_provider(&request, ApiProvider::Openai);
6664 let generic_assistant = openai
6665 .iter()
6666 .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant"))
6667 .expect("assistant message");
6668 assert_eq!(
6669 generic_assistant.get("content").and_then(Value::as_str),
6670 Some("done")
6671 );
6672 assert!(
6673 generic_assistant.get("reasoning_content").is_none(),
6674 "generic OpenAI-compatible providers reject DeepSeek-only reasoning_content (#1542)"
6675 );
6676 }
6677
6678 #[test]
6679 fn chat_messages_replay_tool_round_reasoning_before_new_user_turn() {
6680 let messages = vec![
6681 Message {
6682 role: "user".to_string(),
6683 content: vec![ContentBlock::Text {
6684 text: "Need the date".to_string(),
6685 cache_control: None,
6686 }],
6687 },
6688 Message {
6689 role: "assistant".to_string(),
6690 content: vec![
6691 ContentBlock::Thinking {
6692 signature: None,
6693 thinking: "Need to call a tool".to_string(),
6694 },
6695 ContentBlock::ToolUse {
6696 id: "tool-1".to_string(),
6697 name: "get_date".to_string(),
6698 input: json!({}),
6699 caller: None,
6700 },
6701 ],
6702 },
6703 Message {
6704 role: "user".to_string(),
6705 content: vec![ContentBlock::ToolResult {
6706 tool_use_id: "tool-1".to_string(),
6707 content: "2026-04-23".to_string(),
6708 is_error: None,
6709 content_blocks: None,
6710 }],
6711 },
6712 ];
6713 let out = build_chat_messages(None, &messages, "deepseek-v4-pro");
6714 let tool_assistant = out
6715 .iter()
6716 .find(|value| {
6717 value.get("role").and_then(Value::as_str) == Some("assistant")
6718 && value.get("tool_calls").is_some()
6719 })
6720 .expect("tool-call assistant message");
6721 assert_eq!(
6722 tool_assistant
6723 .get("reasoning_content")
6724 .and_then(Value::as_str),
6725 Some("Need to call a tool"),
6726 "thinking-mode tool sub-turns must replay reasoning_content until the tool chain finishes"
6727 );
6728 }
6729
6730 #[test]
6731 fn chat_messages_replay_prior_tool_round_reasoning_after_new_user_turn() {
6732 let messages = vec![
6733 Message {
6734 role: "user".to_string(),
6735 content: vec![ContentBlock::Text {
6736 text: "Need the date".to_string(),
6737 cache_control: None,
6738 }],
6739 },
6740 Message {
6741 role: "assistant".to_string(),
6742 content: vec![
6743 ContentBlock::Thinking {
6744 signature: None,
6745 thinking: "Need to call a tool".to_string(),
6746 },
6747 ContentBlock::ToolUse {
6748 id: "tool-1".to_string(),
6749 name: "get_date".to_string(),
6750 input: json!({}),
6751 caller: None,
6752 },
6753 ],
6754 },
6755 Message {
6756 role: "user".to_string(),
6757 content: vec![ContentBlock::ToolResult {
6758 tool_use_id: "tool-1".to_string(),
6759 content: "2026-04-23".to_string(),
6760 is_error: None,
6761 content_blocks: None,
6762 }],
6763 },
6764 Message {
6765 role: "assistant".to_string(),
6766 content: vec![ContentBlock::Text {
6767 text: "It is 2026-04-23.".to_string(),
6768 cache_control: None,
6769 }],
6770 },
6771 Message {
6772 role: "user".to_string(),
6773 content: vec![ContentBlock::Text {
6774 text: "Thanks. Next question.".to_string(),
6775 cache_control: None,
6776 }],
6777 },
6778 ];
6779 let out = build_chat_messages(None, &messages, "deepseek-v4-pro");
6780 let tool_assistant = out
6781 .iter()
6782 .find(|value| {
6783 value.get("role").and_then(Value::as_str) == Some("assistant")
6784 && value.get("tool_calls").is_some()
6785 })
6786 .expect("tool-call assistant message");
6787 assert_eq!(
6788 tool_assistant
6789 .get("reasoning_content")
6790 .and_then(Value::as_str),
6791 Some("Need to call a tool"),
6792 "tool-call reasoning_content must be replayed across later user turns"
6793 );
6794 }
6795
6796 #[test]
6797 fn chat_messages_keep_prior_non_tool_reasoning_after_new_user_turn() {
6798 // The serialized JSON for a stored assistant message MUST be a pure
6799 // function of that message — never of what comes after it. DeepSeek's
6800 // prompt cache hashes the leading bytes of every request; flipping
6801 // `reasoning_content` on/off across turns rewrites historical bytes
6802 // and busts the prefix cache from that message onwards. (#583)
6803 let messages = vec![
6804 Message {
6805 role: "user".to_string(),
6806 content: vec![ContentBlock::Text {
6807 text: "Explain it".to_string(),
6808 cache_control: None,
6809 }],
6810 },
6811 Message {
6812 role: "assistant".to_string(),
6813 content: vec![
6814 ContentBlock::Thinking {
6815 signature: None,
6816 thinking: "Internal explanation plan".to_string(),
6817 },
6818 ContentBlock::Text {
6819 text: "Final answer".to_string(),
6820 cache_control: None,
6821 },
6822 ],
6823 },
6824 Message {
6825 role: "user".to_string(),
6826 content: vec![ContentBlock::Text {
6827 text: "Next question".to_string(),
6828 cache_control: None,
6829 }],
6830 },
6831 ];
6832
6833 let out = build_chat_messages(None, &messages, "deepseek-v4-pro");
6834 let assistant = out
6835 .iter()
6836 .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant"))
6837 .expect("assistant message");
6838
6839 assert_eq!(
6840 assistant.get("content").and_then(Value::as_str),
6841 Some("Final answer")
6842 );
6843 assert_eq!(
6844 assistant.get("reasoning_content").and_then(Value::as_str),
6845 Some("Internal explanation plan"),
6846 "reasoning_content must be preserved across follow-up user turns to keep DeepSeek's prefix cache warm"
6847 );
6848 }
6849
6850 #[test]
6851 fn chat_messages_assistant_json_is_byte_stable_across_follow_up_user_turn() {
6852 // Direct prefix-cache regression: the JSON for the assistant message
6853 // built on turn N must equal the JSON for the same assistant message
6854 // built on turn N+1, after a new user message has been appended.
6855 let assistant = Message {
6856 role: "assistant".to_string(),
6857 content: vec![
6858 ContentBlock::Thinking {
6859 signature: None,
6860 thinking: "I should explain step by step.".to_string(),
6861 },
6862 ContentBlock::Text {
6863 text: "Here is the explanation.".to_string(),
6864 cache_control: None,
6865 },
6866 ],
6867 };
6868 let user_initial = Message {
6869 role: "user".to_string(),
6870 content: vec![ContentBlock::Text {
6871 text: "Explain it".to_string(),
6872 cache_control: None,
6873 }],
6874 };
6875 let user_follow_up = Message {
6876 role: "user".to_string(),
6877 content: vec![ContentBlock::Text {
6878 text: "Next question".to_string(),
6879 cache_control: None,
6880 }],
6881 };
6882
6883 let turn_n = build_chat_messages(
6884 None,
6885 &[user_initial.clone(), assistant.clone()],
6886 "deepseek-v4-pro",
6887 );
6888 let turn_n_plus_1 = build_chat_messages(
6889 None,
6890 &[user_initial, assistant, user_follow_up],
6891 "deepseek-v4-pro",
6892 );
6893
6894 let assistant_n = turn_n
6895 .iter()
6896 .find(|v| v.get("role").and_then(Value::as_str) == Some("assistant"))
6897 .expect("assistant present in turn N");
6898 let assistant_n1 = turn_n_plus_1
6899 .iter()
6900 .find(|v| v.get("role").and_then(Value::as_str) == Some("assistant"))
6901 .expect("assistant present in turn N+1");
6902
6903 assert_eq!(
6904 assistant_n, assistant_n1,
6905 "assistant message JSON must be byte-identical across turns or DeepSeek's prefix cache breaks"
6906 );
6907 }
6908
6909 #[test]
6910 fn chat_messages_allow_tool_round_without_reasoning_when_thinking_disabled() {
6911 let request = MessageRequest {
6912 model: "deepseek-v4-pro".to_string(),
6913 messages: vec![
6914 Message {
6915 role: "assistant".to_string(),
6916 content: vec![ContentBlock::ToolUse {
6917 id: "call-no-thinking".to_string(),
6918 name: "read_file".to_string(),
6919 input: json!({"path": "Cargo.toml"}),
6920 caller: None,
6921 }],
6922 },
6923 Message {
6924 role: "user".to_string(),
6925 content: vec![ContentBlock::ToolResult {
6926 tool_use_id: "call-no-thinking".to_string(),
6927 content: "workspace manifest".to_string(),
6928 is_error: None,
6929 content_blocks: None,
6930 }],
6931 },
6932 ],
6933 max_tokens: 1024,
6934 system: None,
6935 tools: None,
6936 tool_choice: None,
6937 metadata: None,
6938 thinking: None,
6939 reasoning_effort: Some("off".to_string()),
6940 stream: None,
6941 temperature: None,
6942 top_p: None,
6943 };
6944
6945 let out = build_chat_messages_for_request(&request);
6946 assert!(
6947 out.iter().any(
6948 |value| value.get("role").and_then(Value::as_str) == Some("assistant")
6949 && value.get("tool_calls").is_some()
6950 ),
6951 "tool calls remain valid when thinking mode is disabled"
6952 );
6953 assert!(
6954 out.iter()
6955 .any(|value| value.get("role").and_then(Value::as_str) == Some("tool")),
6956 "matching tool result should remain"
6957 );
6958 }
6959
6960 #[test]
6961 fn prompt_builder_keeps_system_first_and_current_user_input_last() {
6962 let request = MessageRequest {
6963 model: "deepseek-v4-pro".to_string(),
6964 messages: vec![
6965 Message {
6966 role: "assistant".to_string(),
6967 content: vec![ContentBlock::Text {
6968 text: "Previous answer".to_string(),
6969 cache_control: None,
6970 }],
6971 },
6972 Message {
6973 role: "user".to_string(),
6974 content: vec![
6975 ContentBlock::Text {
6976 text: "<turn_meta>\nCurrent local date: 2026-05-08\n</turn_meta>"
6977 .to_string(),
6978 cache_control: None,
6979 },
6980 ContentBlock::Text {
6981 text: "Current user question".to_string(),
6982 cache_control: None,
6983 },
6984 ],
6985 },
6986 ],
6987 max_tokens: 1024,
6988 system: Some(SystemPrompt::Text(
6989 "Stable mode, project rules, and tool policy".to_string(),
6990 )),
6991 tools: None,
6992 tool_choice: None,
6993 metadata: None,
6994 thinking: None,
6995 reasoning_effort: Some("max".to_string()),
6996 stream: None,
6997 temperature: None,
6998 top_p: None,
6999 };
7000
7001 let out = build_chat_messages_for_request(&request);
7002
7003 assert_eq!(out[0].get("role").and_then(Value::as_str), Some("system"));
7004 assert_eq!(
7005 out[0].get("content").and_then(Value::as_str),
7006 Some("Stable mode, project rules, and tool policy")
7007 );
7008 let last = out.last().expect("latest user message");
7009 assert_eq!(last.get("role").and_then(Value::as_str), Some("user"));
7010 assert!(
7011 last.get("content")
7012 .and_then(Value::as_str)
7013 .is_some_and(|content| content.ends_with("Current user question")),
7014 "current-turn user input must be at the tail of the wire prompt: {last:?}"
7015 );
7016 }
7017
7018 #[test]
7019 fn prompt_inspect_reports_stable_layers_and_dynamic_user_task() {
7020 let request = MessageRequest {
7021 model: "deepseek-v4-pro".to_string(),
7022 messages: vec![
7023 Message {
7024 role: "assistant".to_string(),
7025 content: vec![ContentBlock::Text {
7026 text: "Prior answer".to_string(),
7027 cache_control: None,
7028 }],
7029 },
7030 Message {
7031 role: "user".to_string(),
7032 content: vec![ContentBlock::Text {
7033 text: "Current task".to_string(),
7034 cache_control: None,
7035 }],
7036 },
7037 ],
7038 max_tokens: 1024,
7039 system: Some(SystemPrompt::Text(
7040 "Base policy\n\n<project_instructions source=\"AGENTS.md\">\nRules\n</project_instructions>\n\n## Project Context Pack\n\n<project_context_pack>\n{}\n</project_context_pack>\n\n## Environment\n\n- lang: en"
7041 .to_string(),
7042 )),
7043 tools: None,
7044 tool_choice: None,
7045 metadata: None,
7046 thinking: None,
7047 reasoning_effort: Some("max".to_string()),
7048 stream: None,
7049 temperature: None,
7050 top_p: None,
7051 };
7052
7053 let inspection = inspect_prompt_for_request(&request);
7054
7055 assert_eq!(inspection.base_static_prefix_hash.len(), 64);
7056 assert_eq!(inspection.full_request_prefix_hash.len(), 64);
7057 assert!(inspection.layers.iter().any(|layer| {
7058 layer.name == "Global system prefix"
7059 && layer.stability.label() == "static"
7060 && layer.char_len == "Base policy".chars().count()
7061 && layer.sha256.len() == 64
7062 }));
7063 assert!(inspection.layers.iter().any(|layer| {
7064 layer.name == "Project context" && layer.stability.label() == "static"
7065 }));
7066 assert!(inspection.layers.iter().any(|layer| {
7067 layer.name == "Project context pack" && layer.stability.label() == "static"
7068 }));
7069 assert!(inspection.layers.iter().any(|layer| {
7070 layer.name == "Message #1 assistant" && layer.stability.label() == "history"
7071 }));
7072 assert!(
7073 inspection.layers.last().is_some_and(
7074 |layer| layer.name == "User task" && layer.stability.label() == "dynamic"
7075 )
7076 );
7077 }
7078
7079 #[test]
7080 fn prompt_inspect_keeps_static_base_hash_across_different_user_tasks() {
7081 fn request_with_user_task(task: &str) -> MessageRequest {
7082 MessageRequest {
7083 model: "deepseek-v4-pro".to_string(),
7084 messages: vec![
7085 Message {
7086 role: "assistant".to_string(),
7087 content: vec![ContentBlock::Text {
7088 text: "Prior answer".to_string(),
7089 cache_control: None,
7090 }],
7091 },
7092 Message {
7093 role: "user".to_string(),
7094 content: vec![ContentBlock::Text {
7095 text: task.to_string(),
7096 cache_control: None,
7097 }],
7098 },
7099 ],
7100 max_tokens: 1024,
7101 system: Some(SystemPrompt::Text(
7102 "Base policy\n\n## Environment\n\n- shell: powershell\n\n## Skills\n\n- rust\n\n## Context Management\n\nKeep concise\n\n## Compact\n\nTemplate"
7103 .to_string(),
7104 )),
7105 tools: None,
7106 tool_choice: None,
7107 metadata: None,
7108 thinking: None,
7109 reasoning_effort: Some("max".to_string()),
7110 stream: None,
7111 temperature: None,
7112 top_p: None,
7113 }
7114 }
7115
7116 let first = inspect_prompt_for_request(&request_with_user_task("First task"));
7117 let second = inspect_prompt_for_request(&request_with_user_task("Second task"));
7118 let mut changed_history_request = request_with_user_task("Second task");
7119 changed_history_request.messages[0] = Message {
7120 role: "assistant".to_string(),
7121 content: vec![ContentBlock::Text {
7122 text: "Different prior answer".to_string(),
7123 cache_control: None,
7124 }],
7125 };
7126 let changed_history = inspect_prompt_for_request(&changed_history_request);
7127
7128 assert_eq!(
7129 first.base_static_prefix_hash,
7130 second.base_static_prefix_hash
7131 );
7132 assert_eq!(
7133 first.full_request_prefix_hash, second.full_request_prefix_hash,
7134 "full request prefix excludes the final dynamic user task"
7135 );
7136 assert_ne!(
7137 second.full_request_prefix_hash, changed_history.full_request_prefix_hash,
7138 "full request prefix can change when session history changes"
7139 );
7140 assert!(
7141 second.layers.last().is_some_and(
7142 |layer| layer.name == "User task" && layer.stability.label() == "dynamic"
7143 ),
7144 "current user task must remain the final layer"
7145 );
7146 assert!(second.layers.iter().any(|layer| {
7147 layer.name == "Message #1 assistant" && layer.stability.label() == "history"
7148 }));
7149 assert!(!second.layers.iter().any(
7150 |layer| layer.name.starts_with("Message #") && layer.stability.label() == "static"
7151 ));
7152 }
7153
7154 #[test]
7155 fn prompt_inspect_tracks_tool_catalog_in_static_prefix_hash() {
7156 let request = MessageRequest {
7157 model: "deepseek-v4-pro".to_string(),
7158 messages: vec![Message {
7159 role: "user".to_string(),
7160 content: vec![ContentBlock::Text {
7161 text: "Current task".to_string(),
7162 cache_control: None,
7163 }],
7164 }],
7165 max_tokens: 1024,
7166 system: Some(SystemPrompt::Text("Base policy".to_string())),
7167 tools: Some(vec![test_tool("read_file")]),
7168 tool_choice: None,
7169 metadata: None,
7170 thinking: None,
7171 reasoning_effort: Some("max".to_string()),
7172 stream: None,
7173 temperature: None,
7174 top_p: None,
7175 };
7176
7177 let first = inspect_prompt_for_request(&request);
7178 let mut changed_tools = request.clone();
7179 changed_tools.tools = Some(vec![test_tool("read_file"), test_tool("grep_files")]);
7180 let second = inspect_prompt_for_request(&changed_tools);
7181
7182 assert!(
7183 first.layers.iter().any(|layer| {
7184 layer.name == "Tool catalog" && layer.stability.label() == "static"
7185 })
7186 );
7187 assert_ne!(
7188 first.base_static_prefix_hash, second.base_static_prefix_hash,
7189 "tool schema changes must be visible to cache-inspect base prefix diagnostics"
7190 );
7191 assert_ne!(
7192 first.full_request_prefix_hash, second.full_request_prefix_hash,
7193 "tool schema changes must be visible to full reusable-prefix diagnostics"
7194 );
7195 }
7196
7197 #[test]
7198 fn cache_warmup_request_reuses_stable_prefix_and_fixed_user_tail() {
7199 let request = MessageRequest {
7200 model: "deepseek-v4-pro".to_string(),
7201 messages: vec![
7202 Message {
7203 role: "assistant".to_string(),
7204 content: vec![ContentBlock::Text {
7205 text: "Stable prior answer".to_string(),
7206 cache_control: None,
7207 }],
7208 },
7209 Message {
7210 role: "user".to_string(),
7211 content: vec![ContentBlock::Text {
7212 text: "Dynamic latest user task".to_string(),
7213 cache_control: None,
7214 }],
7215 },
7216 ],
7217 max_tokens: 1024,
7218 system: Some(SystemPrompt::Text(
7219 "Base policy\n\n<project_instructions source=\"AGENTS.md\">\nStable project rules\n</project_instructions>\n\n## Previous Session Relay\n\nDynamic relay"
7220 .to_string(),
7221 )),
7222 tools: Some(vec![test_tool("read_file")]),
7223 tool_choice: None,
7224 metadata: None,
7225 thinking: None,
7226 reasoning_effort: Some("max".to_string()),
7227 stream: Some(true),
7228 temperature: Some(0.7),
7229 top_p: None,
7230 };
7231
7232 let warmup = build_cache_warmup_request(&request);
7233
7234 assert_eq!(warmup.max_tokens, 8);
7235 assert_eq!(warmup.temperature, Some(0.0));
7236 assert_eq!(warmup.reasoning_effort.as_deref(), Some("max"));
7237 assert_eq!(warmup.tools.as_ref().map(Vec::len), Some(1));
7238 assert_eq!(warmup.tool_choice, Some(json!("none")));
7239 assert_eq!(warmup.messages.len(), 2);
7240 assert_eq!(warmup.messages[0].role, "assistant");
7241 assert_eq!(warmup.messages[1].role, "user");
7242 assert_eq!(
7243 warmup.messages[1].content,
7244 vec![ContentBlock::Text {
7245 text: "请只回复 OK".to_string(),
7246 cache_control: None,
7247 }]
7248 );
7249
7250 let wire = build_chat_messages_for_request(&warmup);
7251 let system = wire
7252 .first()
7253 .and_then(|value| value.get("content"))
7254 .and_then(Value::as_str)
7255 .expect("warmup system prompt");
7256 assert!(system.contains("Stable project rules"));
7257 assert!(!system.contains("Dynamic relay"));
7258 assert!(
7259 !wire
7260 .iter()
7261 .any(|value| value.to_string().contains("Dynamic latest user task")),
7262 "warmup must not include the dynamic latest user task"
7263 );
7264 }
7265
7266 #[test]
7267 fn reasoning_effort_uses_deepseek_top_level_thinking_parameter() {
7268 let mut body = json!({});
7269 apply_reasoning_effort(&mut body, Some("max"), ApiProvider::Deepseek);
7270
7271 assert_eq!(
7272 body.get("reasoning_effort").and_then(Value::as_str),
7273 Some("max")
7274 );
7275 assert_eq!(
7276 body.pointer("/thinking/type").and_then(Value::as_str),
7277 Some("enabled")
7278 );
7279 assert!(body.get("extra_body").is_none());
7280 }
7281
7282 #[test]
7283 fn reasoning_effort_off_disables_top_level_thinking() {
7284 let mut body = json!({});
7285 apply_reasoning_effort(&mut body, Some("off"), ApiProvider::Deepseek);
7286
7287 assert_eq!(
7288 body.pointer("/thinking/type").and_then(Value::as_str),
7289 Some("disabled")
7290 );
7291 assert!(body.get("reasoning_effort").is_none());
7292 assert!(body.get("extra_body").is_none());
7293 }
7294
7295 /// First-party DeepSeek routes document `reasoning_effort` low/high/max on
7296 /// the wire (no medium): low is a real cheaper tier, medium rounds up to
7297 /// high (#52). Hosted DeepSeek-compatible routes keep the historic
7298 /// low/medium → high collapse because their own wire contracts are not
7299 /// verified here.
7300 #[test]
7301 fn reasoning_effort_deepseek_maps_the_documented_wire_ladder() {
7302 let mut body = json!({});
7303 apply_reasoning_effort(&mut body, Some("low"), ApiProvider::Deepseek);
7304 assert_eq!(
7305 body,
7306 json!({ "reasoning_effort": "low", "thinking": { "type": "enabled" } })
7307 );
7308
7309 let mut body = json!({});
7310 apply_reasoning_effort(&mut body, Some("medium"), ApiProvider::Deepseek);
7311 assert_eq!(
7312 body,
7313 json!({ "reasoning_effort": "high", "thinking": { "type": "enabled" } })
7314 );
7315
7316 for provider in [ApiProvider::Deepseek, ApiProvider::DeepseekCN] {
7317 let mut body = json!({});
7318 apply_reasoning_effort(&mut body, Some("high"), provider);
7319 assert_eq!(
7320 body,
7321 json!({ "reasoning_effort": "high", "thinking": { "type": "enabled" } }),
7322 "provider {provider:?}"
7323 );
7324 }
7325
7326 for provider in [ApiProvider::Siliconflow, ApiProvider::Deepinfra] {
7327 let mut body = json!({});
7328 apply_reasoning_effort(&mut body, Some("low"), provider);
7329 assert_eq!(
7330 body,
7331 json!({ "reasoning_effort": "high", "thinking": { "type": "enabled" } }),
7332 "hosted route {provider:?} keeps the collapse"
7333 );
7334 }
7335 }
7336
7337 async fn capture_deepseek_chat_body_for_effort(effort: Option<&str>) -> Value {
7338 let server = MockServer::start().await;
7339 Mock::given(method("POST"))
7340 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
7341 "id": "chatcmpl-deepseek-effort-ladder",
7342 "object": "chat.completion",
7343 "model": "deepseek-v4-pro",
7344 "choices": [{
7345 "index": 0,
7346 "message": {"role": "assistant", "content": "ok"},
7347 "finish_reason": "stop"
7348 }],
7349 "usage": {
7350 "prompt_tokens": 1,
7351 "completion_tokens": 1,
7352 "total_tokens": 2
7353 }
7354 })))
7355 .expect(1)
7356 .mount(&server)
7357 .await;
7358
7359 let request = MessageRequest {
7360 model: "deepseek-v4-pro".to_string(),
7361 messages: vec![Message {
7362 role: "user".to_string(),
7363 content: vec![ContentBlock::Text {
7364 text: "effort ladder capture".to_string(),
7365 cache_control: None,
7366 }],
7367 }],
7368 max_tokens: 64,
7369 system: None,
7370 tools: None,
7371 tool_choice: None,
7372 metadata: None,
7373 thinking: None,
7374 reasoning_effort: effort.map(str::to_string),
7375 stream: Some(false),
7376 temperature: None,
7377 top_p: None,
7378 };
7379 let client = deepseek_request_boundary_client(
7380 crate::config::DEFAULT_DEEPSEEK_BASE_URL,
7381 server.uri(),
7382 );
7383 client
7384 .create_message(request)
7385 .await
7386 .expect("non-streaming request succeeds");
7387
7388 let requests = server.received_requests().await.expect("recorded request");
7389 assert_eq!(requests.len(), 1);
7390 serde_json::from_slice(&requests[0].body).expect("captured request JSON")
7391 }
7392
7393 /// Request-body capture per effort level on the first-party DeepSeek chat
7394 /// route: the wire must carry the documented low/high/max ladder and the
7395 /// thinking toggle, never an invented value (#52).
7396 #[tokio::test]
7397 async fn deepseek_chat_wire_body_tracks_the_documented_effort_ladder() {
7398 for (effort, expected_effort, expected_thinking) in [
7399 (Some("low"), Some("low"), Some("enabled")),
7400 (Some("medium"), Some("high"), Some("enabled")),
7401 (Some("high"), Some("high"), Some("enabled")),
7402 (Some("max"), Some("max"), Some("enabled")),
7403 (Some("off"), None, Some("disabled")),
7404 (None, None, None),
7405 ] {
7406 let body = capture_deepseek_chat_body_for_effort(effort).await;
7407 assert_eq!(
7408 body.get("reasoning_effort").and_then(Value::as_str),
7409 expected_effort,
7410 "reasoning_effort on the wire for {effort:?}: {body}"
7411 );
7412 assert_eq!(
7413 body.pointer("/thinking/type").and_then(Value::as_str),
7414 expected_thinking,
7415 "thinking on the wire for {effort:?}: {body}"
7416 );
7417 }
7418 }
7419
7420 #[test]
7421 fn reasoning_effort_off_is_omitted_for_strict_openai_like_providers() {
7422 for provider in [
7423 ApiProvider::Openai,
7424 ApiProvider::WanjieArk,
7425 ApiProvider::Qianfan,
7426 ApiProvider::Arcee,
7427 ApiProvider::Huggingface,
7428 ApiProvider::Fireworks,
7429 ] {
7430 let mut body = json!({});
7431 apply_reasoning_effort(&mut body, Some("off"), provider);
7432
7433 assert_eq!(
7434 body,
7435 json!({}),
7436 "provider {provider:?} should not receive unsupported reasoning-off fields"
7437 );
7438 }
7439 }
7440
7441 #[test]
7442 fn reasoning_effort_atlascloud_speaks_deepseek_dialect() {
7443 let mut body = json!({});
7444 apply_reasoning_effort(&mut body, Some("high"), ApiProvider::Atlascloud);
7445 assert_eq!(
7446 body,
7447 json!({ "reasoning_effort": "high", "thinking": { "type": "enabled" } })
7448 );
7449
7450 let mut body = json!({});
7451 apply_reasoning_effort(&mut body, Some("max"), ApiProvider::Atlascloud);
7452 assert_eq!(
7453 body,
7454 json!({ "reasoning_effort": "max", "thinking": { "type": "enabled" } })
7455 );
7456
7457 let mut body = json!({});
7458 apply_reasoning_effort(&mut body, Some("off"), ApiProvider::Atlascloud);
7459 assert_eq!(body, json!({ "thinking": { "type": "disabled" } }));
7460 }
7461
7462 #[test]
7463 fn reasoning_effort_modelstudio_writes_nothing_without_a_verified_route() {
7464 // The provider enum cannot decide DashScope's controls: `enable_thinking`
7465 // is wrong for the thinking-only models, `reasoning_effort` is only
7466 // valid for DeepSeek-V4/GLM, and a custom `base_url` on any of these
7467 // identities is an arbitrary gateway. All four variants must therefore
7468 // leave the body untouched here — the route shaper in client::chat is
7469 // the sole writer.
7470 for provider in [
7471 ApiProvider::ModelstudioTokenPlan,
7472 ApiProvider::ModelstudioTokenPlanAnthropic,
7473 ApiProvider::ModelstudioCodingPlan,
7474 ApiProvider::ModelstudioCodingPlanAnthropic,
7475 ] {
7476 for effort in [None, Some("off"), Some("low"), Some("high"), Some("max")] {
7477 let mut body = json!({});
7478 apply_reasoning_effort(&mut body, effort, provider);
7479 assert_eq!(body, json!({}), "{provider:?} {effort:?}");
7480 }
7481 }
7482 }
7483
7484 #[test]
7485 fn reasoning_effort_moonshot_toggles_thinking() {
7486 let mut body = json!({});
7487 apply_reasoning_effort(&mut body, Some("high"), ApiProvider::Moonshot);
7488 assert_eq!(body, json!({ "thinking": { "type": "enabled" } }));
7489
7490 let mut body = json!({});
7491 apply_reasoning_effort(&mut body, Some("off"), ApiProvider::Moonshot);
7492 assert_eq!(body, json!({ "thinking": { "type": "disabled" } }));
7493 }
7494
7495 /// TelecomJS TokenHub: the gateway's OpenAI Chat Completions API does NOT
7496 /// support `reasoning_effort` or `thinking` fields (#4188 review). Verify
7497 /// that no reasoning fields are injected for any effort level, since not
7498 /// every gateway model (qwen-max, deepseek-chat, gpt-4o, claude, etc.)
7499 /// accepts the same reasoning dialect.
7500 #[test]
7501 fn reasoning_effort_telecomjs_does_not_inject_reasoning_fields() {
7502 for effort in &["off", "low", "medium", "high", "max", "xhigh"] {
7503 let mut body = json!({});
7504 apply_reasoning_effort(&mut body, Some(effort), ApiProvider::Telecomjs);
7505 assert!(
7506 body.get("reasoning_effort").is_none(),
7507 "TelecomJS must not inject reasoning_effort for effort={effort}: {body}"
7508 );
7509 assert!(
7510 body.get("thinking").is_none(),
7511 "TelecomJS must not inject thinking for effort={effort}: {body}"
7512 );
7513 assert!(
7514 body.get("think").is_none(),
7515 "TelecomJS must not inject think for effort={effort}: {body}"
7516 );
7517 }
7518 }
7519
7520 #[test]
7521 fn moonshot_uses_codewhale_user_agent_not_kimi_cli_identity() {
7522 let user_agent = client_user_agent(ApiProvider::Moonshot);
7523
7524 assert!(user_agent.contains("codewhale/"));
7525 assert!(!user_agent.to_ascii_lowercase().contains("kimi_cli"));
7526 assert!(!user_agent.to_ascii_lowercase().contains("kimi-code-cli"));
7527 }
7528
7529 #[test]
7530 fn reasoning_effort_ollama_toggles_think_flag() {
7531 let mut body = json!({});
7532 apply_reasoning_effort(&mut body, Some("high"), ApiProvider::Ollama);
7533 assert_eq!(body, json!({ "think": true }));
7534
7535 let mut body = json!({});
7536 apply_reasoning_effort(&mut body, Some("off"), ApiProvider::Ollama);
7537 assert_eq!(body, json!({ "think": false }));
7538 }
7539
7540 #[test]
7541 fn reasoning_effort_uses_nvidia_nim_chat_template_kwargs() {
7542 let mut body = json!({});
7543 apply_reasoning_effort(&mut body, Some("max"), ApiProvider::NvidiaNim);
7544
7545 assert_eq!(
7546 body.pointer("/chat_template_kwargs/thinking")
7547 .and_then(Value::as_bool),
7548 Some(true)
7549 );
7550 assert_eq!(
7551 body.pointer("/chat_template_kwargs/reasoning_effort")
7552 .and_then(Value::as_str),
7553 Some("max")
7554 );
7555 assert!(body.get("thinking").is_none());
7556 assert!(body.get("reasoning_effort").is_none());
7557 }
7558
7559 #[test]
7560 fn reasoning_effort_off_disables_nvidia_nim_thinking() {
7561 let mut body = json!({});
7562 apply_reasoning_effort(&mut body, Some("off"), ApiProvider::NvidiaNim);
7563
7564 assert_eq!(
7565 body.pointer("/chat_template_kwargs/thinking")
7566 .and_then(Value::as_bool),
7567 Some(false)
7568 );
7569 assert!(
7570 body.pointer("/chat_template_kwargs/reasoning_effort")
7571 .is_none()
7572 );
7573 }
7574
7575 #[test]
7576 fn reasoning_effort_uses_openai_compatible_shape_for_fireworks() {
7577 let mut body = json!({});
7578 apply_reasoning_effort(&mut body, Some("max"), ApiProvider::Fireworks);
7579
7580 assert_eq!(
7581 body.get("reasoning_effort").and_then(Value::as_str),
7582 Some("max")
7583 );
7584 assert!(
7585 body.get("thinking").is_none(),
7586 "Fireworks strict-validates OpenAI-compatible requests and rejects top-level thinking"
7587 );
7588 }
7589
7590 #[test]
7591 fn reasoning_effort_uses_arcee_reasoning_effort_without_thinking_object() {
7592 for (input, expected) in [
7593 ("minimal", "minimal"),
7594 ("low", "low"),
7595 ("mid", "medium"),
7596 ("medium", "medium"),
7597 ("high", "high"),
7598 ("max", "high"),
7599 ] {
7600 let mut body = json!({});
7601 apply_reasoning_effort(&mut body, Some(input), ApiProvider::Arcee);
7602
7603 assert_eq!(
7604 body.get("reasoning_effort").and_then(Value::as_str),
7605 Some(expected)
7606 );
7607 assert!(
7608 body.get("thinking").is_none(),
7609 "Arcee documents reasoning_effort rather than a DeepSeek thinking object"
7610 );
7611 }
7612 }
7613
7614 #[test]
7615 fn reasoning_effort_maps_openrouter_scale_without_deepseek_max_label() {
7616 for (input, expected) in [
7617 ("low", "low"),
7618 ("minimal", "low"),
7619 ("medium", "medium"),
7620 ("mid", "medium"),
7621 ("high", "high"),
7622 ("max", "xhigh"),
7623 ("xhigh", "xhigh"),
7624 ] {
7625 let mut body = json!({});
7626 apply_reasoning_effort(&mut body, Some(input), ApiProvider::Openrouter);
7627
7628 assert_eq!(
7629 body.get("reasoning_effort").and_then(Value::as_str),
7630 Some(expected),
7631 "OpenRouter effort mapping for {input}"
7632 );
7633 assert_eq!(
7634 body.pointer("/thinking/type").and_then(Value::as_str),
7635 Some("enabled")
7636 );
7637 }
7638 }
7639
7640 #[test]
7641 fn reasoning_effort_uses_xiaomi_mimo_thinking_parameter_only() {
7642 for input in ["low", "medium", "max", "xhigh"] {
7643 let mut body = json!({});
7644 apply_reasoning_effort(&mut body, Some(input), ApiProvider::XiaomiMimo);
7645
7646 assert_eq!(
7647 body.pointer("/thinking/type").and_then(Value::as_str),
7648 Some("enabled"),
7649 "MiMo thinking mapping for {input}"
7650 );
7651 assert!(body.get("reasoning_effort").is_none());
7652 }
7653
7654 let mut body = json!({});
7655 apply_reasoning_effort(&mut body, Some("off"), ApiProvider::XiaomiMimo);
7656 assert_eq!(
7657 body.pointer("/thinking/type").and_then(Value::as_str),
7658 Some("disabled")
7659 );
7660 assert!(body.get("reasoning_effort").is_none());
7661 }
7662
7663 #[test]
7664 fn reasoning_effort_minimax_requires_exact_route_to_split_reasoning() {
7665 let mut body = json!({});
7666 chat::apply_route_reasoning_controls(
7667 &mut body,
7668 ApiProvider::Minimax,
7669 crate::config::DEFAULT_MINIMAX_BASE_URL,
7670 crate::config::DEFAULT_MINIMAX_MODEL,
7671 Some("high"),
7672 );
7673 assert_eq!(
7674 body.get("reasoning_split").and_then(Value::as_bool),
7675 Some(true)
7676 );
7677 assert_eq!(
7678 body.pointer("/thinking/type").and_then(Value::as_str),
7679 Some("adaptive")
7680 );
7681 assert!(body.get("reasoning_effort").is_none());
7682
7683 let mut body = json!({});
7684 chat::apply_route_reasoning_controls(
7685 &mut body,
7686 ApiProvider::Minimax,
7687 crate::config::DEFAULT_MINIMAX_BASE_URL,
7688 crate::config::DEFAULT_MINIMAX_MODEL,
7689 Some("max"),
7690 );
7691 assert_eq!(
7692 body.pointer("/thinking/type").and_then(Value::as_str),
7693 Some("adaptive")
7694 );
7695 assert!(body.get("reasoning_effort").is_none());
7696
7697 let mut body = json!({});
7698 chat::apply_route_reasoning_controls(
7699 &mut body,
7700 ApiProvider::Minimax,
7701 crate::config::DEFAULT_MINIMAX_BASE_URL,
7702 crate::config::DEFAULT_MINIMAX_MODEL,
7703 Some("off"),
7704 );
7705 assert_eq!(
7706 body.get("reasoning_split").and_then(Value::as_bool),
7707 Some(true)
7708 );
7709 assert_eq!(
7710 body.pointer("/thinking/type").and_then(Value::as_str),
7711 Some("disabled")
7712 );
7713
7714 let mut body = json!({});
7715 chat::apply_route_reasoning_controls(
7716 &mut body,
7717 ApiProvider::Minimax,
7718 crate::config::DEFAULT_MINIMAX_BASE_URL,
7719 crate::config::DEFAULT_MINIMAX_MODEL,
7720 None,
7721 );
7722 assert_eq!(body, json!({ "reasoning_split": true }));
7723
7724 for (base_url, model) in [
7725 (
7726 "https://gateway.example/v1",
7727 crate::config::DEFAULT_MINIMAX_MODEL,
7728 ),
7729 (crate::config::DEFAULT_MINIMAX_BASE_URL, "MiniMax-M2"),
7730 ] {
7731 for effort in ["off", "high", "max"] {
7732 let mut body = json!({});
7733 chat::apply_route_reasoning_controls(
7734 &mut body,
7735 ApiProvider::Minimax,
7736 base_url,
7737 model,
7738 Some(effort),
7739 );
7740 assert_eq!(body, json!({}), "{base_url} {model} {effort}");
7741 }
7742 }
7743 }
7744
7745 #[test]
7746 fn reasoning_effort_zai_uses_documented_thinking_shape() {
7747 let mut body = json!({});
7748 apply_reasoning_effort(&mut body, Some("high"), ApiProvider::Zai);
7749 assert_eq!(
7750 body,
7751 json!({ "thinking": { "type": "enabled", "clear_thinking": false } })
7752 );
7753
7754 let mut body = json!({});
7755 apply_reasoning_effort(&mut body, Some("max"), ApiProvider::Zai);
7756 assert_eq!(
7757 body,
7758 json!({ "thinking": { "type": "enabled", "clear_thinking": false } })
7759 );
7760
7761 let mut body = json!({});
7762 apply_reasoning_effort(&mut body, Some("ultracode"), ApiProvider::Zai);
7763 assert_eq!(
7764 body,
7765 json!({ "thinking": { "type": "enabled", "clear_thinking": false } })
7766 );
7767
7768 let mut body = json!({});
7769 apply_reasoning_effort(&mut body, Some("off"), ApiProvider::Zai);
7770 assert_eq!(body, json!({ "thinking": { "type": "disabled" } }));
7771 }
7772
7773 #[test]
7774 fn chat_parser_accepts_nvidia_nim_reasoning_field() -> Result<()> {
7775 let response = parse_chat_message(&json!({
7776 "id": "chatcmpl-test",
7777 "model": "deepseek-ai/deepseek-v4-pro",
7778 "choices": [{
7779 "message": {
7780 "role": "assistant",
7781 "reasoning": "thinking via NIM",
7782 "content": "final answer"
7783 },
7784 "finish_reason": "stop"
7785 }],
7786 "usage": {
7787 "prompt_tokens": 10,
7788 "completion_tokens": 3
7789 }
7790 }))?;
7791
7792 assert!(matches!(
7793 response.content.first(),
7794 Some(ContentBlock::Thinking { thinking, .. }) if thinking == "thinking via NIM"
7795 ));
7796 assert!(matches!(
7797 response.content.get(1),
7798 Some(ContentBlock::Text { text, .. }) if text == "final answer"
7799 ));
7800 Ok(())
7801 }
7802
7803 #[test]
7804 fn sse_parser_accepts_nvidia_nim_reasoning_delta() {
7805 let mut content_index = 0;
7806 let mut text_started = false;
7807 let mut thinking_started = false;
7808 let mut tool_indices = std::collections::HashMap::new();
7809 let mut reasoning_detail_buffers = std::collections::HashMap::new();
7810 let events = parse_sse_chunk(
7811 &json!({
7812 "choices": [{
7813 "delta": {
7814 "reasoning": "nim thought"
7815 }
7816 }]
7817 }),
7818 &mut content_index,
7819 &mut text_started,
7820 &mut thinking_started,
7821 &mut tool_indices,
7822 &mut reasoning_detail_buffers,
7823 true,
7824 );
7825
7826 assert!(events.iter().any(|event| matches!(
7827 event,
7828 StreamEvent::ContentBlockDelta {
7829 delta: Delta::ThinkingDelta { thinking },
7830 ..
7831 } if thinking == "nim thought"
7832 )));
7833 }
7834
7835 #[test]
7836 fn chat_tool_strict_flag_is_nested_under_function() {
7837 let tool = Tool {
7838 tool_type: Some("function".to_string()),
7839 name: "emit_json".to_string(),
7840 description: "Emit JSON".to_string(),
7841 input_schema: json!({"type": "object", "properties": {}}),
7842 allowed_callers: None,
7843 defer_loading: None,
7844 input_examples: None,
7845 strict: Some(true),
7846 cache_control: None,
7847 };
7848 let encoded = tool_to_chat(&tool);
7849 assert_eq!(
7850 encoded
7851 .get("function")
7852 .and_then(|function| function.get("strict"))
7853 .and_then(Value::as_bool),
7854 Some(true)
7855 );
7856 assert!(encoded.get("strict").is_none());
7857 }
7858
7859 #[test]
7860 fn deepseek_non_beta_base_url_strips_strict_tool_flag() {
7861 let tool = Tool {
7862 tool_type: Some("function".to_string()),
7863 name: "emit_json".to_string(),
7864 description: "Emit JSON".to_string(),
7865 input_schema: json!({"type": "object", "properties": {}}),
7866 allowed_callers: None,
7867 defer_loading: None,
7868 input_examples: None,
7869 strict: Some(true),
7870 cache_control: None,
7871 };
7872
7873 let encoded = tool_to_chat_for_base_url(&tool, "https://api.deepseek.com/v1");
7874
7875 assert!(
7876 encoded
7877 .get("function")
7878 .and_then(|function| function.get("strict"))
7879 .is_none()
7880 );
7881 }
7882
7883 #[test]
7884 fn deepseek_beta_and_custom_base_urls_keep_strict_tool_flag() {
7885 let tool = Tool {
7886 tool_type: Some("function".to_string()),
7887 name: "emit_json".to_string(),
7888 description: "Emit JSON".to_string(),
7889 input_schema: json!({"type": "object", "properties": {}}),
7890 allowed_callers: None,
7891 defer_loading: None,
7892 input_examples: None,
7893 strict: Some(true),
7894 cache_control: None,
7895 };
7896
7897 for base_url in [
7898 "https://api.deepseek.com/beta",
7899 "https://example.com/openai/v1",
7900 ] {
7901 let encoded = tool_to_chat_for_base_url(&tool, base_url);
7902 assert_eq!(
7903 encoded
7904 .get("function")
7905 .and_then(|function| function.get("strict"))
7906 .and_then(Value::as_bool),
7907 Some(true)
7908 );
7909 }
7910 }
7911
7912 #[test]
7913 fn chat_tool_wire_shape_omits_anthropic_only_metadata() {
7914 let tool = Tool {
7915 tool_type: Some("function".to_string()),
7916 name: "mcp_read_resource".to_string(),
7917 description: "Read resource".to_string(),
7918 input_schema: json!({"type": "object", "properties": {}}),
7919 allowed_callers: Some(vec!["direct".to_string()]),
7920 defer_loading: Some(false),
7921 input_examples: Some(vec![json!({"uri": "file://example"})]),
7922 strict: None,
7923 cache_control: None,
7924 };
7925
7926 let encoded = tool_to_chat_for_base_url(&tool, "https://api.fireworks.ai/inference/v1");
7927
7928 assert!(encoded.get("allowed_callers").is_none());
7929 assert!(encoded.get("defer_loading").is_none());
7930 assert!(encoded.get("input_examples").is_none());
7931 }
7932
7933 #[test]
7934 fn chat_messages_drop_thinking_only_assistant_for_non_reasoning_model() {
7935 let message = Message {
7936 role: "assistant".to_string(),
7937 content: vec![ContentBlock::Thinking {
7938 signature: None,
7939 thinking: "plan".to_string(),
7940 }],
7941 };
7942 let out = build_chat_messages(None, &[message], "some-non-deepseek-model");
7943 assert!(
7944 !out.iter()
7945 .any(|value| value.get("role").and_then(Value::as_str) == Some("assistant")),
7946 "non-reasoning model should drop thinking-only assistant"
7947 );
7948 }
7949
7950 #[test]
7951 fn parse_sse_chunk_closes_each_tool_block_with_matching_index() {
7952 let chunk = json!({
7953 "choices": [{
7954 "delta": {
7955 "tool_calls": [
7956 {
7957 "index": 0,
7958 "id": "call_0",
7959 "function": {"name": "read_file", "arguments": "{\"path\":\"a\"}"}
7960 },
7961 {
7962 "index": 1,
7963 "id": "call_1",
7964 "function": {"name": "read_file", "arguments": "{\"path\":\"b\"}"}
7965 }
7966 ]
7967 },
7968 "finish_reason": "tool_calls"
7969 }]
7970 });
7971
7972 let mut content_index = 0;
7973 let mut text_started = false;
7974 let mut thinking_started = false;
7975 let mut tool_indices: std::collections::HashMap<u32, u32> =
7976 std::collections::HashMap::new();
7977 let mut reasoning_detail_buffers = std::collections::HashMap::new();
7978 let events = parse_sse_chunk(
7979 &chunk,
7980 &mut content_index,
7981 &mut text_started,
7982 &mut thinking_started,
7983 &mut tool_indices,
7984 &mut reasoning_detail_buffers,
7985 false,
7986 );
7987
7988 let starts: Vec<u32> = events
7989 .iter()
7990 .filter_map(|event| match event {
7991 StreamEvent::ContentBlockStart {
7992 index,
7993 content_block: ContentBlockStart::ToolUse { .. },
7994 } => Some(*index),
7995 _ => None,
7996 })
7997 .collect();
7998 let stops: Vec<u32> = events
7999 .iter()
8000 .filter_map(|event| match event {
8001 StreamEvent::ContentBlockStop { index } => Some(*index),
8002 _ => None,
8003 })
8004 .collect();
8005 let deltas: Vec<u32> = events
8006 .iter()
8007 .filter_map(|event| match event {
8008 StreamEvent::ContentBlockDelta {
8009 index,
8010 delta: Delta::InputJsonDelta { .. },
8011 } => Some(*index),
8012 _ => None,
8013 })
8014 .collect();
8015
8016 assert_eq!(starts, vec![0, 1]);
8017 assert_eq!(stops, vec![0, 1]);
8018 assert_eq!(deltas, vec![0, 1]);
8019 }
8020
8021 #[test]
8022 fn parse_sse_chunk_handles_empty_choices_usage_chunk() {
8023 let chunk = json!({
8024 "choices": [],
8025 "usage": {
8026 "prompt_tokens": 100,
8027 "completion_tokens": 20,
8028 "prompt_cache_hit_tokens": 70,
8029 "prompt_cache_miss_tokens": 30
8030 }
8031 });
8032
8033 let mut content_index = 0;
8034 let mut text_started = false;
8035 let mut thinking_started = false;
8036 let mut tool_indices: std::collections::HashMap<u32, u32> =
8037 std::collections::HashMap::new();
8038 let mut reasoning_detail_buffers = std::collections::HashMap::new();
8039 let events = parse_sse_chunk(
8040 &chunk,
8041 &mut content_index,
8042 &mut text_started,
8043 &mut thinking_started,
8044 &mut tool_indices,
8045 &mut reasoning_detail_buffers,
8046 false,
8047 );
8048
8049 let StreamEvent::MessageDelta {
8050 usage: Some(usage), ..
8051 } = &events[0]
8052 else {
8053 panic!("expected usage delta");
8054 };
8055 assert_eq!(usage.input_tokens, 100);
8056 assert_eq!(usage.prompt_cache_hit_tokens, Some(70));
8057 assert_eq!(usage.prompt_cache_miss_tokens, Some(30));
8058 }
8059
8060 #[test]
8061 fn chat_messages_drop_orphan_tool_results() {
8062 let messages = vec![Message {
8063 role: "user".to_string(),
8064 content: vec![ContentBlock::ToolResult {
8065 tool_use_id: "tool-1".to_string(),
8066 content: "ok".to_string(),
8067 is_error: None,
8068 content_blocks: None,
8069 }],
8070 }];
8071
8072 let out = build_chat_messages(None, &messages, "deepseek-v4-flash");
8073 assert!(
8074 !out.iter()
8075 .any(|value| { value.get("role").and_then(Value::as_str) == Some("tool") })
8076 );
8077 }
8078
8079 #[test]
8080 fn chat_messages_include_tool_results_when_call_present() {
8081 let messages = vec![
8082 Message {
8083 role: "assistant".to_string(),
8084 content: vec![
8085 ContentBlock::Thinking {
8086 signature: None,
8087 thinking: "Need to inspect the directory".to_string(),
8088 },
8089 ContentBlock::ToolUse {
8090 id: "tool-1".to_string(),
8091 name: "list_dir".to_string(),
8092 input: json!({}),
8093 caller: None,
8094 },
8095 ],
8096 },
8097 Message {
8098 role: "user".to_string(),
8099 content: vec![ContentBlock::ToolResult {
8100 tool_use_id: "tool-1".to_string(),
8101 content: "ok".to_string(),
8102 is_error: None,
8103 content_blocks: None,
8104 }],
8105 },
8106 ];
8107
8108 let out = build_chat_messages(None, &messages, "deepseek-v4-flash");
8109 assert!(
8110 out.iter()
8111 .any(|value| { value.get("role").and_then(Value::as_str) == Some("tool") })
8112 );
8113 let assistant = out
8114 .iter()
8115 .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant"))
8116 .expect("assistant message");
8117 assert!(assistant.get("tool_calls").is_some());
8118 }
8119
8120 #[test]
8121 fn chat_messages_encode_tool_call_names() {
8122 let messages = vec![
8123 Message {
8124 role: "assistant".to_string(),
8125 content: vec![
8126 ContentBlock::Thinking {
8127 signature: None,
8128 thinking: "Need to search".to_string(),
8129 },
8130 ContentBlock::ToolUse {
8131 id: "tool-1".to_string(),
8132 name: "web.run".to_string(),
8133 input: json!({}),
8134 caller: None,
8135 },
8136 ],
8137 },
8138 Message {
8139 role: "user".to_string(),
8140 content: vec![ContentBlock::ToolResult {
8141 tool_use_id: "tool-1".to_string(),
8142 content: "ok".to_string(),
8143 is_error: None,
8144 content_blocks: None,
8145 }],
8146 },
8147 ];
8148
8149 let out = build_chat_messages(None, &messages, "deepseek-v4-flash");
8150 let assistant = out
8151 .iter()
8152 .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant"))
8153 .expect("assistant message");
8154 let tool_calls = assistant
8155 .get("tool_calls")
8156 .and_then(Value::as_array)
8157 .expect("tool_calls array");
8158 let function_name = tool_calls
8159 .first()
8160 .and_then(|call| call.get("function"))
8161 .and_then(|func| func.get("name"))
8162 .and_then(Value::as_str)
8163 .expect("tool call function name");
8164
8165 assert_eq!(function_name, to_api_tool_name("web.run"));
8166 }
8167
8168 #[test]
8169 fn chat_messages_strips_orphaned_tool_calls_after_compaction() {
8170 // Simulates post-compaction state: assistant has tool_calls but the
8171 // tool result messages were summarized away.
8172 let messages = vec![
8173 Message {
8174 role: "assistant".to_string(),
8175 content: vec![ContentBlock::ToolUse {
8176 id: "tool-orphan".to_string(),
8177 name: "read_file".to_string(),
8178 input: json!({"path": "src/main.rs"}),
8179 caller: None,
8180 }],
8181 },
8182 // No tool result follows — it was removed by compaction.
8183 Message {
8184 role: "user".to_string(),
8185 content: vec![ContentBlock::Text {
8186 text: "continue".to_string(),
8187 cache_control: None,
8188 }],
8189 },
8190 ];
8191
8192 let out = build_chat_messages(None, &messages, "deepseek-v4-flash");
8193 let assistant = out
8194 .iter()
8195 .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant"));
8196 // The safety net may drop the assistant message entirely if it only
8197 // contained orphaned tool_calls and no text content.
8198 assert!(
8199 assistant.is_none(),
8200 "assistant without content/tool_calls should be removed"
8201 );
8202 assert!(
8203 !out.iter()
8204 .any(|v| v.get("role").and_then(Value::as_str) == Some("tool")),
8205 "orphaned tool results should also be removed"
8206 );
8207 }
8208
8209 #[test]
8210 fn chat_messages_keeps_valid_tool_calls_intact() {
8211 // Complete call+result pair should NOT be stripped.
8212 let messages = vec![
8213 Message {
8214 role: "assistant".to_string(),
8215 content: vec![
8216 ContentBlock::Thinking {
8217 signature: None,
8218 thinking: "Need to list files".to_string(),
8219 },
8220 ContentBlock::ToolUse {
8221 id: "tool-ok".to_string(),
8222 name: "list_dir".to_string(),
8223 input: json!({}),
8224 caller: None,
8225 },
8226 ],
8227 },
8228 Message {
8229 role: "user".to_string(),
8230 content: vec![ContentBlock::ToolResult {
8231 tool_use_id: "tool-ok".to_string(),
8232 content: "files".to_string(),
8233 is_error: None,
8234 content_blocks: None,
8235 }],
8236 },
8237 ];
8238
8239 let out = build_chat_messages(None, &messages, "deepseek-v4-flash");
8240 let assistant = out
8241 .iter()
8242 .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant"))
8243 .expect("assistant message");
8244 assert!(
8245 assistant.get("tool_calls").is_some(),
8246 "valid tool_calls should remain intact"
8247 );
8248 assert!(
8249 out.iter()
8250 .any(|value| value.get("role").and_then(Value::as_str) == Some("tool")),
8251 "tool result should remain"
8252 );
8253 }
8254
8255 #[test]
8256 fn chat_messages_strips_partial_tool_results() {
8257 let messages = vec![
8258 Message {
8259 role: "assistant".to_string(),
8260 content: vec![
8261 ContentBlock::ToolUse {
8262 id: "t1".to_string(),
8263 name: "read_file".to_string(),
8264 input: json!({"path": "a.rs"}),
8265 caller: None,
8266 },
8267 ContentBlock::ToolUse {
8268 id: "t2".to_string(),
8269 name: "read_file".to_string(),
8270 input: json!({"path": "b.rs"}),
8271 caller: None,
8272 },
8273 ContentBlock::ToolUse {
8274 id: "t3".to_string(),
8275 name: "shell".to_string(),
8276 input: json!({"cmd": "ls"}),
8277 caller: None,
8278 },
8279 ],
8280 },
8281 Message {
8282 role: "user".to_string(),
8283 content: vec![ContentBlock::ToolResult {
8284 tool_use_id: "t1".to_string(),
8285 content: "content a".to_string(),
8286 is_error: None,
8287 content_blocks: None,
8288 }],
8289 },
8290 Message {
8291 role: "user".to_string(),
8292 content: vec![ContentBlock::ToolResult {
8293 tool_use_id: "t2".to_string(),
8294 content: "content b".to_string(),
8295 is_error: None,
8296 content_blocks: None,
8297 }],
8298 },
8299 // No result for t3
8300 Message {
8301 role: "user".to_string(),
8302 content: vec![ContentBlock::Text {
8303 text: "continue".to_string(),
8304 cache_control: None,
8305 }],
8306 },
8307 ];
8308
8309 let out = build_chat_messages(None, &messages, "deepseek-v4-flash");
8310 let assistant = out
8311 .iter()
8312 .find(|v| v.get("role").and_then(Value::as_str) == Some("assistant"));
8313 assert!(
8314 assistant.is_none(),
8315 "assistant with only partial tool_calls should be removed"
8316 );
8317 assert!(
8318 !out.iter()
8319 .any(|v| v.get("role").and_then(Value::as_str) == Some("tool")),
8320 "all orphaned tool results should be removed"
8321 );
8322 }
8323
8324 #[test]
8325 fn parse_models_response_parses_and_deduplicates() {
8326 let payload = r#"{
8327 "object": "list",
8328 "data": [
8329 {"id": "deepseek-v4-pro", "object": "model", "owned_by": "deepseek", "created": 1},
8330 {"id": "deepseek-v4-flash", "object": "model"},
8331 {"id": "deepseek-v4-pro", "object": "model", "owned_by": "deepseek", "created": 1}
8332 ]
8333 }"#;
8334
8335 let models = parse_models_response(payload).expect("parse models");
8336 assert_eq!(
8337 models,
8338 vec![
8339 AvailableModel {
8340 id: "deepseek-v4-flash".to_string(),
8341 owned_by: None,
8342 created: None
8343 },
8344 AvailableModel {
8345 id: "deepseek-v4-pro".to_string(),
8346 owned_by: Some("deepseek".to_string()),
8347 created: Some(1)
8348 }
8349 ]
8350 );
8351 }
8352
8353 #[test]
8354 fn parse_models_response_accepts_ollama_tag_ids() {
8355 let payload = r#"{
8356 "object": "list",
8357 "data": [
8358 {"id": "qwen2.5-coder:7b", "object": "model", "owned_by": "library"},
8359 {"id": "deepseek-coder-v2:16b", "object": "model"}
8360 ]
8361 }"#;
8362
8363 let models = parse_models_response(payload).expect("parse models");
8364 assert_eq!(
8365 models
8366 .iter()
8367 .map(|model| model.id.as_str())
8368 .collect::<Vec<_>>(),
8369 vec!["deepseek-coder-v2:16b", "qwen2.5-coder:7b"]
8370 );
8371 }
8372
8373 // === #3385: provider live /models fetch + secret-free cache ==============
8374 //
8375 // All model ids below are SYNTHETIC (never real vendor model names), per the
8376 // issue's anti-hardcoding rule.
8377
8378 /// Build a client whose OpenRouter base URL points at a mock server.
8379 fn openrouter_client_for(server: &MockServer) -> DeepSeekClient {
8380 let _ = rustls::crypto::ring::default_provider().install_default();
8381 DeepSeekClient::new(&Config {
8382 provider: Some("openrouter".to_string()),
8383 providers: Some(ProvidersConfig {
8384 openrouter: ProviderConfig {
8385 api_key: Some("test-key".to_string()),
8386 base_url: Some(server.uri()),
8387 ..ProviderConfig::default()
8388 },
8389 ..ProvidersConfig::default()
8390 }),
8391 ..Config::default()
8392 })
8393 .expect("openrouter client")
8394 }
8395
8396 fn opencode_go_client_for(server: &MockServer) -> DeepSeekClient {
8397 let _ = rustls::crypto::ring::default_provider().install_default();
8398 DeepSeekClient::new(&Config {
8399 provider: Some("opencode-go".to_string()),
8400 providers: Some(ProvidersConfig {
8401 opencode_go: ProviderConfig {
8402 api_key: Some("test-key".to_string()),
8403 base_url: Some(server.uri()),
8404 ..ProviderConfig::default()
8405 },
8406 ..ProvidersConfig::default()
8407 }),
8408 ..Config::default()
8409 })
8410 .expect("OpenCode Go client")
8411 }
8412
8413 fn telecomjs_client_for(server: &MockServer) -> DeepSeekClient {
8414 let _ = rustls::crypto::ring::default_provider().install_default();
8415 DeepSeekClient::new(&Config {
8416 provider: Some("telecomjs".to_string()),
8417 providers: Some(ProvidersConfig {
8418 telecomjs: ProviderConfig {
8419 api_key: Some("test-key".to_string()),
8420 base_url: Some(server.uri()),
8421 ..ProviderConfig::default()
8422 },
8423 ..ProvidersConfig::default()
8424 }),
8425 ..Config::default()
8426 })
8427 .expect("TelecomJS client")
8428 }
8429
8430 async fn mount_models_json(server: &MockServer, status: u16, body: serde_json::Value) {
8431 Mock::given(method("GET"))
8432 .and(path("/v1/models"))
8433 .respond_with(ResponseTemplate::new(status).set_body_json(body))
8434 .mount(server)
8435 .await;
8436 }
8437
8438 #[tokio::test]
8439 async fn verify_provider_api_key_accepts_mocked_models_success() {
8440 let server = MockServer::start().await;
8441 Mock::given(method("GET"))
8442 .and(path("/v1/models"))
8443 .and(header("authorization", "Bearer test-key"))
8444 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"data": []})))
8445 .mount(&server)
8446 .await;
8447
8448 verify_provider_api_key(ApiProvider::Openrouter, "test-key", &server.uri())
8449 .await
8450 .expect("mocked /models success should verify");
8451 }
8452
8453 #[tokio::test]
8454 async fn verify_provider_api_key_returns_status_and_unicode_body_without_panic() {
8455 let server = MockServer::start().await;
8456 Mock::given(method("GET"))
8457 .and(path("/v1/models"))
8458 .respond_with(ResponseTemplate::new(401).set_body_string("密钥无效"))
8459 .mount(&server)
8460 .await;
8461
8462 let err = verify_provider_api_key(ApiProvider::Openrouter, "bad-key", &server.uri())
8463 .await
8464 .expect_err("mocked /models failure should be reported");
8465
8466 assert!(err.contains("HTTP 401"), "status is preserved: {err}");
8467 assert!(err.contains("密钥无效"), "unicode body is preserved: {err}");
8468 }
8469
8470 #[test]
8471 fn opencode_go_client_rejects_messages_only_config_models() {
8472 let _ = rustls::crypto::ring::default_provider().install_default();
8473 for model in [
8474 "minimax-m3",
8475 "minimax-m2.7",
8476 "minimax-m2.5",
8477 "qwen3.7-max",
8478 "qwen3.7-plus",
8479 "qwen3.6-plus",
8480 ] {
8481 let config = Config {
8482 provider: Some("opencode-go".to_string()),
8483 providers: Some(ProvidersConfig {
8484 opencode_go: ProviderConfig {
8485 api_key: Some("test-key".to_string()),
8486 model: Some(model.to_string()),
8487 ..ProviderConfig::default()
8488 },
8489 ..ProvidersConfig::default()
8490 }),
8491 ..Config::default()
8492 };
8493 let err = DeepSeekClient::new(&config)
8494 .err()
8495 .expect("Messages-only model must fail before client construction");
8496 assert!(err.to_string().contains("Chat Completions"), "{err:#}");
8497 }
8498 }
8499
8500 #[tokio::test]
8501 async fn opencode_go_live_model_paths_keep_only_chat_completions_rows() {
8502 let server = MockServer::start().await;
8503 let mut rows: Vec<_> = crate::config::OPENCODE_GO_CHAT_MODELS
8504 .iter()
8505 .map(|id| json!({"id": id}))
8506 .collect();
8507 rows.extend([
8508 json!({"id": "minimax-m3"}),
8509 json!({"id": "minimax-m2.7"}),
8510 json!({"id": "minimax-m2.5"}),
8511 json!({"id": "qwen3.7-max"}),
8512 json!({"id": "qwen3.7-plus"}),
8513 json!({"id": "qwen3.6-plus"}),
8514 ]);
8515 mount_models_json(&server, 200, json!({"data": rows})).await;
8516 let client = opencode_go_client_for(&server);
8517
8518 let listed = client.list_models().await.expect("filtered model list");
8519 let listed: std::collections::BTreeSet<_> =
8520 listed.into_iter().map(|model| model.id).collect();
8521 let expected: std::collections::BTreeSet<_> = crate::config::OPENCODE_GO_CHAT_MODELS
8522 .iter()
8523 .map(|model| (*model).to_string())
8524 .collect();
8525 assert_eq!(listed, expected);
8526
8527 let delta = client.fetch_catalog_delta().await.expect("filtered delta");
8528 assert_eq!(delta.provider, "opencode-go");
8529 let delta_ids: std::collections::BTreeSet<_> = delta
8530 .offerings
8531 .iter()
8532 .map(|offering| offering.wire_model_id.clone())
8533 .collect();
8534 assert_eq!(delta_ids, expected);
8535 assert!(
8536 delta
8537 .offerings
8538 .iter()
8539 .all(|offering| offering.endpoint_key == "chat")
8540 );
8541 }
8542
8543 #[tokio::test]
8544 async fn telecomjs_live_catalog_keeps_cross_provider_metadata_unknown() {
8545 let server = MockServer::start().await;
8546 let ambiguous_id = codewhale_config::catalog::bundled_catalog_offerings()
8547 .into_iter()
8548 .find(|offering| {
8549 !offering.provider.eq_ignore_ascii_case("telecomjs")
8550 && !offering
8551 .wire_model_id
8552 .eq_ignore_ascii_case(DEFAULT_TELECOMJS_MODEL)
8553 && (offering.canonical_model.is_some()
8554 || offering.family.is_some()
8555 || offering.limit.is_some()
8556 || offering.cost.is_some()
8557 || offering.reasoning.is_some()
8558 || offering.tool_call.is_some())
8559 })
8560 .expect("bundled catalog should contain a metadata-bearing non-TelecomJS row")
8561 .wire_model_id;
8562 Mock::given(method("GET"))
8563 .and(path("/v1/models"))
8564 .and(header("authorization", "Bearer test-key"))
8565 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
8566 "data": [
8567 {"id": ambiguous_id.clone()},
8568 {"id": DEFAULT_TELECOMJS_MODEL}
8569 ]
8570 })))
8571 .mount(&server)
8572 .await;
8573
8574 let delta = telecomjs_client_for(&server)
8575 .fetch_catalog_delta()
8576 .await
8577 .expect("TelecomJS catalog delta");
8578 assert_eq!(delta.provider, "telecomjs");
8579 assert_eq!(delta.offerings.len(), 2);
8580
8581 let ambiguous = delta
8582 .offerings
8583 .iter()
8584 .find(|offering| offering.wire_model_id == ambiguous_id)
8585 .expect("ambiguous cross-provider id");
8586 assert!(!ambiguous.default_for_provider);
8587 assert_eq!(ambiguous.endpoint_key, "chat");
8588 assert_eq!(ambiguous.canonical_model, None);
8589 assert_eq!(ambiguous.family, None);
8590 assert_eq!(ambiguous.limit, None);
8591 assert_eq!(ambiguous.cost, None);
8592 assert_eq!(ambiguous.modalities, None);
8593 assert_eq!(ambiguous.attachment, None);
8594 assert_eq!(ambiguous.reasoning, None);
8595 assert_eq!(ambiguous.tool_call, None);
8596 assert_eq!(ambiguous.structured_output, None);
8597 assert!(ambiguous.reasoning_options.is_empty());
8598 assert!(matches!(ambiguous.source, CatalogSource::Live { .. }));
8599
8600 let default = delta
8601 .offerings
8602 .iter()
8603 .find(|offering| offering.wire_model_id == DEFAULT_TELECOMJS_MODEL)
8604 .expect("TelecomJS default row");
8605 assert!(default.default_for_provider);
8606 }
8607
8608 #[tokio::test]
8609 async fn fetch_catalog_delta_success_builds_scoped_secret_free_live_delta() {
8610 let server = MockServer::start().await;
8611 mount_models_json(
8612 &server,
8613 200,
8614 json!({"data": [
8615 {"id": "synthetic-model-alpha", "owned_by": "synthetic-owner"},
8616 {"id": "synthetic-model-beta"}
8617 ]}),
8618 )
8619 .await;
8620 let client = openrouter_client_for(&server);
8621
8622 let delta = client.fetch_catalog_delta().await.expect("delta");
8623 assert_eq!(delta.provider, "openrouter");
8624 assert_eq!(
8625 delta.base_url_fingerprint,
8626 base_url_fingerprint(&server.uri()),
8627 "delta is scoped to the base-URL fingerprint"
8628 );
8629 let ids: Vec<&str> = delta
8630 .offerings
8631 .iter()
8632 .map(|offering| offering.wire_model_id.as_str())
8633 .collect();
8634 assert!(ids.contains(&"synthetic-model-alpha"), "ids: {ids:?}");
8635 assert!(ids.contains(&"synthetic-model-beta"), "ids: {ids:?}");
8636 for offering in &delta.offerings {
8637 // Live rows carry honest provenance and no inferred facts/secrets.
8638 assert!(matches!(offering.source, CatalogSource::Live { .. }));
8639 assert_eq!(offering.canonical_model, None);
8640 assert_eq!(offering.cost, None);
8641 assert!(offering.reasoning.is_none());
8642 }
8643 }
8644
8645 #[tokio::test]
8646 async fn fetch_catalog_delta_maps_http_statuses_to_typed_errors() {
8647 for (status, expected) in [
8648 (401u16, CatalogRefreshError::Unauthorized),
8649 (403, CatalogRefreshError::Forbidden),
8650 (404, CatalogRefreshError::NotFound),
8651 (429, CatalogRefreshError::RateLimited),
8652 (500, CatalogRefreshError::Network),
8653 ] {
8654 let server = MockServer::start().await;
8655 mount_models_json(&server, status, json!({"error": "nope"})).await;
8656 let client = openrouter_client_for(&server);
8657 let err = client.fetch_catalog_delta().await.expect_err("should fail");
8658 assert_eq!(err, expected, "status {status} should map to {expected:?}");
8659 }
8660 }
8661
8662 #[tokio::test]
8663 async fn fetch_catalog_delta_maps_invalid_json_and_empty_list() {
8664 // Invalid JSON -> InvalidResponse.
8665 let server = MockServer::start().await;
8666 Mock::given(method("GET"))
8667 .and(path("/v1/models"))
8668 .respond_with(ResponseTemplate::new(200).set_body_string("not json"))
8669 .mount(&server)
8670 .await;
8671 let client = openrouter_client_for(&server);
8672 assert_eq!(
8673 client
8674 .fetch_catalog_delta()
8675 .await
8676 .expect_err("invalid json"),
8677 CatalogRefreshError::InvalidResponse
8678 );
8679
8680 // Empty list -> EmptyList.
8681 let server = MockServer::start().await;
8682 mount_models_json(&server, 200, json!({"data": []})).await;
8683 let client = openrouter_client_for(&server);
8684 assert_eq!(
8685 client.fetch_catalog_delta().await.expect_err("empty list"),
8686 CatalogRefreshError::EmptyList
8687 );
8688 }
8689
8690 #[tokio::test]
8691 async fn refresh_catalog_cache_records_success_then_preserves_rows_on_failure() {
8692 // First refresh succeeds and caches live rows.
8693 let server = MockServer::start().await;
8694 mount_models_json(
8695 &server,
8696 200,
8697 json!({"data": [{"id": "synthetic-model-gamma"}]}),
8698 )
8699 .await;
8700 let client = openrouter_client_for(&server);
8701 let mut cache = ProviderCatalogCache::new();
8702
8703 let status = client.refresh_catalog_cache(&mut cache, 3600).await;
8704 assert_eq!(status, CatalogStatus::Fresh);
8705 let fp = base_url_fingerprint(&server.uri());
8706 let cached = cache.get("openrouter", &fp).expect("cached entry");
8707 assert_eq!(cached.offerings.len(), 1);
8708 assert_eq!(cached.offerings[0].wire_model_id, "synthetic-model-gamma");
8709
8710 // A later failing refresh on the same base URL flips status to Failed
8711 // but PRESERVES the rows.
8712 server.reset().await;
8713 mount_models_json(&server, 401, json!({"error": "denied"})).await;
8714 let status = client.refresh_catalog_cache(&mut cache, 3600).await;
8715 assert!(matches!(
8716 status,
8717 CatalogStatus::Failed {
8718 reason: CatalogRefreshError::Unauthorized,
8719 ..
8720 }
8721 ));
8722 let cached = cache.get("openrouter", &fp).expect("entry still present");
8723 assert_eq!(
8724 cached.offerings.len(),
8725 1,
8726 "rows from the prior success must survive a failed refresh"
8727 );
8728 assert!(matches!(cached.status, CatalogStatus::Failed { .. }));
8729
8730 // #4139: failed/stale rows must still publish into ProviderLake so
8731 // pickers keep live coverage instead of dropping back to bundled-only.
8732 let visible = cache.all_visible_offerings(now_unix());
8733 assert_eq!(visible.len(), 1);
8734 assert_eq!(visible[0].wire_model_id, "synthetic-model-gamma");
8735 assert!(
8736 cache.all_fresh_offerings(now_unix()).is_empty(),
8737 "Failed entries are not fresh, but they remain visible"
8738 );
8739 }
8740
8741 #[tokio::test]
8742 async fn live_catalog_is_scoped_by_base_url_fingerprint() {
8743 // Same provider, two different base URLs -> two distinct cache scopes.
8744 let server_a = MockServer::start().await;
8745 mount_models_json(&server_a, 200, json!({"data": [{"id": "synthetic-a"}]})).await;
8746 let server_b = MockServer::start().await;
8747 mount_models_json(&server_b, 200, json!({"data": [{"id": "synthetic-b"}]})).await;
8748
8749 let mut cache = ProviderCatalogCache::new();
8750 openrouter_client_for(&server_a)
8751 .refresh_catalog_cache(&mut cache, 3600)
8752 .await;
8753 openrouter_client_for(&server_b)
8754 .refresh_catalog_cache(&mut cache, 3600)
8755 .await;
8756
8757 let fp_a = base_url_fingerprint(&server_a.uri());
8758 let fp_b = base_url_fingerprint(&server_b.uri());
8759 assert_ne!(
8760 fp_a, fp_b,
8761 "different base URLs must fingerprint differently"
8762 );
8763 assert_eq!(
8764 cache.get("openrouter", &fp_a).expect("a").offerings[0].wire_model_id,
8765 "synthetic-a"
8766 );
8767 assert_eq!(
8768 cache.get("openrouter", &fp_b).expect("b").offerings[0].wire_model_id,
8769 "synthetic-b"
8770 );
8771 }
8772
8773 #[tokio::test]
8774 async fn static_rows_survive_a_live_refresh_failure() {
8775 // Bundled/static rows compile through even when the live layer is empty
8776 // (the state after a failed refresh with no prior success).
8777 let server = MockServer::start().await;
8778 mount_models_json(&server, 503, json!({"error": "down"})).await;
8779 let client = openrouter_client_for(&server);
8780 let mut cache = ProviderCatalogCache::new();
8781 let status = client.refresh_catalog_cache(&mut cache, 3600).await;
8782 assert!(matches!(status, CatalogStatus::Failed { .. }));
8783
8784 let static_row = CatalogOffering {
8785 provider: "openrouter".to_string(),
8786 wire_model_id: "synthetic-static".to_string(),
8787 endpoint_key: "chat".to_string(),
8788 ..CatalogOffering::default()
8789 };
8790 let fp = base_url_fingerprint(&server.uri());
8791 let fresh_live: Vec<CatalogOffering> = cache
8792 .get("openrouter", &fp)
8793 .filter(|entry| entry.is_fresh(now_unix()))
8794 .map(|entry| entry.offerings.clone())
8795 .unwrap_or_default();
8796 let snapshot = codewhale_config::catalog::CatalogCompiler::new()
8797 .with_bundled(vec![static_row])
8798 .with_live(fresh_live)
8799 .compile();
8800 assert!(
8801 snapshot
8802 .offerings
8803 .iter()
8804 .any(|offering| offering.wire_model_id == "synthetic-static"),
8805 "static fallback row must remain available after a failed refresh"
8806 );
8807 }
8808
8809 #[test]
8810 fn parse_usage_reads_deepseek_cache_and_reasoning_tokens() {
8811 let usage = parse_usage(Some(&json!({
8812 "prompt_tokens": 100,
8813 "completion_tokens": 20,
8814 "prompt_cache_hit_tokens": 70,
8815 "prompt_cache_miss_tokens": 30,
8816 "completion_tokens_details": {
8817 "reasoning_tokens": 12
8818 }
8819 })));
8820
8821 assert_eq!(usage.input_tokens, 100);
8822 assert_eq!(usage.output_tokens, 20);
8823 assert_eq!(usage.prompt_cache_hit_tokens, Some(70));
8824 assert_eq!(usage.prompt_cache_miss_tokens, Some(30));
8825 assert_eq!(usage.reasoning_tokens, Some(12));
8826 }
8827
8828 #[test]
8829 fn parse_usage_saturates_every_u64_token_field() {
8830 let usage = parse_usage(Some(&json!({
8831 "input_tokens": u64::MAX,
8832 "output_tokens": u64::MAX,
8833 "prompt_cache_hit_tokens": u64::MAX,
8834 "prompt_cache_miss_tokens": u64::MAX,
8835 "completion_tokens_details": { "reasoning_tokens": u64::MAX },
8836 "server_tool_use": {
8837 "code_execution_requests": u64::MAX,
8838 "tool_search_requests": u64::MAX
8839 }
8840 })));
8841 assert_eq!(usage.input_tokens, u32::MAX);
8842 assert_eq!(usage.output_tokens, u32::MAX);
8843 assert_eq!(usage.prompt_cache_hit_tokens, Some(u32::MAX));
8844 assert_eq!(usage.prompt_cache_miss_tokens, Some(u32::MAX));
8845 assert_eq!(usage.reasoning_tokens, Some(u32::MAX));
8846 let server = usage.server_tool_use.expect("server usage");
8847 assert_eq!(server.code_execution_requests, Some(u32::MAX));
8848 assert_eq!(server.tool_search_requests, Some(u32::MAX));
8849 }
8850
8851 #[test]
8852 fn client_route_envelope_freezes_saved_minimax_billing_mode_and_wire_model() {
8853 let _ = rustls::crypto::ring::default_provider().install_default();
8854 let config = Config {
8855 provider: Some("minimax".to_string()),
8856 providers: Some(ProvidersConfig {
8857 minimax: ProviderConfig {
8858 api_key: Some("test-key".to_string()),
8859 mode: Some("pay-as-you-go".to_string()),
8860 ..ProviderConfig::default()
8861 },
8862 ..ProvidersConfig::default()
8863 }),
8864 ..Config::default()
8865 };
8866 let client = DeepSeekClient::new(&config).expect("MiniMax client");
8867 let dispatched_at =
8868 chrono::DateTime::<chrono::Utc>::from_timestamp(1_234, 0).expect("timestamp");
8869 let route = client.effective_route_envelope("MiniMax-M3", dispatched_at);
8870
8871 assert_eq!(route.provider, ApiProvider::Minimax);
8872 assert_eq!(route.provider_identity, "minimax");
8873 assert_eq!(route.model, "MiniMax-M3");
8874 assert_eq!(
8875 route.billing_surface.as_deref(),
8876 Some(crate::pricing::MINIMAX_PAYG_BILLING_SURFACE)
8877 );
8878 assert_eq!(
8879 route.billing_mode,
8880 crate::cost_status::RouteBillingMode::Metered
8881 );
8882 assert_eq!(route.dispatched_at.timestamp(), 1_234);
8883 }
8884
8885 /// Real-shaped Chat-Completions usage payloads from the three providers most
8886 /// likely to report reasoning tokens, carried end-to-end into pricing.
8887 ///
8888 /// Two invariants hold for every fixture: `reasoning_tokens <= output_tokens`,
8889 /// and pricing never adds reasoning on top of output — dropping the reasoning
8890 /// field entirely must not change the cost by a single cent.
8891 #[test]
8892 fn reasoning_parser_fixtures_never_exceed_or_add_to_billable_output() {
8893 use crate::config::ApiProvider;
8894 use crate::pricing::{calculate_turn_cost_estimate_for_provider, token_usage_for_pricing};
8895
8896 // (label, provider, model, payload)
8897 let fixtures: [(&str, ApiProvider, &str, serde_json::Value); 3] = [
8898 (
8899 "moonshot",
8900 ApiProvider::Moonshot,
8901 "kimi-k2.7-code",
8902 json!({
8903 "prompt_tokens": 30_000,
8904 "completion_tokens": 2_400,
8905 "total_tokens": 32_400,
8906 "prompt_tokens_details": { "cached_tokens": 24_000 },
8907 "completion_tokens_details": { "reasoning_tokens": 1_900 }
8908 }),
8909 ),
8910 (
8911 "minimax",
8912 ApiProvider::Minimax,
8913 "minimax-m3",
8914 json!({
8915 "prompt_tokens": 12_000,
8916 "completion_tokens": 3_000,
8917 "total_tokens": 15_000,
8918 "prompt_tokens_details": { "cached_tokens": 4_000 },
8919 "completion_tokens_details": { "reasoning_tokens": 2_950 }
8920 }),
8921 ),
8922 (
8923 "openrouter",
8924 ApiProvider::Openrouter,
8925 "qwen/qwen3.7-plus",
8926 json!({
8927 "prompt_tokens": 8_000,
8928 "completion_tokens": 1_500,
8929 "total_tokens": 9_500,
8930 "prompt_tokens_details": { "cached_tokens": 2_000 },
8931 "completion_tokens_details": { "reasoning_tokens": 1_500 }
8932 }),
8933 ),
8934 ];
8935
8936 for (label, provider, model, payload) in fixtures {
8937 let usage = parse_usage(Some(&payload));
8938 let reasoning = usage.reasoning_tokens.expect("fixture reports reasoning");
8939
8940 // Invariant 1: reasoning is a subset of the billed completion count.
8941 assert!(
8942 reasoning <= usage.output_tokens,
8943 "{label}: reasoning {reasoning} exceeds output {}",
8944 usage.output_tokens
8945 );
8946 // Billable output is exactly the reported completion count.
8947 let classes = token_usage_for_pricing(&usage);
8948 assert_eq!(
8949 classes.output,
8950 u64::from(usage.output_tokens),
8951 "{label}: reasoning leaked into billable output"
8952 );
8953
8954 // Invariant 2: pricing does not add reasoning a second time. The same
8955 // usage with the reasoning field removed must cost the same.
8956 let without = crate::models::Usage {
8957 reasoning_tokens: None,
8958 ..usage.clone()
8959 };
8960 assert_eq!(
8961 calculate_turn_cost_estimate_for_provider(provider, model, &usage),
8962 calculate_turn_cost_estimate_for_provider(provider, model, &without),
8963 "{label}: reasoning changed the price"
8964 );
8965 }
8966 }
8967
8968 /// A payload claiming more reasoning than output contradicts the subset
8969 /// invariant. That is broken telemetry, so the field is discarded — and it
8970 /// must never become extra billable output.
8971 #[test]
8972 fn pathological_reasoning_above_output_is_rejected_not_billed() {
8973 let usage = parse_usage(Some(&json!({
8974 "prompt_tokens": 1_000,
8975 "completion_tokens": 100,
8976 "completion_tokens_details": { "reasoning_tokens": 5_000 }
8977 })));
8978
8979 assert_eq!(usage.output_tokens, 100, "output stays as reported");
8980 assert_eq!(
8981 usage.reasoning_tokens, None,
8982 "impossible reasoning telemetry is dropped rather than trusted"
8983 );
8984 let classes = crate::pricing::token_usage_for_pricing(&usage);
8985 assert_eq!(classes.output, 100);
8986
8987 // `completion_tokens: 0` with reasoning present is the *legitimate*
8988 // shape this filter must not break: providers that report only reasoning
8989 // set output from it, keeping reasoning == output.
8990 let zero_output = parse_usage(Some(&json!({
8991 "prompt_tokens": 1_000,
8992 "completion_tokens": 0,
8993 "completion_tokens_details": { "reasoning_tokens": 12 }
8994 })));
8995 assert_eq!(zero_output.output_tokens, 12);
8996 assert_eq!(zero_output.reasoning_tokens, Some(12));
8997 }
8998
8999 #[test]
9000 fn parse_usage_counts_reasoning_tokens_when_completion_tokens_are_zero() {
9001 let usage = parse_usage(Some(&json!({
9002 "prompt_tokens": 100,
9003 "completion_tokens": 0,
9004 "completion_tokens_details": {
9005 "reasoning_tokens": 12
9006 }
9007 })));
9008
9009 assert_eq!(usage.input_tokens, 100);
9010 assert_eq!(usage.output_tokens, 12);
9011 assert_eq!(usage.reasoning_tokens, Some(12));
9012 assert!(
9013 crate::pricing::calculate_turn_cost_from_usage("deepseek-v4-pro", &usage)
9014 .expect("DeepSeek V4 Pro pricing should apply")
9015 > 0.0
9016 );
9017 }
9018
9019 #[test]
9020 fn parse_usage_derives_completion_tokens_from_total_tokens_when_needed() {
9021 let usage = parse_usage(Some(&json!({
9022 "prompt_tokens": 100,
9023 "total_tokens": 125,
9024 "prompt_cache_hit_tokens": 70,
9025 "prompt_cache_miss_tokens": 30
9026 })));
9027
9028 assert_eq!(usage.input_tokens, 100);
9029 assert_eq!(usage.output_tokens, 25);
9030 assert_eq!(usage.prompt_cache_hit_tokens, Some(70));
9031 assert_eq!(usage.prompt_cache_miss_tokens, Some(30));
9032 }
9033
9034 #[test]
9035 fn parse_usage_reads_v4_prompt_tokens_details_cached_tokens() {
9036 let usage = parse_usage(Some(&json!({
9037 "prompt_tokens": 4000,
9038 "completion_tokens": 20,
9039 "prompt_tokens_details": {
9040 "cached_tokens": 3000
9041 }
9042 })));
9043
9044 assert_eq!(usage.input_tokens, 4000);
9045 assert_eq!(usage.output_tokens, 20);
9046 assert_eq!(usage.prompt_cache_hit_tokens, Some(3000));
9047 assert_eq!(usage.prompt_cache_miss_tokens, Some(1000));
9048 }
9049
9050 #[test]
9051 fn parse_usage_infers_cache_miss_from_selected_hit_source() {
9052 let usage = parse_usage(Some(&json!({
9053 "prompt_tokens": 4000,
9054 "completion_tokens": 20,
9055 "prompt_cache_hit_tokens": 3000,
9056 "prompt_tokens_details": {
9057 "cached_tokens": 1000
9058 }
9059 })));
9060
9061 assert_eq!(usage.input_tokens, 4000);
9062 assert_eq!(usage.prompt_cache_hit_tokens, Some(3000));
9063 assert_eq!(usage.prompt_cache_miss_tokens, Some(1000));
9064 }
9065
9066 #[test]
9067 fn sanitize_thinking_mode_counts_reasoning_replay_across_assistant_turns() {
9068 // Multi-turn body that mimics two prior tool-calling rounds: each
9069 // assistant message carries its `reasoning_content`. The sanitizer
9070 // should keep all of them and the count helper should tally bytes
9071 // across every assistant message.
9072 let mut body = json!({
9073 "model": "deepseek-v4-pro",
9074 "messages": [
9075 { "role": "system", "content": "you are helpful" },
9076 { "role": "user", "content": "step 1" },
9077 {
9078 "role": "assistant",
9079 "content": "",
9080 "reasoning_content": "I need to call tool A first.",
9081 "tool_calls": [{ "id": "1", "type": "function" }]
9082 },
9083 { "role": "tool", "tool_call_id": "1", "content": "ok" },
9084 {
9085 "role": "assistant",
9086 "content": "",
9087 "reasoning_content": "Now I call tool B.",
9088 "tool_calls": [{ "id": "2", "type": "function" }]
9089 },
9090 { "role": "tool", "tool_call_id": "2", "content": "ok" },
9091 { "role": "user", "content": "step 2" }
9092 ]
9093 });
9094
9095 let approx_tokens = sanitize_thinking_mode_messages(
9096 &mut body,
9097 "deepseek-v4-pro",
9098 Some("max"),
9099 ApiProvider::Deepseek,
9100 )
9101 .expect("multi-turn thinking-mode conversation should report replay tokens");
9102 // ~4 chars/token; 46 bytes of reasoning -> 11 tokens.
9103 assert_eq!(approx_tokens, 11);
9104
9105 let chars = count_reasoning_replay_chars(&body);
9106 // "I need to call tool A first." (28) + "Now I call tool B." (18) = 46
9107 assert_eq!(chars, 46);
9108
9109 // No assistant messages should have lost or had their reasoning_content blanked.
9110 let messages = body["messages"].as_array().unwrap();
9111 let assistant_with_reasoning: usize = messages
9112 .iter()
9113 .filter(|m| m["role"] == "assistant")
9114 .filter(|m| {
9115 m["reasoning_content"]
9116 .as_str()
9117 .is_some_and(|s| !s.is_empty())
9118 })
9119 .count();
9120 assert_eq!(assistant_with_reasoning, 2);
9121 }
9122
9123 /// Issue #30: when no thinking-mode replay applies (non-thinking model or
9124 /// empty conversation), the sanitizer returns `None` so the footer chip
9125 /// stays hidden.
9126 #[test]
9127 fn sanitize_thinking_mode_returns_none_for_non_thinking_model() {
9128 let mut body = json!({
9129 "model": "deepseek-v4-flash",
9130 "messages": [
9131 { "role": "user", "content": "hi" }
9132 ]
9133 });
9134 let result = sanitize_thinking_mode_messages(
9135 &mut body,
9136 "deepseek-v4-flash",
9137 None,
9138 ApiProvider::Deepseek,
9139 );
9140 // reasoning_effort is None → no thinking injection, result is None
9141 assert!(result.is_none());
9142 }
9143
9144 #[test]
9145 fn sanitize_thinking_mode_counts_substituted_placeholder() {
9146 // An assistant tool-call message is missing reasoning_content; the
9147 // sanitizer must inject the placeholder, and the count helper must
9148 // include the placeholder in the total (since it's in the wire
9149 // payload that ships to DeepSeek).
9150 let mut body = json!({
9151 "model": "deepseek-v4-pro",
9152 "messages": [
9153 { "role": "user", "content": "hi" },
9154 {
9155 "role": "assistant",
9156 "content": "",
9157 "tool_calls": [{ "id": "1", "type": "function" }]
9158 }
9159 ]
9160 });
9161
9162 sanitize_thinking_mode_messages(
9163 &mut body,
9164 "deepseek-v4-pro",
9165 Some("max"),
9166 ApiProvider::Deepseek,
9167 );
9168
9169 let chars = count_reasoning_replay_chars(&body);
9170 // "(reasoning omitted)" is 19 bytes.
9171 assert_eq!(chars, 19);
9172 }
9173
9174 #[test]
9175 fn sanitize_thinking_mode_skips_generic_openai_provider() {
9176 // #1542 intent (narrowed by #1739/#1694): the sanitizer only skips for
9177 // a *genuine non-DeepSeek* model on the generic openai provider. A
9178 // DeepSeek reasoning model on the openai provider still gets sanitized
9179 // (see chat.rs `deepseek_model_on_openai_provider_still_replays_*`).
9180 let mut body = json!({
9181 "model": "qwen3-coder",
9182 "messages": [
9183 { "role": "user", "content": "hi" },
9184 {
9185 "role": "assistant",
9186 "content": "",
9187 "tool_calls": [{ "id": "1", "type": "function" }]
9188 }
9189 ]
9190 });
9191
9192 let result = sanitize_thinking_mode_messages(
9193 &mut body,
9194 "qwen3-coder",
9195 Some("max"),
9196 ApiProvider::Openai,
9197 );
9198
9199 assert!(result.is_none());
9200 let assistant = body["messages"]
9201 .as_array()
9202 .and_then(|messages| {
9203 messages
9204 .iter()
9205 .find(|message| message["role"] == "assistant")
9206 })
9207 .expect("assistant message");
9208 assert!(
9209 assistant.get("reasoning_content").is_none(),
9210 "generic OpenAI-compatible provider payload must not get reasoning_content (#1542)"
9211 );
9212 }
9213
9214 #[test]
9215 fn sanitize_thinking_mode_keeps_tool_call_placeholder_after_new_user_turn() {
9216 let mut body = json!({
9217 "model": "deepseek-v4-pro",
9218 "messages": [
9219 { "role": "user", "content": "step 1" },
9220 {
9221 "role": "assistant",
9222 "content": "",
9223 "tool_calls": [{ "id": "1", "type": "function" }]
9224 },
9225 { "role": "tool", "tool_call_id": "1", "content": "ok" },
9226 { "role": "user", "content": "step 2" }
9227 ]
9228 });
9229
9230 sanitize_thinking_mode_messages(
9231 &mut body,
9232 "deepseek-v4-pro",
9233 Some("max"),
9234 ApiProvider::Deepseek,
9235 );
9236
9237 let messages = body["messages"].as_array().unwrap();
9238 let assistant = messages
9239 .iter()
9240 .find(|m| m["role"] == "assistant")
9241 .expect("assistant tool-call message");
9242 assert_eq!(
9243 assistant.get("reasoning_content").and_then(Value::as_str),
9244 Some("(reasoning omitted)")
9245 );
9246 }
9247
9248 #[test]
9249 fn token_bucket_enforces_delay_when_empty() {
9250 let now = Instant::now();
9251 let mut bucket = TokenBucket {
9252 enabled: true,
9253 capacity: 1.0,
9254 tokens: 1.0,
9255 refill_per_sec: 2.0,
9256 last_refill: now,
9257 };
9258
9259 assert!(bucket.delay_until_available(1.0).is_none());
9260 let delay = bucket
9261 .delay_until_available(1.0)
9262 .expect("bucket should require refill delay");
9263 assert!(
9264 delay >= Duration::from_millis(400) && delay <= Duration::from_millis(600),
9265 "unexpected refill delay: {delay:?}"
9266 );
9267 }
9268
9269 #[test]
9270 fn stream_buffer_pool_reuses_released_buffers() {
9271 let mut first = acquire_stream_buffer();
9272 first.extend_from_slice(b"hello");
9273 let released_capacity = first.capacity();
9274 release_stream_buffer(first);
9275
9276 let second = acquire_stream_buffer();
9277 assert!(second.is_empty());
9278 assert!(
9279 second.capacity() >= released_capacity,
9280 "pooled buffer capacity should be reused"
9281 );
9282 }
9283
9284 #[test]
9285 fn base_url_security_rejects_insecure_non_local_http() {
9286 let _lock = ALLOW_INSECURE_HTTP_ENV_LOCK.lock().unwrap();
9287 let _guard = AllowInsecureHttpEnvGuard::capture();
9288 unsafe { std::env::remove_var(ALLOW_INSECURE_HTTP_ENV) };
9289
9290 let err = validate_base_url_security("http://api.deepseek.com")
9291 .expect_err("non-local insecure HTTP should be rejected");
9292 assert!(err.to_string().contains("Refusing insecure base URL"));
9293 }
9294
9295 #[test]
9296 fn base_url_security_errors_redact_sensitive_url_parts() {
9297 let _lock = ALLOW_INSECURE_HTTP_ENV_LOCK.lock().unwrap();
9298 let _guard = AllowInsecureHttpEnvGuard::capture();
9299 unsafe { std::env::remove_var(ALLOW_INSECURE_HTTP_ENV) };
9300
9301 let err =
9302 validate_base_url_security("http://user:secret@example.com/v1?api_key=sk-test&ok=1")
9303 .expect_err("non-local insecure HTTP should be rejected");
9304 let message = err.to_string();
9305
9306 assert!(message.contains("http://***:***@example.com/v1?api_key=***&ok=1"));
9307 assert!(!message.contains("user:secret"));
9308 assert!(!message.contains("sk-test"));
9309 }
9310
9311 #[test]
9312 fn base_url_security_allows_localhost_http() {
9313 let _lock = ALLOW_INSECURE_HTTP_ENV_LOCK.lock().unwrap();
9314 let _guard = AllowInsecureHttpEnvGuard::capture();
9315 unsafe { std::env::remove_var(ALLOW_INSECURE_HTTP_ENV) };
9316
9317 assert!(validate_base_url_security("http://localhost:8080").is_ok());
9318 assert!(validate_base_url_security("http://127.0.0.1:8080").is_ok());
9319 }
9320
9321 #[test]
9322 fn base_url_security_allows_non_local_http_with_explicit_opt_in() {
9323 let _lock = ALLOW_INSECURE_HTTP_ENV_LOCK.lock().unwrap();
9324 let _guard = AllowInsecureHttpEnvGuard::capture();
9325 unsafe { std::env::set_var(ALLOW_INSECURE_HTTP_ENV, "1") };
9326
9327 assert!(validate_base_url_security("http://192.168.0.110:8000/v1").is_ok());
9328 }
9329
9330 /// Serialize tests that mutate `DEEPSEEK_ALLOW_INSECURE_HTTP`; env vars are
9331 /// process-global and would otherwise leak across security checks.
9332 static ALLOW_INSECURE_HTTP_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
9333
9334 struct AllowInsecureHttpEnvGuard {
9335 prior: Option<std::ffi::OsString>,
9336 prior_legacy: Option<std::ffi::OsString>,
9337 }
9338 impl AllowInsecureHttpEnvGuard {
9339 fn capture() -> Self {
9340 let guard = Self {
9341 prior: std::env::var_os(ALLOW_INSECURE_HTTP_ENV),
9342 prior_legacy: std::env::var_os(LEGACY_ALLOW_INSECURE_HTTP_ENV),
9343 };
9344 // Clear the legacy alias so ambient shell state cannot satisfy
9345 // the CODEWHALE-first fallback chain behind a test's back.
9346 unsafe { std::env::remove_var(LEGACY_ALLOW_INSECURE_HTTP_ENV) };
9347 guard
9348 }
9349 }
9350 impl Drop for AllowInsecureHttpEnvGuard {
9351 fn drop(&mut self) {
9352 match &self.prior {
9353 Some(v) => unsafe { std::env::set_var(ALLOW_INSECURE_HTTP_ENV, v) },
9354 None => unsafe { std::env::remove_var(ALLOW_INSECURE_HTTP_ENV) },
9355 }
9356 match &self.prior_legacy {
9357 Some(v) => unsafe { std::env::set_var(LEGACY_ALLOW_INSECURE_HTTP_ENV, v) },
9358 None => unsafe { std::env::remove_var(LEGACY_ALLOW_INSECURE_HTTP_ENV) },
9359 }
9360 }
9361 }
9362
9363 #[test]
9364 fn connection_health_degrades_and_recovers() {
9365 let now = Instant::now();
9366 let mut health = ConnectionHealth::default();
9367 assert_eq!(health.state, ConnectionState::Healthy);
9368
9369 apply_request_failure(&mut health, now);
9370 assert_eq!(health.state, ConnectionState::Healthy);
9371
9372 apply_request_failure(&mut health, now + Duration::from_millis(1));
9373 assert_eq!(health.state, ConnectionState::Degraded);
9374 assert_eq!(health.consecutive_failures, 2);
9375
9376 let recovered = apply_request_success(&mut health, now + Duration::from_secs(1));
9377 assert!(recovered);
9378 assert_eq!(health.state, ConnectionState::Healthy);
9379 assert_eq!(health.consecutive_failures, 0);
9380 }
9381
9382 #[test]
9383 fn recovery_probe_respects_cooldown() {
9384 let now = Instant::now();
9385 let mut health = ConnectionHealth {
9386 state: ConnectionState::Degraded,
9387 ..ConnectionHealth::default()
9388 };
9389
9390 assert!(mark_recovery_probe_if_due(&mut health, now));
9391 assert_eq!(health.state, ConnectionState::Recovering);
9392 assert!(!mark_recovery_probe_if_due(
9393 &mut health,
9394 now + Duration::from_secs(1)
9395 ));
9396 assert!(mark_recovery_probe_if_due(
9397 &mut health,
9398 now + RECOVERY_PROBE_COOLDOWN + Duration::from_millis(1)
9399 ));
9400 }
9401
9402 // === #103 Phase 2: HTTP/1 escape hatch ===================================
9403
9404 /// Serialize tests that mutate `DEEPSEEK_FORCE_HTTP1` so they don't race
9405 /// against each other — env vars are process-global.
9406 static FORCE_HTTP1_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
9407
9408 struct ForceHttp1EnvGuard {
9409 prior: Option<std::ffi::OsString>,
9410 }
9411 impl ForceHttp1EnvGuard {
9412 fn capture() -> Self {
9413 Self {
9414 prior: std::env::var_os("DEEPSEEK_FORCE_HTTP1"),
9415 }
9416 }
9417 }
9418 impl Drop for ForceHttp1EnvGuard {
9419 fn drop(&mut self) {
9420 // Safety: scoped to test process; reverts to the captured value.
9421 match &self.prior {
9422 Some(v) => unsafe { std::env::set_var("DEEPSEEK_FORCE_HTTP1", v) },
9423 None => unsafe { std::env::remove_var("DEEPSEEK_FORCE_HTTP1") },
9424 }
9425 }
9426 }
9427
9428 #[test]
9429 fn force_http1_unset_is_false() {
9430 let _lock = FORCE_HTTP1_ENV_LOCK.lock().unwrap();
9431 let _guard = ForceHttp1EnvGuard::capture();
9432 unsafe { std::env::remove_var("DEEPSEEK_FORCE_HTTP1") };
9433 assert!(!force_http1_from_env());
9434 }
9435
9436 #[test]
9437 fn force_http1_truthy_values() {
9438 let _lock = FORCE_HTTP1_ENV_LOCK.lock().unwrap();
9439 let _guard = ForceHttp1EnvGuard::capture();
9440 for value in ["1", "true", "True", "YES", "on", " 1 "] {
9441 // Safety: serialized by FORCE_HTTP1_ENV_LOCK; reverted by guard.
9442 unsafe { std::env::set_var("DEEPSEEK_FORCE_HTTP1", value) };
9443 assert!(
9444 force_http1_from_env(),
9445 "{value:?} should be parsed as truthy",
9446 );
9447 }
9448 }
9449
9450 #[test]
9451 fn force_http1_falsy_values() {
9452 let _lock = FORCE_HTTP1_ENV_LOCK.lock().unwrap();
9453 let _guard = ForceHttp1EnvGuard::capture();
9454 for value in ["0", "false", "no", "off", "", "garbage", "2"] {
9455 unsafe { std::env::set_var("DEEPSEEK_FORCE_HTTP1", value) };
9456 assert!(
9457 !force_http1_from_env(),
9458 "{value:?} should NOT be parsed as truthy"
9459 );
9460 }
9461 }
9462
9463 #[test]
9464 fn api_url_with_suffix_strips_version_before_chat_suffix() {
9465 assert_eq!(
9466 api_url_with_suffix(
9467 "https://api.example.com/v1",
9468 "chat/completions",
9469 Some("/chat/completions")
9470 ),
9471 "https://api.example.com/chat/completions"
9472 );
9473 assert_eq!(
9474 api_url_with_suffix(
9475 "https://api.example.com/beta",
9476 "chat/completions",
9477 Some("/chat/completions")
9478 ),
9479 "https://api.example.com/chat/completions"
9480 );
9481 }
9482
9483 #[test]
9484 fn api_url_with_suffix_handles_leading_slash() {
9485 assert_eq!(
9486 api_url_with_suffix(
9487 "https://api.example.com/v1",
9488 "chat/completions",
9489 Some("chat/completions")
9490 ),
9491 "https://api.example.com/chat/completions"
9492 );
9493 }
9494
9495 #[test]
9496 fn api_url_with_suffix_ignores_suffix_for_models() {
9497 assert_eq!(
9498 api_url_with_suffix(
9499 "https://api.example.com/v1",
9500 "models",
9501 Some("/chat/completions")
9502 ),
9503 "https://api.example.com/v1/models"
9504 );
9505 }
9506
9507 #[test]
9508 fn api_url_with_suffix_ignores_suffix_for_beta_paths() {
9509 assert_eq!(
9510 api_url_with_suffix(
9511 "https://api.example.com/v1",
9512 "beta/completions",
9513 Some("/chat/completions")
9514 ),
9515 "https://api.example.com/beta/completions"
9516 );
9517 }
9518
9519 #[test]
9520 fn api_url_with_suffix_default_behavior_without_suffix() {
9521 assert_eq!(
9522 api_url_with_suffix("https://api.deepseek.com", "chat/completions", None),
9523 "https://api.deepseek.com/v1/chat/completions"
9524 );
9525 }
9526
9527 #[test]
9528 fn redact_url_for_display_masks_userinfo_and_sensitive_query_values() {
9529 let redacted = redact_url_for_display(
9530 "https://user:secret@example.com/v1?api_key=sk-test&region=us&refresh-token=abc",
9531 );
9532
9533 assert_eq!(
9534 redacted,
9535 "https://***:***@example.com/v1?api_key=***&region=us&refresh-token=***"
9536 );
9537 }
9538
9539 #[test]
9540 fn take_sse_line_preserves_multibyte_split_across_reads() {
9541 // "你好" streamed so the 3-byte '好' straddles a read boundary.
9542 let full = "data: 你好\n";
9543 let bytes = full.as_bytes();
9544 let split = bytes.len() - 2; // mid '好'
9545 let mut buffer: Vec<u8> = Vec::new();
9546 // First read: no complete line yet.
9547 buffer.extend_from_slice(&bytes[..split]);
9548 assert_eq!(take_sse_line(&mut buffer), None);
9549 // Second read completes the line; '好' must be intact, not U+FFFD.
9550 buffer.extend_from_slice(&bytes[split..]);
9551 let line = take_sse_line(&mut buffer).expect("a complete line");
9552 assert_eq!(line, "data: 你好");
9553 assert!(!line.contains('\u{FFFD}'), "multibyte char was corrupted");
9554 assert_eq!(extract_sse_data_value(&line), Some("你好"));
9555 // Buffer fully drained.
9556 assert!(buffer.is_empty());
9557 }
9558
9559 #[test]
9560 fn take_sse_line_returns_none_without_newline() {
9561 let mut buffer = b"data: partial".to_vec();
9562 assert_eq!(take_sse_line(&mut buffer), None);
9563 assert_eq!(buffer, b"data: partial");
9564 }
9565
9566 #[test]
9567 fn extract_sse_data_value_accepts_optional_space() {
9568 assert_eq!(
9569 extract_sse_data_value("data: {\"ok\":true}"),
9570 Some("{\"ok\":true}")
9571 );
9572 assert_eq!(
9573 extract_sse_data_value("data:{\"ok\":true}"),
9574 Some("{\"ok\":true}")
9575 );
9576 }
9577
9578 #[test]
9579 fn extract_sse_data_value_handles_done_marker() {
9580 assert_eq!(extract_sse_data_value("data: [DONE]"), Some("[DONE]"));
9581 assert_eq!(extract_sse_data_value("data:[DONE]"), Some("[DONE]"));
9582 }
9583
9584 #[test]
9585 fn extract_sse_data_value_rejects_non_data_lines() {
9586 assert_eq!(extract_sse_data_value("event: message"), None);
9587 assert_eq!(extract_sse_data_value(": heartbeat"), None);
9588 }
9589
9590 /// Build a DeepSeek config with an inline key/base URL plus the resolved
9591 /// runtime route for it. `RouteResolver` (reached through
9592 /// `resolve_runtime_route`) is the only producer of `ReadyRouteCandidate`,
9593 /// so we mint candidates the same way the engine does at switch time.
9594 fn deepseek_route_for_test(
9595 base_url: &str,
9596 model: &str,
9597 ) -> (Config, crate::route_runtime::ResolvedRuntimeRoute) {
9598 let config = Config {
9599 provider: Some("deepseek".to_string()),
9600 api_key: Some("ds-test".to_string()),
9601 base_url: Some(base_url.to_string()),
9602 default_text_model: Some(model.to_string()),
9603 ..Config::default()
9604 };
9605 let route = crate::route_runtime::resolve_runtime_route(
9606 &config,
9607 ApiProvider::Deepseek,
9608 Some(model),
9609 )
9610 .expect("deepseek route should resolve");
9611 (config, route)
9612 }
9613
9614 #[test]
9615 fn from_candidate_uses_candidate_base_url_and_wire_model() {
9616 let (_config, route) =
9617 deepseek_route_for_test("https://route.example.com/v1", "deepseek-v4-pro");
9618
9619 let client = DeepSeekClient::from_candidate(&route.config, &route.candidate)
9620 .expect("client should construct from candidate");
9621
9622 // The transport is bound to the candidate, not re-derived from Config.
9623 assert_eq!(client.base_url, route.candidate.endpoint().base_url);
9624 assert_eq!(
9625 client.default_model,
9626 route.candidate.wire_model_id().as_str()
9627 );
9628 }
9629
9630 #[test]
9631 fn from_candidate_matches_new_when_config_agrees() {
9632 // For a normal route, the resolver writes the candidate's wire model and
9633 // endpoint back into `route.config`, so constructing from the candidate
9634 // must be byte-identical to constructing from that config. This pins the
9635 // "no behavior change today" guarantee for Slice A.
9636 let (_config, route) =
9637 deepseek_route_for_test("https://api.deepseek.com/v1", "deepseek-v4-pro");
9638
9639 let from_new = DeepSeekClient::new(&route.config).expect("new client");
9640 let from_candidate = DeepSeekClient::from_candidate(&route.config, &route.candidate)
9641 .expect("candidate client");
9642
9643 assert_eq!(from_candidate.base_url, from_new.base_url);
9644 assert_eq!(from_candidate.default_model, from_new.default_model);
9645 assert_eq!(from_candidate.api_provider, from_new.api_provider);
9646 }
9647
9648 #[test]
9649 fn official_deepseek_flash_binds_responses_request_and_endpoint() {
9650 let (_config, route) =
9651 deepseek_route_for_test("https://api.deepseek.com/beta", "deepseek-v4-flash");
9652 assert_eq!(route.candidate.protocol(), WireFormat::Responses);
9653
9654 let client = DeepSeekClient::new(&route.config).expect("Flash client resolves");
9655 assert_eq!(client.wire_format, WireFormat::Responses);
9656
9657 let prepared = client
9658 .prepare_outbound_request(
9659 MessageRequest {
9660 model: "deepseek-v4-flash".to_string(),
9661 messages: vec![Message {
9662 role: "user".to_string(),
9663 content: vec![ContentBlock::Text {
9664 text: "hello".to_string(),
9665 cache_control: None,
9666 }],
9667 }],
9668 max_tokens: 64,
9669 system: None,
9670 tools: None,
9671 tool_choice: None,
9672 metadata: None,
9673 thinking: None,
9674 reasoning_effort: Some("max".to_string()),
9675 stream: Some(true),
9676 temperature: None,
9677 top_p: None,
9678 },
9679 true,
9680 )
9681 .expect("Flash Responses request prepares");
9682
9683 assert_eq!(prepared.dialect, WireDialect::OpenAiResponses);
9684 assert_eq!(prepared.endpoint.url, "https://api.deepseek.com/responses");
9685 assert_eq!(prepared.body["model"], "deepseek-v4-flash");
9686 assert_eq!(prepared.body["reasoning"]["effort"], "max");
9687 }
9688
9689 #[test]
9690 fn rebinding_a_chat_bound_client_for_flash_switches_to_responses() {
9691 // #5042: fleet dispatch binds the child client before the profile
9692 // model is resolved; a chat-bound DeepSeek client asked to run flash
9693 // must be rebuilt on the Responses protocol by the central resolver
9694 // instead of failing deterministically at first send.
9695 let (_config, route) =
9696 deepseek_route_for_test("https://api.deepseek.com/beta", "deepseek-v4-pro");
9697 let client = DeepSeekClient::new(&route.config).expect("pro client resolves");
9698 assert_eq!(client.wire_format, WireFormat::ChatCompletions);
9699
9700 let rebound = client
9701 .rebound_for_model_protocol(Some(&route.config), "deepseek-v4-flash")
9702 .expect("flash rebind resolves")
9703 .expect("flash requires a different protocol");
9704 assert_eq!(rebound.wire_format, WireFormat::Responses);
9705 assert_eq!(rebound.default_model, "deepseek-v4-flash");
9706
9707 assert!(
9708 client
9709 .rebound_for_model_protocol(Some(&route.config), "deepseek-v4-pro")
9710 .expect("pro rebind resolves")
9711 .is_none(),
9712 "a matching protocol must not rebuild the client"
9713 );
9714 }
9715
9716 #[test]
9717 fn from_candidate_binds_custom_provider_base_url_and_model() {
9718 // #1519: a custom OpenAI-compatible provider resolves to a candidate
9719 // whose endpoint/model come from the named `[providers.<name>]` table,
9720 // and `from_candidate` must bind that verbatim base URL + wire model.
9721 let mut custom = std::collections::HashMap::new();
9722 custom.insert(
9723 "my_thing".to_string(),
9724 ProviderConfig {
9725 kind: Some("openai-compatible".to_string()),
9726 base_url: Some("https://api.example.com/v1".to_string()),
9727 model: Some("custom-model-v1".to_string()),
9728 api_key_env: Some("EXAMPLE_API_KEY_FROM_CANDIDATE_TEST".to_string()),
9729 ..Default::default()
9730 },
9731 );
9732 let config = Config {
9733 provider: Some("my_thing".to_string()),
9734 providers: Some(ProvidersConfig {
9735 custom,
9736 ..Default::default()
9737 }),
9738 ..Config::default()
9739 };
9740
9741 // The config names a custom provider, so it must resolve as Custom.
9742 assert_eq!(config.api_provider(), ApiProvider::Custom);
9743
9744 let route = crate::route_runtime::resolve_runtime_route(&config, ApiProvider::Custom, None)
9745 .expect("custom route should resolve");
9746
9747 // Provide the key the route's auth path will read.
9748 // SAFETY: single-threaded unit test mutating a uniquely-named var.
9749 unsafe {
9750 std::env::set_var("EXAMPLE_API_KEY_FROM_CANDIDATE_TEST", "sk-custom");
9751 }
9752 let client = DeepSeekClient::from_candidate(&route.config, &route.candidate)
9753 .expect("client should construct from custom candidate");
9754 unsafe {
9755 std::env::remove_var("EXAMPLE_API_KEY_FROM_CANDIDATE_TEST");
9756 }
9757
9758 assert_eq!(client.base_url, "https://api.example.com/v1");
9759 assert_eq!(client.default_model, "custom-model-v1");
9760 assert_eq!(client.api_provider, ApiProvider::Custom);
9761 // The candidate carried the custom endpoint + verbatim wire model.
9762 assert_eq!(
9763 route.candidate.endpoint().base_url,
9764 "https://api.example.com/v1"
9765 );
9766 assert_eq!(route.candidate.wire_model_id().as_str(), "custom-model-v1");
9767 }
9768 }
9769
9769 lines RUST