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