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