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