| 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={}{}®ion=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 |