返回 CodeWhale
prompt_suggestion.rs
根目录 / crates / tui / src / tui / prompt_suggestion.rs
1 //! Ghost-text follow-up prompt suggestion.
2 //!
3 //! After each completed turn, a lightweight API call generates ONE short
4 //! follow-up question the user might want to ask next. The suggestion is
5 //! rendered as dimmed ghost text in the composer when the input is empty.
6
7 use std::fmt;
8 use std::sync::OnceLock;
9
10 use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
11 use serde_json::Value;
12 use tracing::debug;
13
14 use crate::config::{ApiProvider, Config};
15 use crate::core::events::TurnRoute;
16 use crate::route_receipt::{TurnRouteReceipt, endpoint_identity};
17
18 /// The exact route authority a turn was launched against.
19 ///
20 /// This is a thin, gated wrapper around the [`TurnRouteReceipt`] the **engine**
21 /// minted from the installed, preflighted client. It is never derived from
22 /// live config: the whole point is that by the time the TUI processes
23 /// `TurnStarted`, config may already describe a different endpoint or
24 /// credential (web config events are drained ahead of engine events), and
25 /// authority resolved from that mutable state would authorize sending a
26 /// completed turn's context to a route the turn never ran on.
27 ///
28 /// `TurnComplete` re-resolves the same identity and must reproduce every field
29 /// of this record; anything else — including a same-identity endpoint or key
30 /// rotation performed mid-turn — fails closed.
31 #[derive(Clone, PartialEq, Eq, Debug)]
32 pub struct SuggestionRouteAuthority {
33 receipt: TurnRouteReceipt,
34 }
35
36 impl SuggestionRouteAuthority {
37 #[must_use]
38 pub fn provider(&self) -> ApiProvider {
39 self.receipt.provider()
40 }
41
42 /// Exact configured route key (`TurnRoute::provider_identity`).
43 #[must_use]
44 pub fn provider_identity(&self) -> &str {
45 self.receipt.provider_identity()
46 }
47
48 /// Exact wire model the turn's client was bound to.
49 #[must_use]
50 pub fn model(&self) -> &str {
51 self.receipt.wire_model()
52 }
53
54 /// Normalized, redacted identity of the endpoint the turn's client used.
55 #[must_use]
56 pub fn endpoint_identity(&self) -> &str {
57 self.receipt.endpoint_identity()
58 }
59
60 /// Whether a live re-resolution still lands on the same endpoint and the
61 /// same credential generation.
62 fn authorizes(&self, base_url: &str, api_key: &str, openrouter_vendor: Option<&str>) -> bool {
63 self.receipt.matches_live_route(base_url, api_key)
64 && self.receipt.openrouter_vendor() == openrouter_vendor
65 }
66
67 #[cfg(test)]
68 pub(crate) fn from_receipt_for_test(receipt: TurnRouteReceipt) -> Self {
69 Self { receipt }
70 }
71 }
72
73 /// Non-secret route provenance for the turn that just completed.
74 ///
75 /// This is a snapshot of `TurnRoute` plus the authority minted when that turn's
76 /// client was installed, not live UI selection state. Every suggestion decision
77 /// is anchored to it, so a route switch made after the turn completed cannot
78 /// redirect the background request.
79 #[derive(Clone, Copy)]
80 pub struct SuggestionRouteSnapshot<'a> {
81 pub provider: ApiProvider,
82 /// Exact configured route key (`TurnRoute::provider_identity`).
83 pub provider_identity: &'a str,
84 /// Exact wire model the completed turn actually used.
85 pub model: &'a str,
86 /// Authority carried on the completed turn's route receipt.
87 pub authority: &'a SuggestionRouteAuthority,
88 /// Actual base URL this turn's client used, from `Event::TurnComplete`.
89 pub actual_base_url: Option<&'a str>,
90 }
91
92 /// Redacted: `actual_base_url` is a raw endpoint that may carry URL userinfo or
93 /// sensitive query values, so it renders as its normalized redacted identity.
94 impl fmt::Debug for SuggestionRouteSnapshot<'_> {
95 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96 f.debug_struct("SuggestionRouteSnapshot")
97 .field("provider", &self.provider)
98 .field("provider_identity", &self.provider_identity)
99 .field("model", &self.model)
100 .field("authority", &self.authority)
101 .field(
102 "actual_endpoint_identity",
103 &self.actual_base_url.map(endpoint_identity),
104 )
105 .finish()
106 }
107 }
108
109 /// Credential material resolved for exactly one route identity.
110 ///
111 /// The resolver that produces this must scope itself to the snapshot identity;
112 /// it must never fall back to the ambient/active provider.
113 #[derive(Clone, PartialEq, Eq)]
114 pub struct SuggestionRouteCredentials {
115 pub api_key: String,
116 pub base_url: String,
117 /// Wire model the resolver arrived at. Must equal the snapshot model.
118 pub model: String,
119 pub openrouter_vendor: Option<String>,
120 }
121
122 /// Redacted: an API key must never reach a log line, panic message, or test
123 /// failure output through `{:?}`, and a raw `base_url` can itself carry
124 /// credentials in URL userinfo or a query token.
125 impl fmt::Debug for SuggestionRouteCredentials {
126 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127 f.debug_struct("SuggestionRouteCredentials")
128 .field("api_key", &"<redacted>")
129 .field("endpoint_identity", &endpoint_identity(&self.base_url))
130 .field("model", &self.model)
131 .finish()
132 }
133 }
134
135 /// A fully validated background suggestion request.
136 #[derive(Clone, PartialEq, Eq)]
137 pub struct SuggestionLaunch {
138 pub api_key: String,
139 pub base_url: String,
140 pub model: String,
141 pub openrouter_vendor: Option<String>,
142 }
143
144 /// Redacted: see [`SuggestionRouteCredentials`].
145 impl fmt::Debug for SuggestionLaunch {
146 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147 f.debug_struct("SuggestionLaunch")
148 .field("api_key", &"<redacted>")
149 .field("endpoint_identity", &endpoint_identity(&self.base_url))
150 .field("model", &self.model)
151 .finish()
152 }
153 }
154
155 /// Whether a provider speaks the ordinary OpenAI-compatible
156 /// `/chat/completions` shape [`generate_suggestion`] hardcodes.
157 ///
158 /// Gate on wire protocol, not a vendor enum: Anthropic Messages and the
159 /// OpenAI Responses API are different request shapes and stay out.
160 #[must_use]
161 pub fn route_is_supported_suggestion_provider(provider: ApiProvider) -> bool {
162 crate::client::provider_speaks_chat_completions(provider)
163 }
164
165 /// Resolve credentials for exactly one configured route identity.
166 ///
167 /// The identity is revalidated against live config and then scoped with
168 /// `resolve_runtime_route_for_identity`, so the key and endpoint come from that
169 /// route's own configuration rather than from whichever provider happens to be
170 /// selected now. An identity that no longer resolves, or that now resolves to a
171 /// different provider kind, yields `None`.
172 ///
173 /// The returned `base_url` is the **resolved route candidate's** endpoint, not
174 /// `Config::active_route_base_url()`. Those two are not the same string: the config
175 /// accessor is one input to candidate resolution, and the candidate endpoint is
176 /// what `CodewhaleClient::from_candidate` binds the transport to and therefore
177 /// what `Event::TurnComplete` reports back. Comparing anything else here would
178 /// compare a turn's actual endpoint against a differently-canonicalized value
179 /// and fail closed on routes that never changed.
180 fn resolve_credentials_for_identity(
181 config: &Config,
182 provider: ApiProvider,
183 provider_identity: &str,
184 model: &str,
185 ) -> Option<SuggestionRouteCredentials> {
186 // Belt and braces: callers already gated, but this function must never
187 // read credentials for a wire this helper does not speak.
188 if !route_is_supported_suggestion_provider(provider) {
189 return None;
190 }
191 let identity = config.resolve_provider_identity(provider_identity).ok()?;
192 if identity.provider != provider {
193 return None;
194 }
195 let resolved =
196 crate::route_runtime::resolve_runtime_route_for_identity(config, &identity, Some(model))
197 .ok()?;
198 if resolved.identity.provider != provider {
199 return None;
200 }
201 // This helper intentionally sends the ordinary Chat Completions shape.
202 // A configured path override may describe a provider-specific transport
203 // contract that this bounded feature does not implement, so fail closed
204 // instead of silently bypassing it with the canonical path.
205 if resolved
206 .config
207 .provider_config_for(provider)
208 .and_then(|route| route.path_suffix.as_ref())
209 .is_some()
210 {
211 return None;
212 }
213 let api_key = resolved.config.active_route_api_key().ok()?;
214 Some(SuggestionRouteCredentials {
215 api_key,
216 base_url: resolved.candidate.endpoint().base_url.clone(),
217 model: resolved.model.clone(),
218 openrouter_vendor: resolved.config.openrouter_vendor().ok()?,
219 })
220 }
221
222 /// Adopt the engine's route receipt as this turn's suggestion authority.
223 ///
224 /// Takes **no `Config`**, by design. This runs while the TUI handles
225 /// `TurnStarted`, which is strictly after the engine resolved, preflighted, and
226 /// installed the turn's client — and strictly after any web config event queued
227 /// in the meantime has been drained. Reading credentials here would capture
228 /// whatever route config describes *now*, not the route the turn is running on.
229 /// The receipt was minted from the installed client itself, so it cannot drift.
230 ///
231 /// Unsupported providers — Anthropic Messages, Responses, and any other
232 /// non-Chat-Completions wire — return `None`, and no credential material
233 /// of any provider is inspected on this path at all.
234 #[must_use]
235 pub fn capture_route_authority(route: &TurnRoute) -> Option<SuggestionRouteAuthority> {
236 if !route_is_supported_suggestion_provider(route.provider) {
237 return None;
238 }
239 let provider_identity = route.provider_identity.trim();
240 let model = route.model.trim();
241 if provider_identity.is_empty() || model.is_empty() {
242 return None;
243 }
244
245 // A receipt that describes a different route than the event's own
246 // `TurnRoute` is broken provenance, not a usable authority.
247 let receipt = route.receipt.as_ref()?;
248 if receipt.provider() != route.provider
249 || receipt.provider_identity() != provider_identity
250 || receipt.wire_model() != model
251 || receipt.endpoint_identity().is_empty()
252 || receipt.credential_generation().is_empty()
253 {
254 return None;
255 }
256
257 Some(SuggestionRouteAuthority {
258 receipt: receipt.clone(),
259 })
260 }
261
262 /// Decide whether a completed turn may launch a background prompt suggestion,
263 /// and with exactly what credentials, endpoint, and model.
264 ///
265 /// Fail-closed by construction:
266 /// - `resolve_route_credentials` is only invoked once every non-credential gate
267 /// has passed for a Chat Completions route, so a Messages/Responses
268 /// completion never reaches another provider's credentials at all.
269 /// - The decision reads only `completed_route`, never live selection state, so
270 /// a later route switch cannot redirect it.
271 /// - The route must still resolve to the *same* provider, identity, wire model,
272 /// endpoint identity, and credential generation the engine recorded on this
273 /// turn's route receipt, and to the endpoint the turn's client actually used.
274 /// A same-identity endpoint or key rotation is therefore a mismatch, not an
275 /// accepted match, and no conversation context is sent anywhere.
276 pub fn plan_suggestion_launch<F>(
277 turn_completed: bool,
278 suggestion_enabled: bool,
279 api_message_count: usize,
280 completed_route: Option<SuggestionRouteSnapshot<'_>>,
281 resolve_route_credentials: F,
282 ) -> Option<SuggestionLaunch>
283 where
284 F: FnOnce(&SuggestionRouteSnapshot<'_>) -> Option<SuggestionRouteCredentials>,
285 {
286 if !turn_completed || !suggestion_enabled || api_message_count < 2 {
287 return None;
288 }
289 // No route snapshot means no provenance. Non-model turns (composer `!`
290 // shell commands) land here too.
291 let route = completed_route?;
292 if !route_is_supported_suggestion_provider(route.provider) {
293 return None;
294 }
295 let identity = route.provider_identity.trim();
296 if identity.is_empty() {
297 return None;
298 }
299 let model = route.model.trim();
300 if model.is_empty() {
301 return None;
302 }
303
304 // The authority came off this turn's own route receipt. If it describes
305 // anything else, the provenance chain is broken.
306 let authority = route.authority;
307 if authority.provider() != route.provider
308 || authority.provider_identity() != identity
309 || authority.model() != model
310 || authority.endpoint_identity().is_empty()
311 {
312 return None;
313 }
314
315 // The engine reports the endpoint this turn's client actually used. It is
316 // required, and it must be the endpoint the receipt was minted from.
317 let actual_endpoint = endpoint_identity(route.actual_base_url?);
318 if actual_endpoint.is_empty() || actual_endpoint != authority.endpoint_identity() {
319 return None;
320 }
321
322 let credentials = resolve_route_credentials(&route)?;
323 if credentials.api_key.trim().is_empty() {
324 return None;
325 }
326 // Never silently swap in a cheaper/different model than the one the
327 // completed turn was actually routed to.
328 if credentials.model.trim() != model {
329 return None;
330 }
331 // A same-identity endpoint mutation *or* credential rotation lands here.
332 // Both are checked against the receipt in one step, over the raw endpoint
333 // and raw credential, so a mutation hidden behind identical redaction (URL
334 // userinfo, a query token) is still a mismatch.
335 if !authority.authorizes(
336 &credentials.base_url,
337 &credentials.api_key,
338 credentials.openrouter_vendor.as_deref(),
339 ) {
340 return None;
341 }
342
343 // Dispatch from the exact base endpoint and credential the receipt
344 // authorized — the raw pair the digest was taken over, not a
345 // re-canonicalized variant. `generate_suggestion` applies the same
346 // canonical ordinary Chat Completions path mapping as the installed
347 // client; custom path overrides failed closed above.
348 Some(SuggestionLaunch {
349 api_key: credentials.api_key,
350 base_url: credentials.base_url,
351 model: model.to_string(),
352 openrouter_vendor: credentials.openrouter_vendor,
353 })
354 }
355
356 /// [`plan_suggestion_launch`] wired to the real, identity-scoped config
357 /// resolver. This is the only production entry point.
358 #[must_use]
359 pub fn plan_suggestion_launch_with_config(
360 config: &Config,
361 turn_completed: bool,
362 suggestion_enabled: bool,
363 api_message_count: usize,
364 completed_route: Option<SuggestionRouteSnapshot<'_>>,
365 ) -> Option<SuggestionLaunch> {
366 plan_suggestion_launch(
367 turn_completed,
368 suggestion_enabled,
369 api_message_count,
370 completed_route,
371 |route| {
372 resolve_credentials_for_identity(
373 config,
374 route.provider,
375 route.provider_identity.trim(),
376 route.model.trim(),
377 )
378 },
379 )
380 }
381
382 /// Reusable static client — avoids creating a new connection pool per request.
383 fn suggestion_client() -> &'static reqwest::Client {
384 static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
385 CLIENT.get_or_init(crate::tls::reqwest_client)
386 }
387
388 /// Generate a follow-up prompt suggestion based on recent messages.
389 ///
390 /// Sends the conversation summary to the API with a system prompt that
391 /// asks for a single short follow-up question. Returns `None` on failure
392 /// or empty result — callers treat this as best-effort.
393 pub async fn generate_suggestion(
394 api_key: &str,
395 base_url: &str,
396 model: &str,
397 recent_messages: &str,
398 openrouter_vendor: Option<&str>,
399 ) -> Option<String> {
400 // Suggestions are model output derived from the just-completed
401 // interactive transcript. They therefore participate in the same
402 // attached CWC run even though this narrow adapter owns a raw reqwest
403 // client instead of a `CodewhaleClient`. Retain the permit through decode
404 // so Runtime Chat cannot overlap or project a second inference lifecycle.
405 let _inference = crate::client::acquire_remote_control_inference_participant().await;
406 let client = suggestion_client();
407 let mut body = serde_json::json!({
408 "model": model,
409 "messages": [
410 {
411 "role": "system",
412 "content": "\
413 You are a helpful assistant. Based on the recent conversation context, generate \
414 ONE short follow-up question (under 60 characters) the user might want to ask \
415 next. Reply with ONLY the question text, nothing else — no quotes, no explanations, \
416 no prefixes."
417 },
418 {
419 "role": "user",
420 "content": format!(
421 "Recent conversation:\n{recent_messages}\n\n\
422 Generate ONE short follow-up question the user might ask next:"
423 )
424 }
425 ],
426 "max_tokens": 64,
427 "temperature": 0.3,
428 "stream": false
429 });
430 crate::client::apply_openrouter_vendor(&mut body, openrouter_vendor);
431
432 let url = crate::client::api_url(base_url, "chat/completions");
433 // Never log the raw request URL: a base URL can carry credentials in its
434 // userinfo or in a query token. The redacted endpoint identity keeps the
435 // line diagnosable without carrying either.
436 debug!(
437 endpoint = %endpoint_identity(&url),
438 %model,
439 "generating prompt suggestion"
440 );
441 let mut request = client
442 .post(&url)
443 .header(AUTHORIZATION, format!("Bearer {api_key}"))
444 .header(CONTENT_TYPE, "application/json")
445 .timeout(std::time::Duration::from_secs(10))
446 .json(&body);
447 // OpenRouter app attribution: same headers the Chat Completions client
448 // already sends. Infer from the turn's actual endpoint so this helper
449 // does not grow a provider enum of its own.
450 if endpoint_identity(&url).contains("openrouter.ai") {
451 request = request
452 .header("HTTP-Referer", "https://codewhale.net")
453 .header("X-Title", "Codewhale");
454 }
455 let response = match request.send().await {
456 Ok(r) => r,
457 Err(_) => return None,
458 };
459
460 let value: Value = match response.json().await {
461 Ok(v) => v,
462 Err(_) => return None,
463 };
464
465 let suggestion = value["choices"][0]["message"]["content"]
466 .as_str()
467 .map(|s| s.trim().trim_matches('"').to_string())
468 .filter(|s| !s.is_empty() && s.len() <= 200)?;
469
470 // The suggestion is model output derived from conversation context, so its
471 // text stays out of logs; only its shape is recorded.
472 debug!(
473 chars = suggestion.chars().count(),
474 "prompt suggestion generated"
475 );
476 Some(suggestion)
477 }
478
479 /// Extract the first text line from a single message.
480 fn message_summary(m: &codewhale_models::Message) -> Option<String> {
481 let role = match m.role.as_str() {
482 "user" => "User",
483 "assistant" => "Assistant",
484 _ => return None,
485 };
486 let text = m
487 .content
488 .iter()
489 .filter_map(|block| match block {
490 codewhale_models::ContentBlock::Text { text, .. } => Some(text.as_str()),
491 _ => None,
492 })
493 .collect::<Vec<_>>()
494 .join(" ");
495 let first_line = text.lines().next().unwrap_or("").trim();
496 if first_line.is_empty() {
497 return None;
498 }
499 let truncated: String = first_line
500 .chars()
501 .take(120)
502 .chain(if first_line.chars().count() > 120 {
503 Some('…')
504 } else {
505 None
506 })
507 .collect();
508 Some(format!("{role}: {truncated}"))
509 }
510
511 /// Build a one-line-per-message summary of recent conversation context.
512 /// Takes the last N messages, skipping tool-only messages.
513 pub fn summarize_recent_messages(messages: &[codewhale_models::Message], limit: usize) -> String {
514 let start = messages.len().saturating_sub(limit);
515 messages[start..]
516 .iter()
517 .filter_map(message_summary)
518 .collect::<Vec<_>>()
519 .join("\n")
520 }
521
522 #[cfg(test)]
523 mod tests {
524 use std::cell::RefCell;
525
526 use super::{
527 ApiProvider, Config, SuggestionRouteAuthority, SuggestionRouteCredentials,
528 SuggestionRouteSnapshot, TurnRoute, TurnRouteReceipt, capture_route_authority,
529 endpoint_identity, generate_suggestion, plan_suggestion_launch,
530 plan_suggestion_launch_with_config, resolve_credentials_for_identity,
531 };
532 use crate::config::ProvidersConfig;
533 use crate::test_support::{EnvVarGuard, TestEnvLock, lock_test_env};
534 use wiremock::matchers::{method, path};
535 use wiremock::{Mock, MockServer, ResponseTemplate};
536
537 const DEEPSEEK_BASE: &str = "https://api.deepseek.com/v1";
538 const DEEPSEEK_KEY: &str = "sk-deepseek-secret";
539
540 #[tokio::test]
541 async fn suggestion_request_preserves_openrouter_vendor_pin() {
542 let server = MockServer::start().await;
543 Mock::given(method("POST"))
544 .and(path("/v1/chat/completions"))
545 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
546 "choices": [{ "message": { "content": "What should we do next?" } }]
547 })))
548 .expect(2)
549 .mount(&server)
550 .await;
551
552 for vendor in [Some("chutes/region-fixture"), None] {
553 assert!(
554 generate_suggestion(
555 "fixture-key",
556 &format!("{}/v1", server.uri()),
557 "fixture/model",
558 "User: hello",
559 vendor,
560 )
561 .await
562 .is_some()
563 );
564 }
565 let requests = server.received_requests().await.unwrap();
566 assert_eq!(requests.len(), 2);
567 let pinned: serde_json::Value = serde_json::from_slice(&requests[0].body).unwrap();
568 assert_eq!(
569 pinned["provider"],
570 serde_json::json!({"order": ["chutes/region-fixture"], "allow_fallbacks": false})
571 );
572 let unpinned: serde_json::Value = serde_json::from_slice(&requests[1].body).unwrap();
573 assert!(unpinned.get("provider").is_none());
574 }
575
576 #[tokio::test]
577 async fn suggestion_inference_waits_for_runtime_chat_ownership() {
578 let server = MockServer::start().await;
579 Mock::given(method("POST"))
580 .and(path("/v1/chat/completions"))
581 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
582 "choices": [{ "message": { "content": "What should we do next?" } }]
583 })))
584 .expect(1)
585 .mount(&server)
586 .await;
587
588 let ownership = crate::client::acquire_runtime_chat_inference_ownership().await;
589 let base_url = format!("{}/v1", server.uri());
590 let mut suggestion = tokio::spawn(async move {
591 generate_suggestion(
592 "fixture-key",
593 &base_url,
594 "deepseek-v4-flash",
595 "User: hello\nAssistant: hi",
596 None,
597 )
598 .await
599 });
600 assert!(
601 tokio::time::timeout(std::time::Duration::from_millis(40), &mut suggestion)
602 .await
603 .is_err(),
604 "background suggestion provider output must wait behind Runtime Chat"
605 );
606 drop(ownership);
607 assert_eq!(
608 tokio::time::timeout(std::time::Duration::from_secs(1), suggestion)
609 .await
610 .expect("suggestion resumes after relay settlement")
611 .expect("suggestion task")
612 .as_deref(),
613 Some("What should we do next?")
614 );
615 }
616
617 /// Stand-in for the real credential resolver. Records every identity it was
618 /// asked about so a test can prove it was never consulted at all.
619 struct RecordingResolver {
620 /// Credentials keyed by exact `(provider, provider_identity)`.
621 available: Vec<(ApiProvider, &'static str, SuggestionRouteCredentials)>,
622 asked: RefCell<Vec<(ApiProvider, String)>>,
623 }
624
625 impl RecordingResolver {
626 fn new(available: Vec<(ApiProvider, &'static str, SuggestionRouteCredentials)>) -> Self {
627 Self {
628 available,
629 asked: RefCell::new(Vec::new()),
630 }
631 }
632
633 fn resolve(
634 &self,
635 route: &SuggestionRouteSnapshot<'_>,
636 ) -> Option<SuggestionRouteCredentials> {
637 self.asked
638 .borrow_mut()
639 .push((route.provider, route.provider_identity.to_string()));
640 self.available
641 .iter()
642 .find(|(provider, identity, _)| {
643 *provider == route.provider && *identity == route.provider_identity
644 })
645 .map(|(_, _, credentials)| credentials.clone())
646 }
647
648 fn asked(&self) -> Vec<(ApiProvider, String)> {
649 self.asked.borrow().clone()
650 }
651 }
652
653 fn credentials(api_key: &str, base_url: &str, model: &str) -> SuggestionRouteCredentials {
654 SuggestionRouteCredentials {
655 api_key: api_key.to_string(),
656 base_url: base_url.to_string(),
657 model: model.to_string(),
658 openrouter_vendor: None,
659 }
660 }
661
662 fn deepseek_credentials(model: &str) -> SuggestionRouteCredentials {
663 credentials(DEEPSEEK_KEY, DEEPSEEK_BASE, model)
664 }
665
666 /// A receipt as the engine would have minted it from the installed client.
667 fn receipt(
668 provider: ApiProvider,
669 identity: &str,
670 model: &str,
671 base_url: &str,
672 api_key: &str,
673 ) -> TurnRouteReceipt {
674 TurnRouteReceipt::new(provider, identity, model, base_url, api_key)
675 }
676
677 /// Authority as the TUI would have adopted it at `TurnStarted`.
678 ///
679 /// Bypasses the provider gate so the unsupported-provider tests below can
680 /// prove the *later* gates also hold, not just the first one.
681 fn route_authority(
682 provider: ApiProvider,
683 identity: &str,
684 model: &str,
685 base_url: &str,
686 api_key: &str,
687 ) -> SuggestionRouteAuthority {
688 SuggestionRouteAuthority::from_receipt_for_test(receipt(
689 provider, identity, model, base_url, api_key,
690 ))
691 }
692
693 fn deepseek_authority(model: &str) -> SuggestionRouteAuthority {
694 route_authority(
695 ApiProvider::Deepseek,
696 "deepseek",
697 model,
698 DEEPSEEK_BASE,
699 DEEPSEEK_KEY,
700 )
701 }
702
703 fn snapshot<'a>(
704 provider: ApiProvider,
705 identity: &'a str,
706 model: &'a str,
707 authority: &'a SuggestionRouteAuthority,
708 ) -> SuggestionRouteSnapshot<'a> {
709 SuggestionRouteSnapshot {
710 provider,
711 provider_identity: identity,
712 model,
713 authority,
714 actual_base_url: Some(DEEPSEEK_BASE),
715 }
716 }
717
718 #[test]
719 fn deepseek_route_uses_its_exact_wire_model_and_base_url() {
720 let resolver = RecordingResolver::new(vec![(
721 ApiProvider::Deepseek,
722 "deepseek",
723 deepseek_credentials("deepseek-reasoner"),
724 )]);
725 let authority = deepseek_authority("deepseek-reasoner");
726 let launch = plan_suggestion_launch(
727 true,
728 true,
729 2,
730 Some(snapshot(
731 ApiProvider::Deepseek,
732 "deepseek",
733 "deepseek-reasoner",
734 &authority,
735 )),
736 |route| resolver.resolve(route),
737 )
738 .expect("supported deepseek route with unchanged credentials must launch");
739
740 assert_eq!(launch.model, "deepseek-reasoner");
741 assert_eq!(launch.base_url, DEEPSEEK_BASE);
742 assert_eq!(launch.api_key, DEEPSEEK_KEY);
743 }
744
745 #[test]
746 fn non_chat_completions_completion_never_touches_foreign_credentials() {
747 // A Chat Completions key exists and would resolve fine — the gate must
748 // run before the resolver is ever consulted.
749 let resolver = RecordingResolver::new(vec![(
750 ApiProvider::Deepseek,
751 "deepseek",
752 deepseek_credentials("deepseek-chat"),
753 )]);
754 for (provider, identity, model) in [
755 (ApiProvider::Anthropic, "anthropic", "claude-sonnet-4"),
756 (ApiProvider::OpenaiCodex, "openai-codex", "gpt-5.4"),
757 (
758 ApiProvider::DeepseekAnthropic,
759 "deepseek-anthropic",
760 "deepseek-chat",
761 ),
762 ] {
763 let authority = route_authority(provider, identity, model, DEEPSEEK_BASE, DEEPSEEK_KEY);
764 let launch = plan_suggestion_launch(
765 true,
766 true,
767 8,
768 Some(snapshot(provider, identity, model, &authority)),
769 |route| resolver.resolve(route),
770 );
771 assert!(
772 launch.is_none(),
773 "{provider:?} completion must not launch a prompt suggestion"
774 );
775 }
776 assert!(
777 resolver.asked().is_empty(),
778 "credential resolution must never be attempted for unsupported routes, got {:?}",
779 resolver.asked()
780 );
781 }
782
783 #[test]
784 fn chat_completions_routes_launch_with_their_own_credentials() {
785 for (provider, identity, model, base, key) in [
786 (
787 ApiProvider::Deepseek,
788 "deepseek",
789 "deepseek-chat",
790 DEEPSEEK_BASE,
791 DEEPSEEK_KEY,
792 ),
793 (
794 ApiProvider::Openai,
795 "openai",
796 "gpt-5.6",
797 "https://api.openai.com/v1",
798 "sk-openai",
799 ),
800 (
801 ApiProvider::Openrouter,
802 "openrouter",
803 "some/model",
804 "https://openrouter.ai/api/v1",
805 "sk-or",
806 ),
807 (
808 ApiProvider::Custom,
809 "lm-studio",
810 "local-model",
811 "http://127.0.0.1:1234/v1",
812 "lm-key",
813 ),
814 (
815 ApiProvider::Zai,
816 "zai",
817 "GLM-5.3",
818 "https://api.z.ai/api/paas/v4",
819 "zai-key",
820 ),
821 ] {
822 let resolver =
823 RecordingResolver::new(vec![(provider, identity, credentials(key, base, model))]);
824 let authority = route_authority(provider, identity, model, base, key);
825 let route = SuggestionRouteSnapshot {
826 provider,
827 provider_identity: identity,
828 model,
829 authority: &authority,
830 actual_base_url: Some(base),
831 };
832 let launch =
833 plan_suggestion_launch(true, true, 2, Some(route), |route| resolver.resolve(route))
834 .unwrap_or_else(|| panic!("{provider:?} Chat Completions route must launch"));
835 assert_eq!(launch.api_key, key, "{provider:?}");
836 assert_eq!(launch.base_url, base, "{provider:?}");
837 assert_eq!(launch.model, model, "{provider:?}");
838 assert_eq!(resolver.asked(), vec![(provider, identity.to_string())]);
839 }
840 }
841
842 #[test]
843 fn missing_credentials_fail_closed() {
844 let authority = deepseek_authority("deepseek-chat");
845 // No entry for the deepseek identity: resolver returns None.
846 let empty = RecordingResolver::new(Vec::new());
847 assert!(
848 plan_suggestion_launch(
849 true,
850 true,
851 2,
852 Some(snapshot(
853 ApiProvider::Deepseek,
854 "deepseek",
855 "deepseek-chat",
856 &authority
857 )),
858 |route| empty.resolve(route),
859 )
860 .is_none(),
861 "unresolvable route credentials must fail closed"
862 );
863
864 for incomplete in [
865 credentials(" ", DEEPSEEK_BASE, "deepseek-chat"),
866 credentials(DEEPSEEK_KEY, "", "deepseek-chat"),
867 ] {
868 assert!(
869 plan_suggestion_launch(
870 true,
871 true,
872 2,
873 Some(snapshot(
874 ApiProvider::Deepseek,
875 "deepseek",
876 "deepseek-chat",
877 &authority
878 )),
879 |_| Some(incomplete.clone()),
880 )
881 .is_none(),
882 "incomplete credentials must fail closed: {incomplete:?}"
883 );
884 }
885 }
886
887 #[test]
888 fn resolver_is_asked_only_about_the_completed_route_identity() {
889 // Live selection has moved on to another provider; the plan is built
890 // from the completed-turn snapshot, so the resolver only ever sees the
891 // completed identity.
892 const CN_BASE: &str = "https://api.deepseek.cn/v1";
893 let resolver = RecordingResolver::new(vec![
894 (
895 ApiProvider::Deepseek,
896 "deepseek",
897 deepseek_credentials("deepseek-chat"),
898 ),
899 (
900 ApiProvider::DeepseekCN,
901 "deepseek-cn",
902 credentials("sk-cn", CN_BASE, "deepseek-chat"),
903 ),
904 ]);
905 let authority = route_authority(
906 ApiProvider::DeepseekCN,
907 "deepseek-cn",
908 "deepseek-chat",
909 CN_BASE,
910 "sk-cn",
911 );
912 let launch = plan_suggestion_launch(
913 true,
914 true,
915 4,
916 Some(SuggestionRouteSnapshot {
917 provider: ApiProvider::DeepseekCN,
918 provider_identity: "deepseek-cn",
919 model: "deepseek-chat",
920 authority: &authority,
921 actual_base_url: Some(CN_BASE),
922 }),
923 |route| resolver.resolve(route),
924 )
925 .expect("completed deepseek-cn route must launch on its own endpoint");
926
927 assert_eq!(launch.base_url, CN_BASE);
928 assert_eq!(launch.api_key, "sk-cn");
929 assert_eq!(
930 resolver.asked(),
931 vec![(ApiProvider::DeepseekCN, "deepseek-cn".to_string())],
932 "only the completed route identity may be inspected"
933 );
934 }
935
936 #[test]
937 fn model_substitution_by_the_resolver_fails_closed() {
938 let authority = deepseek_authority("deepseek-reasoner");
939 assert!(
940 plan_suggestion_launch(
941 true,
942 true,
943 2,
944 Some(snapshot(
945 ApiProvider::Deepseek,
946 "deepseek",
947 "deepseek-reasoner",
948 &authority
949 )),
950 // Silent downgrade to a cheaper model.
951 |_| Some(deepseek_credentials("deepseek-chat")),
952 )
953 .is_none(),
954 "a resolver-substituted model must not be dispatched"
955 );
956 }
957
958 #[test]
959 fn same_identity_endpoint_or_key_rotation_fails_closed() {
960 let authority = deepseek_authority("deepseek-chat");
961 // Same provider, same identity, same model — but the endpoint moved
962 // while the turn was in flight.
963 assert!(
964 plan_suggestion_launch(
965 true,
966 true,
967 2,
968 Some(snapshot(
969 ApiProvider::Deepseek,
970 "deepseek",
971 "deepseek-chat",
972 &authority
973 )),
974 |_| Some(credentials(
975 DEEPSEEK_KEY,
976 "https://exfil.example.com/v1",
977 "deepseek-chat"
978 )),
979 )
980 .is_none(),
981 "a same-identity endpoint mutation must fail closed"
982 );
983 // …and the same for a credential rotation onto the same endpoint.
984 assert!(
985 plan_suggestion_launch(
986 true,
987 true,
988 2,
989 Some(snapshot(
990 ApiProvider::Deepseek,
991 "deepseek",
992 "deepseek-chat",
993 &authority
994 )),
995 |_| Some(credentials(
996 "sk-rotated-elsewhere",
997 DEEPSEEK_BASE,
998 "deepseek-chat"
999 )),
1000 )
1001 .is_none(),
1002 "a same-identity credential mutation must fail closed"
1003 );
1004 }
1005
1006 #[test]
1007 fn userinfo_rotation_behind_identical_redaction_fails_closed() {
1008 // Both endpoints redact to the same identity string. Only the
1009 // credential-generation digest, which covers the raw endpoint, can
1010 // tell them apart — so redaction must not be the whole comparison.
1011 const ORIGINAL: &str = "https://svc:original@api.deepseek.com/v1";
1012 const ROTATED: &str = "https://svc:rotated@api.deepseek.com/v1";
1013 assert_eq!(endpoint_identity(ORIGINAL), endpoint_identity(ROTATED));
1014
1015 let authority = route_authority(
1016 ApiProvider::Deepseek,
1017 "deepseek",
1018 "deepseek-chat",
1019 ORIGINAL,
1020 DEEPSEEK_KEY,
1021 );
1022 let route = SuggestionRouteSnapshot {
1023 provider: ApiProvider::Deepseek,
1024 provider_identity: "deepseek",
1025 model: "deepseek-chat",
1026 authority: &authority,
1027 actual_base_url: Some(ORIGINAL),
1028 };
1029 assert!(
1030 plan_suggestion_launch(true, true, 2, Some(route), |_| Some(credentials(
1031 DEEPSEEK_KEY,
1032 ROTATED,
1033 "deepseek-chat"
1034 )))
1035 .is_none(),
1036 "a URL-userinfo rotation hidden by redaction must fail closed"
1037 );
1038 assert!(
1039 plan_suggestion_launch(true, true, 2, Some(route), |_| Some(credentials(
1040 DEEPSEEK_KEY,
1041 ORIGINAL,
1042 "deepseek-chat"
1043 )))
1044 .is_some(),
1045 "control: the unrotated endpoint still launches"
1046 );
1047 }
1048
1049 #[test]
1050 fn actual_turn_endpoint_must_be_present_and_match_the_authority() {
1051 let authority = deepseek_authority("deepseek-chat");
1052 for actual_base_url in [None, Some("https://exfil.example.com/v1"), Some(" ")] {
1053 let route = SuggestionRouteSnapshot {
1054 provider: ApiProvider::Deepseek,
1055 provider_identity: "deepseek",
1056 model: "deepseek-chat",
1057 authority: &authority,
1058 actual_base_url,
1059 };
1060 assert!(
1061 plan_suggestion_launch(true, true, 2, Some(route), |_| Some(deepseek_credentials(
1062 "deepseek-chat"
1063 )))
1064 .is_none(),
1065 "actual turn endpoint {actual_base_url:?} must fail closed"
1066 );
1067 }
1068
1069 // A trailing-slash-only difference is the same endpoint.
1070 let route = SuggestionRouteSnapshot {
1071 provider: ApiProvider::Deepseek,
1072 provider_identity: "deepseek",
1073 model: "deepseek-chat",
1074 authority: &authority,
1075 actual_base_url: Some("https://api.deepseek.com/v1/"),
1076 };
1077 assert!(
1078 plan_suggestion_launch(true, true, 2, Some(route), |_| Some(deepseek_credentials(
1079 "deepseek-chat"
1080 )))
1081 .is_some(),
1082 "trailing-slash normalization must not break the exact-route match"
1083 );
1084 }
1085
1086 #[test]
1087 fn authority_from_a_different_route_fails_closed() {
1088 // Authority belongs to deepseek-cn; the completed snapshot claims
1089 // deepseek. Broken provenance must never dispatch.
1090 let cn = route_authority(
1091 ApiProvider::DeepseekCN,
1092 "deepseek-cn",
1093 "deepseek-chat",
1094 "https://api.deepseek.cn/v1",
1095 "sk-cn",
1096 );
1097 let route = SuggestionRouteSnapshot {
1098 provider: ApiProvider::Deepseek,
1099 provider_identity: "deepseek",
1100 model: "deepseek-chat",
1101 authority: &cn,
1102 actual_base_url: Some(DEEPSEEK_BASE),
1103 };
1104 assert!(
1105 plan_suggestion_launch(true, true, 2, Some(route), |_| Some(deepseek_credentials(
1106 "deepseek-chat"
1107 )))
1108 .is_none(),
1109 "an authority captured for another route must fail closed"
1110 );
1111 }
1112
1113 #[test]
1114 fn missing_route_snapshot_or_disabled_gates_produce_no_request() {
1115 let resolver = RecordingResolver::new(vec![(
1116 ApiProvider::Deepseek,
1117 "deepseek",
1118 deepseek_credentials("deepseek-chat"),
1119 )]);
1120 let authority = deepseek_authority("deepseek-chat");
1121 let route = snapshot(
1122 ApiProvider::Deepseek,
1123 "deepseek",
1124 "deepseek-chat",
1125 &authority,
1126 );
1127
1128 // No route provenance (non-model turn).
1129 assert!(plan_suggestion_launch(true, true, 4, None, |r| resolver.resolve(r)).is_none());
1130 // Turn did not complete.
1131 assert!(
1132 plan_suggestion_launch(false, true, 4, Some(route), |r| resolver.resolve(r)).is_none()
1133 );
1134 // Feature disabled.
1135 assert!(
1136 plan_suggestion_launch(true, false, 4, Some(route), |r| resolver.resolve(r)).is_none()
1137 );
1138 // Not enough conversation context.
1139 assert!(
1140 plan_suggestion_launch(true, true, 1, Some(route), |r| resolver.resolve(r)).is_none()
1141 );
1142 // Empty identity is malformed provenance, not a legacy root route.
1143 let empty_identity = route_authority(
1144 ApiProvider::Deepseek,
1145 " ",
1146 "deepseek-chat",
1147 DEEPSEEK_BASE,
1148 DEEPSEEK_KEY,
1149 );
1150 assert!(
1151 plan_suggestion_launch(
1152 true,
1153 true,
1154 4,
1155 Some(snapshot(
1156 ApiProvider::Deepseek,
1157 " ",
1158 "deepseek-chat",
1159 &empty_identity
1160 )),
1161 |r| resolver.resolve(r),
1162 )
1163 .is_none()
1164 );
1165 // Empty model.
1166 let empty_model = route_authority(
1167 ApiProvider::Deepseek,
1168 "deepseek",
1169 "",
1170 DEEPSEEK_BASE,
1171 DEEPSEEK_KEY,
1172 );
1173 assert!(
1174 plan_suggestion_launch(
1175 true,
1176 true,
1177 4,
1178 Some(snapshot(
1179 ApiProvider::Deepseek,
1180 "deepseek",
1181 "",
1182 &empty_model
1183 )),
1184 |r| resolver.resolve(r),
1185 )
1186 .is_none()
1187 );
1188
1189 assert!(
1190 resolver.asked().is_empty(),
1191 "gates must reject before credential resolution, got {:?}",
1192 resolver.asked()
1193 );
1194 }
1195
1196 /// Every URL-bearing rendered surface in this feature, exercised against a
1197 /// base URL that carries credentials in both userinfo and query values.
1198 #[test]
1199 fn debug_never_renders_credential_material_or_raw_urls() {
1200 let secret_base = format!(
1201 "https://{}:{}@api.deepseek.com/v1?api_key={}{}&token={}{}&region=us-east",
1202 "svc-user", "hunter2", "sk", "-live-abc123", "tok", "-secret-xyz"
1203 );
1204 let secrets = [
1205 DEEPSEEK_KEY.to_string(),
1206 "svc-user".to_string(),
1207 "hunter2".to_string(),
1208 ["sk", "-live-abc123"].concat(),
1209 ["tok", "-secret-xyz"].concat(),
1210 ];
1211
1212 let credentials = credentials(DEEPSEEK_KEY, &secret_base, "deepseek-chat");
1213 let authority = route_authority(
1214 ApiProvider::Deepseek,
1215 "deepseek",
1216 "deepseek-chat",
1217 &secret_base,
1218 DEEPSEEK_KEY,
1219 );
1220 let route = SuggestionRouteSnapshot {
1221 provider: ApiProvider::Deepseek,
1222 provider_identity: "deepseek",
1223 model: "deepseek-chat",
1224 authority: &authority,
1225 actual_base_url: Some(&secret_base),
1226 };
1227 let launch =
1228 plan_suggestion_launch(true, true, 2, Some(route), |_| Some(credentials.clone()))
1229 .expect("unchanged route must launch");
1230
1231 for rendered in [
1232 format!("{credentials:?}"),
1233 format!("{credentials:#?}"),
1234 format!("{launch:?}"),
1235 format!("{launch:#?}"),
1236 format!("{authority:?}"),
1237 format!("{authority:#?}"),
1238 format!("{route:?}"),
1239 format!("{route:#?}"),
1240 ] {
1241 for secret in &secrets {
1242 assert!(
1243 !rendered.contains(secret),
1244 "a rendered surface leaked {secret}: {rendered}"
1245 );
1246 }
1247 // The endpoint identity is still useful for diagnostics.
1248 assert!(
1249 rendered.contains("api.deepseek.com"),
1250 "endpoint identity must survive redaction: {rendered}"
1251 );
1252 assert!(
1253 rendered.contains("region=us-east"),
1254 "non-sensitive query values must survive redaction: {rendered}"
1255 );
1256 }
1257 for rendered in [format!("{credentials:?}"), format!("{launch:?}")] {
1258 assert!(
1259 rendered.contains("<redacted>"),
1260 "Debug must mark the redacted field: {rendered}"
1261 );
1262 }
1263 // …while the launch still dispatches to the real, unredacted endpoint.
1264 assert_eq!(launch.base_url, secret_base);
1265 assert_eq!(launch.api_key, DEEPSEEK_KEY);
1266 }
1267
1268 // === Config-backed tests ===
1269 //
1270 // These drive the real, identity-scoped config resolver and the real
1271 // client-minted route receipt rather than recording stand-ins, so they
1272 // cover the actual production path.
1273
1274 /// Hold the env lock and remove every ambient variable that could displace
1275 /// the fixture's configured DeepSeek route.
1276 ///
1277 /// Without this, a developer shell that exports `DEEPSEEK_API_KEY` (or a
1278 /// dispatcher-marked `--api-key` forward) can make these tests pass or fail
1279 /// for reasons that have nothing to do with the privacy contract.
1280 ///
1281 /// Field order is load-bearing: the guards must restore the environment
1282 /// before the lock is released.
1283 struct SealedDeepseekEnv {
1284 _guards: Vec<EnvVarGuard>,
1285 _lock: TestEnvLock,
1286 }
1287
1288 fn seal_deepseek_env() -> SealedDeepseekEnv {
1289 let lock = lock_test_env();
1290 let guards = [
1291 "DEEPSEEK_API_KEY",
1292 "DEEPSEEK_API_KEY_SOURCE",
1293 "CODEWHALE_CLI_API_KEY",
1294 "DEEPSEEK_BASE_URL",
1295 "CODEWHALE_BASE_URL",
1296 ]
1297 .into_iter()
1298 .map(EnvVarGuard::remove)
1299 .collect();
1300 SealedDeepseekEnv {
1301 _guards: guards,
1302 _lock: lock,
1303 }
1304 }
1305
1306 fn deepseek_config(api_key: &str, base_url: &str) -> Config {
1307 let mut config = Config {
1308 provider: Some("deepseek".to_string()),
1309 ..Config::default()
1310 };
1311 let providers = config
1312 .providers
1313 .get_or_insert_with(ProvidersConfig::default);
1314 providers.deepseek.api_key = Some(api_key.to_string());
1315 providers.deepseek.base_url = Some(base_url.to_string());
1316 config
1317 }
1318
1319 #[test]
1320 fn suggestion_vendor_pin_rotation_or_invalid_config_fails_closed() {
1321 let _env = seal_deepseek_env();
1322 let mut config = Config {
1323 provider: Some("openrouter".to_string()),
1324 providers: Some(ProvidersConfig {
1325 openrouter: crate::config::ProviderConfig {
1326 api_key: Some("fixture-openrouter-key".to_string()),
1327 base_url: Some("http://127.0.0.1:18080/v1".to_string()),
1328 model: Some("fixture/model".to_string()),
1329 vendor: Some("chutes/region-fixture".to_string()),
1330 ..Default::default()
1331 },
1332 ..Default::default()
1333 }),
1334 ..Default::default()
1335 };
1336 let client = crate::client::CodewhaleClient::new(&config).unwrap();
1337 let authority = SuggestionRouteAuthority::from_receipt_for_test(
1338 client.turn_route_receipt("openrouter"),
1339 );
1340 let route = SuggestionRouteSnapshot {
1341 provider: ApiProvider::Openrouter,
1342 provider_identity: "openrouter",
1343 model: authority.model(),
1344 authority: &authority,
1345 actual_base_url: Some(client.base_url()),
1346 };
1347 let launch = plan_suggestion_launch_with_config(&config, true, true, 2, Some(route))
1348 .expect("unchanged OpenRouter pin remains authorized");
1349 assert_eq!(
1350 launch.openrouter_vendor.as_deref(),
1351 Some("chutes/region-fixture")
1352 );
1353
1354 for changed_vendor in [Some("another-vendor"), None, Some("bad vendor")] {
1355 config.providers.as_mut().unwrap().openrouter.vendor =
1356 changed_vendor.map(str::to_string);
1357 assert!(
1358 plan_suggestion_launch_with_config(&config, true, true, 2, Some(route)).is_none(),
1359 "changed, removed, or invalid vendor must not broaden the completed turn's route"
1360 );
1361 }
1362 }
1363
1364 /// Build the completed-turn route the engine would have reported, using the
1365 /// same resolution the engine performs. This keeps the tests correct even
1366 /// if a model selector normalizes to a different wire id.
1367 ///
1368 /// The receipt is minted from the **preflighted client**, exactly as
1369 /// `Engine::send_message` does — not from config — so these tests exercise
1370 /// the real provenance chain rather than a re-derivation of it.
1371 fn deepseek_turn_route(config: &Config) -> TurnRoute {
1372 let identity = config
1373 .resolve_provider_identity("deepseek")
1374 .expect("test config must expose the deepseek identity");
1375 let resolved = crate::route_runtime::resolve_runtime_route_for_identity(
1376 config,
1377 &identity,
1378 Some(crate::config::DEFAULT_TEXT_MODEL),
1379 )
1380 .expect("test config must resolve the deepseek route");
1381 let model = resolved.model.clone();
1382 let validated = resolved
1383 .validate()
1384 .expect("test config must preflight a deepseek client");
1385 TurnRoute {
1386 provider: ApiProvider::Deepseek,
1387 provider_identity: "deepseek".to_string(),
1388 model,
1389 auto_model: false,
1390 receipt: Some(validated.client.turn_route_receipt("deepseek")),
1391 billing: Some(crate::core::events::RouteBillingEnvelope {
1392 billing_surface: None,
1393 endpoint_fingerprint: None,
1394 openrouter_vendor: None,
1395 provider_live_pricing: None,
1396 billing_mode: crate::cost_status::RouteBillingMode::Unknown,
1397 dispatched_at: chrono::Utc::now(),
1398 }),
1399 base_url: crate::config::DEFAULT_DEEPSEEK_BASE_URL.to_string(),
1400 billing_product: crate::route_billing::RouteProduct::Unproven,
1401 }
1402 }
1403
1404 /// The endpoint `Event::TurnComplete` would report for this config.
1405 ///
1406 /// Deliberately derived from the production resolver rather than written
1407 /// as a literal: `Config::active_route_base_url()` canonicalizes DeepSeek hosts
1408 /// (it strips a trailing `/v1`), and the transport is bound to the resolved
1409 /// candidate's endpoint, so a hand-written literal is a different string
1410 /// than the one the client actually uses.
1411 fn deepseek_actual_base_url(config: &Config, route: &TurnRoute) -> String {
1412 resolve_credentials_for_identity(
1413 config,
1414 route.provider,
1415 &route.provider_identity,
1416 &route.model,
1417 )
1418 .expect("test config must resolve the deepseek route")
1419 .base_url
1420 }
1421
1422 /// Redacted mismatch report for a route that unexpectedly failed closed.
1423 ///
1424 /// Names the non-secret field that diverged so a future regression is
1425 /// diagnosable without ever printing a key, a raw URL, or a credential
1426 /// generation digest.
1427 fn route_mismatch_report(config: &Config, snapshot: &SuggestionRouteSnapshot<'_>) -> String {
1428 let resolved = resolve_credentials_for_identity(
1429 config,
1430 snapshot.provider,
1431 snapshot.provider_identity.trim(),
1432 snapshot.model.trim(),
1433 );
1434 let credential_matches = resolved.as_ref().map(|credentials| {
1435 snapshot
1436 .authority
1437 .authorizes(&credentials.base_url, &credentials.api_key, None)
1438 });
1439 format!(
1440 "snapshot={snapshot:?}, resolved={resolved:?}, \
1441 resolved_authorized_by_receipt={credential_matches:?}"
1442 )
1443 }
1444
1445 fn config_snapshot<'a>(
1446 route: &'a TurnRoute,
1447 authority: &'a SuggestionRouteAuthority,
1448 actual_base_url: &'a str,
1449 ) -> SuggestionRouteSnapshot<'a> {
1450 SuggestionRouteSnapshot {
1451 provider: route.provider,
1452 provider_identity: route.provider_identity.as_str(),
1453 model: route.model.as_str(),
1454 authority,
1455 actual_base_url: Some(actual_base_url),
1456 }
1457 }
1458
1459 #[test]
1460 fn config_exact_unchanged_completed_route_launches() {
1461 let _env = seal_deepseek_env();
1462 let config = deepseek_config(DEEPSEEK_KEY, DEEPSEEK_BASE);
1463 let route = deepseek_turn_route(&config);
1464 let actual_base_url = deepseek_actual_base_url(&config, &route);
1465 let authority =
1466 capture_route_authority(&route).expect("deepseek turn must capture authority");
1467
1468 let snapshot = config_snapshot(&route, &authority, &actual_base_url);
1469 let launch = plan_suggestion_launch_with_config(&config, true, true, 4, Some(snapshot))
1470 .unwrap_or_else(|| {
1471 panic!(
1472 "an unchanged deepseek route must launch: {}",
1473 route_mismatch_report(&config, &snapshot)
1474 )
1475 });
1476
1477 assert_eq!(launch.base_url, actual_base_url);
1478 assert_eq!(launch.api_key, DEEPSEEK_KEY);
1479 assert_eq!(launch.model, route.model);
1480 // The turn's endpoint is the configured DeepSeek host, canonicalized
1481 // by the route resolver — not some other provider's endpoint.
1482 assert!(
1483 launch.base_url.contains("api.deepseek.com"),
1484 "unexpected endpoint host: {}",
1485 endpoint_identity(&launch.base_url)
1486 );
1487 }
1488
1489 #[test]
1490 fn configured_chat_path_override_fails_closed() {
1491 let _env = seal_deepseek_env();
1492 let mut config = deepseek_config(DEEPSEEK_KEY, DEEPSEEK_BASE);
1493 config
1494 .providers
1495 .as_mut()
1496 .expect("providers")
1497 .deepseek
1498 .path_suffix = Some("/private/chat".to_string());
1499 let route = deepseek_turn_route(&config);
1500 let authority =
1501 capture_route_authority(&route).expect("deepseek turn must capture authority");
1502 let identity = config
1503 .resolve_provider_identity("deepseek")
1504 .expect("deepseek identity");
1505 let actual_base_url = crate::route_runtime::resolve_runtime_route_for_identity(
1506 &config,
1507 &identity,
1508 Some(&route.model),
1509 )
1510 .expect("resolved route")
1511 .candidate
1512 .endpoint()
1513 .base_url
1514 .clone();
1515 let snapshot = config_snapshot(&route, &authority, &actual_base_url);
1516
1517 assert!(
1518 plan_suggestion_launch_with_config(&config, true, true, 4, Some(snapshot)).is_none(),
1519 "the suggestion helper must not bypass a provider-specific path contract"
1520 );
1521
1522 config
1523 .providers
1524 .as_mut()
1525 .expect("providers")
1526 .deepseek
1527 .path_suffix = Some(" ".to_string());
1528 assert!(
1529 resolve_credentials_for_identity(
1530 &config,
1531 route.provider,
1532 &route.provider_identity,
1533 &route.model,
1534 )
1535 .is_none(),
1536 "even a blank configured suffix is an installed transport override"
1537 );
1538 }
1539
1540 /// The #4404/#4411 race, end to end.
1541 ///
1542 /// Route A is resolved, preflighted, and installed; the engine mints its
1543 /// receipt from that client. Config is then mutated to route B *before* the
1544 /// TUI ever handles `TurnStarted` — which is reachable because web config
1545 /// events are drained ahead of engine events. Authority must still be A,
1546 /// and the completed turn's context must not be dispatchable with B.
1547 #[test]
1548 fn config_mutated_before_turn_started_cannot_move_authority_off_route_a() {
1549 let _env = seal_deepseek_env();
1550
1551 // --- Route A: resolved, preflighted, installed, receipt minted. ---
1552 const KEY_A: &str = "sk-route-a-secret";
1553 const BASE_A: &str = "https://api.deepseek.com/v1";
1554 let config_a = deepseek_config(KEY_A, BASE_A);
1555 let route = deepseek_turn_route(&config_a);
1556 let actual_base_url = deepseek_actual_base_url(&config_a, &route);
1557
1558 // --- Config mutates to route B, still before TurnStarted handling. ---
1559 const KEY_B: &str = "sk-route-b-attacker";
1560 const BASE_B: &str = "https://exfil.example.com/v1";
1561 let config_b = deepseek_config(KEY_B, BASE_B);
1562
1563 // --- TurnStarted handling. It takes no config, by construction. ---
1564 let authority =
1565 capture_route_authority(&route).expect("deepseek turn must capture authority");
1566 assert_eq!(
1567 authority.endpoint_identity(),
1568 endpoint_identity(&actual_base_url),
1569 "authority must describe route A, not whatever config says now"
1570 );
1571 assert!(
1572 !authority.endpoint_identity().contains("exfil.example.com"),
1573 "authority leaked onto route B: {}",
1574 authority.endpoint_identity()
1575 );
1576 assert!(
1577 !authority.authorizes(BASE_B, KEY_B, None),
1578 "route B must not be authorized by route A's receipt"
1579 );
1580 assert!(
1581 authority.authorizes(&actual_base_url, KEY_A, None),
1582 "route A must still authorize itself"
1583 );
1584
1585 // --- TurnComplete: the later suggestion launch is refused. ---
1586 let snapshot = config_snapshot(&route, &authority, &actual_base_url);
1587 assert!(
1588 plan_suggestion_launch_with_config(&config_b, true, true, 4, Some(snapshot)).is_none(),
1589 "completed-turn context must not be dispatchable under the mutated config"
1590 );
1591 // A mutation of only the key, with route A's endpoint intact, is the
1592 // narrower form of the same race and must also fail closed.
1593 let key_only_mutation = deepseek_config(KEY_B, BASE_A);
1594 assert!(
1595 plan_suggestion_launch_with_config(&key_only_mutation, true, true, 4, Some(snapshot))
1596 .is_none(),
1597 "a credential-only mutation must fail closed too"
1598 );
1599
1600 // --- Control: under the unmutated config A, the launch happens on A. ---
1601 let launch = plan_suggestion_launch_with_config(&config_a, true, true, 4, Some(snapshot))
1602 .unwrap_or_else(|| {
1603 panic!(
1604 "route A must still launch on itself: {}",
1605 route_mismatch_report(&config_a, &snapshot)
1606 )
1607 });
1608 assert_eq!(launch.api_key, KEY_A);
1609 assert_eq!(launch.base_url, actual_base_url);
1610 assert_ne!(launch.api_key, KEY_B);
1611 assert!(!launch.base_url.contains("exfil.example.com"));
1612 }
1613
1614 #[test]
1615 fn config_same_identity_base_url_mutation_fails_closed() {
1616 let _env = seal_deepseek_env();
1617 let config = deepseek_config(DEEPSEEK_KEY, DEEPSEEK_BASE);
1618 let route = deepseek_turn_route(&config);
1619 // The endpoint the completed turn really used, so this test fails
1620 // closed on the mutation itself rather than on a stale literal.
1621 let actual_base_url = deepseek_actual_base_url(&config, &route);
1622 let authority =
1623 capture_route_authority(&route).expect("deepseek turn must capture authority");
1624
1625 // The web config surface repoints the SAME provider identity at a
1626 // different endpoint while the turn is still running.
1627 let mutated = deepseek_config(DEEPSEEK_KEY, "https://exfil.example.com/v1");
1628
1629 assert!(
1630 plan_suggestion_launch_with_config(
1631 &mutated,
1632 true,
1633 true,
1634 4,
1635 Some(config_snapshot(&route, &authority, &actual_base_url)),
1636 )
1637 .is_none(),
1638 "a same-identity endpoint mutation must send no context anywhere"
1639 );
1640 }
1641
1642 #[test]
1643 fn config_same_identity_api_key_mutation_fails_closed() {
1644 let _env = seal_deepseek_env();
1645 let config = deepseek_config(DEEPSEEK_KEY, DEEPSEEK_BASE);
1646 let route = deepseek_turn_route(&config);
1647 let actual_base_url = deepseek_actual_base_url(&config, &route);
1648 let authority =
1649 capture_route_authority(&route).expect("deepseek turn must capture authority");
1650
1651 // Same identity, same endpoint, different credential. Everything except
1652 // the key matches, so only the credential-generation gate can reject.
1653 let mutated = deepseek_config("sk-attacker-rotated", DEEPSEEK_BASE);
1654 assert_eq!(
1655 deepseek_actual_base_url(&mutated, &route),
1656 actual_base_url,
1657 "this test must isolate the credential rotation, not an endpoint change"
1658 );
1659
1660 assert!(
1661 plan_suggestion_launch_with_config(
1662 &mutated,
1663 true,
1664 true,
1665 4,
1666 Some(config_snapshot(&route, &authority, &actual_base_url)),
1667 )
1668 .is_none(),
1669 "a same-identity credential mutation must send no context anywhere"
1670 );
1671 }
1672
1673 #[test]
1674 fn config_selection_switch_cannot_redirect_the_completed_route() {
1675 let _env = seal_deepseek_env();
1676 let config = deepseek_config(DEEPSEEK_KEY, DEEPSEEK_BASE);
1677 let route = deepseek_turn_route(&config);
1678 let actual_base_url = deepseek_actual_base_url(&config, &route);
1679 let authority =
1680 capture_route_authority(&route).expect("deepseek turn must capture authority");
1681
1682 // Ordinary UI selection switch: the live provider is now OpenAI, with
1683 // its own key and endpoint. The completed DeepSeek turn must still
1684 // resolve DeepSeek — and must never reach the OpenAI route.
1685 let mut switched = deepseek_config(DEEPSEEK_KEY, DEEPSEEK_BASE);
1686 switched.provider = Some("openai".to_string());
1687 {
1688 let providers = switched
1689 .providers
1690 .get_or_insert_with(ProvidersConfig::default);
1691 providers.openai.api_key = Some("sk-openai-secret".to_string());
1692 providers.openai.base_url = Some("https://api.openai.com/v1".to_string());
1693 }
1694
1695 let snapshot = config_snapshot(&route, &authority, &actual_base_url);
1696 let launch = plan_suggestion_launch_with_config(&switched, true, true, 4, Some(snapshot))
1697 .unwrap_or_else(|| {
1698 panic!(
1699 "the completed deepseek route stays valid across a selection switch: {}",
1700 route_mismatch_report(&switched, &snapshot)
1701 )
1702 });
1703
1704 assert_eq!(launch.base_url, actual_base_url);
1705 assert_eq!(launch.api_key, DEEPSEEK_KEY);
1706 assert_ne!(launch.api_key, "sk-openai-secret");
1707 assert!(!launch.base_url.contains("openai"));
1708 }
1709
1710 #[test]
1711 fn config_unsupported_providers_capture_no_authority() {
1712 let _env = seal_deepseek_env();
1713 // A usable Chat Completions credential exists in this config, and
1714 // each route below is even handed a receipt. A Messages/Responses
1715 // completed route must still capture nothing.
1716 let config = deepseek_config(DEEPSEEK_KEY, DEEPSEEK_BASE);
1717
1718 for (provider, identity, model) in [
1719 (ApiProvider::Anthropic, "anthropic", "claude-sonnet-4"),
1720 (ApiProvider::OpenaiCodex, "openai-codex", "gpt-5.4"),
1721 (
1722 ApiProvider::DeepseekAnthropic,
1723 "deepseek-anthropic",
1724 "deepseek-chat",
1725 ),
1726 ] {
1727 let route = TurnRoute {
1728 provider,
1729 provider_identity: identity.to_string(),
1730 model: model.to_string(),
1731 auto_model: false,
1732 receipt: Some(receipt(
1733 provider,
1734 identity,
1735 model,
1736 DEEPSEEK_BASE,
1737 DEEPSEEK_KEY,
1738 )),
1739 billing: Some(crate::core::events::RouteBillingEnvelope {
1740 billing_surface: None,
1741 endpoint_fingerprint: None,
1742 openrouter_vendor: None,
1743 provider_live_pricing: None,
1744 billing_mode: crate::cost_status::RouteBillingMode::Unknown,
1745 dispatched_at: chrono::Utc::now(),
1746 }),
1747 base_url: DEEPSEEK_BASE.to_string(),
1748 billing_product: crate::route_billing::RouteProduct::Unproven,
1749 };
1750 assert!(
1751 capture_route_authority(&route).is_none(),
1752 "{provider:?} must not capture a suggestion authority"
1753 );
1754 }
1755
1756 // The direct credential resolver refuses unsupported providers too, so
1757 // no later caller can reach a key through it.
1758 assert!(
1759 resolve_credentials_for_identity(
1760 &config,
1761 ApiProvider::DeepseekAnthropic,
1762 "deepseek-anthropic",
1763 "deepseek-chat",
1764 )
1765 .is_none(),
1766 "DeepseekAnthropic must never reach a credential lookup"
1767 );
1768 }
1769
1770 #[test]
1771 fn route_without_a_receipt_captures_no_authority() {
1772 let _env = seal_deepseek_env();
1773 let config = deepseek_config(DEEPSEEK_KEY, DEEPSEEK_BASE);
1774 let mut route = deepseek_turn_route(&config);
1775 route.receipt = None;
1776 assert!(
1777 capture_route_authority(&route).is_none(),
1778 "a turn with no installed-client receipt has no provenance to trust"
1779 );
1780 }
1781
1782 #[test]
1783 fn receipt_describing_another_route_captures_no_authority() {
1784 let _env = seal_deepseek_env();
1785 let config = deepseek_config(DEEPSEEK_KEY, DEEPSEEK_BASE);
1786 let mut route = deepseek_turn_route(&config);
1787 // Same provider and identity, different wire model than the event's
1788 // own route: the chain is broken, not merely stale.
1789 route.receipt = Some(receipt(
1790 ApiProvider::Deepseek,
1791 "deepseek",
1792 "some-other-model",
1793 DEEPSEEK_BASE,
1794 DEEPSEEK_KEY,
1795 ));
1796 assert!(capture_route_authority(&route).is_none());
1797
1798 route.receipt = Some(receipt(
1799 ApiProvider::DeepseekCN,
1800 "deepseek-cn",
1801 &route.model,
1802 DEEPSEEK_BASE,
1803 DEEPSEEK_KEY,
1804 ));
1805 assert!(capture_route_authority(&route).is_none());
1806 }
1807
1808 #[test]
1809 fn config_requires_the_endpoint_the_turn_actually_used() {
1810 let _env = seal_deepseek_env();
1811 let config = deepseek_config(DEEPSEEK_KEY, DEEPSEEK_BASE);
1812 let route = deepseek_turn_route(&config);
1813 let actual_base_url = deepseek_actual_base_url(&config, &route);
1814 let authority =
1815 capture_route_authority(&route).expect("deepseek turn must capture authority");
1816
1817 // Control: with the endpoint the turn really used, this route launches.
1818 // Without it, the two negatives below would prove nothing.
1819 let baseline = config_snapshot(&route, &authority, &actual_base_url);
1820 assert!(
1821 plan_suggestion_launch_with_config(&config, true, true, 4, Some(baseline)).is_some(),
1822 "baseline route must launch: {}",
1823 route_mismatch_report(&config, &baseline)
1824 );
1825
1826 // `Event::TurnComplete` reported a different endpoint than the one the
1827 // receipt was minted from: the turn was not on this route.
1828 let mut snapshot = config_snapshot(&route, &authority, &actual_base_url);
1829 snapshot.actual_base_url = Some("https://exfil.example.com/v1");
1830 assert!(
1831 plan_suggestion_launch_with_config(&config, true, true, 4, Some(snapshot)).is_none(),
1832 "a completed turn on a different endpoint must fail closed"
1833 );
1834
1835 // A missing endpoint is missing provenance, not an implicit match.
1836 let mut snapshot = config_snapshot(&route, &authority, &actual_base_url);
1837 snapshot.actual_base_url = None;
1838 assert!(
1839 plan_suggestion_launch_with_config(&config, true, true, 4, Some(snapshot)).is_none(),
1840 "an absent turn endpoint must fail closed"
1841 );
1842 }
1843 }
1844
1844 lines RUST