返回 CodeWhale
route_runtime.rs
根目录 / crates / tui / src / route_runtime.rs
1 use std::collections::BTreeMap;
2
3 use chrono::{DateTime, Duration, Utc};
4 use codewhale_config::route::{
5 LimitField, LogicalModelRef, OverrideSource, ReadyRouteCandidate, RouteError, RouteLimits,
6 RouteRequest, RouteResolver, SourcedLimitOverride, WireModelId,
7 };
8 use serde::Serialize;
9
10 use crate::client::CodewhaleClient;
11 use crate::codex_model_cache::{CodexModelCacheFreshness, CodexModelRoster, model_roster_for};
12 use crate::config::{
13 Config, KIMI_CODE_K3_CONTEXT_WINDOW_TOKENS, ProviderIdentity, ProviderKind,
14 is_exact_direct_moonshot_k3_route, is_exact_kimi_code_bare_k3_route,
15 validate_kimi_code_api_model_id,
16 };
17 use codewhale_models::DIRECT_KIMI_K3_MAX_OUTPUT_TOKENS;
18
19 /// Why a route is using its effective context-window value. Keep this
20 /// receipt separate from the numeric route limits so every consumer can state
21 /// whether the number is operator-configured, freshly provider-reported, a
22 /// Kimi Code safety floor, catalog data, or a conservative fallback.
23 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
24 #[serde(rename_all = "snake_case")]
25 pub(crate) enum ContextWindowSource {
26 Configured,
27 /// `[providers.<id>.model_context_windows]` hit for this exact wire model
28 /// id — outranks the provider-level `Configured` rung (#6108).
29 ConfiguredModel,
30 UserDeclared,
31 ProviderReported,
32 StaticKimiCodeSafeFloor,
33 Catalog,
34 /// Parsed from a vendor-agnostic `_Nk` suffix in the model name
35 /// (#5441). Optimistic, unlike the conservative [`Self::Fallback`]: a
36 /// serving engine may ignore its own naming convention, so the number
37 /// drives real budgets but is never evidence about the route.
38 NameSuffixHint,
39 Fallback,
40 }
41
42 impl ContextWindowSource {
43 /// Every rung, in precedence order. The name-suffix hint sits between
44 /// catalog data and the conservative fallback: any concrete fact about
45 /// the route beats a naming convention.
46 pub(crate) const ALL: [Self; 8] = [
47 Self::ConfiguredModel,
48 Self::Configured,
49 Self::UserDeclared,
50 Self::ProviderReported,
51 Self::StaticKimiCodeSafeFloor,
52 Self::Catalog,
53 Self::NameSuffixHint,
54 Self::Fallback,
55 ];
56
57 #[must_use]
58 pub(crate) const fn label(self) -> &'static str {
59 match self {
60 Self::Configured => "configured",
61 Self::ConfiguredModel => "configured (per-model)",
62 Self::UserDeclared => "user declared",
63 Self::ProviderReported => "provider-reported",
64 Self::StaticKimiCodeSafeFloor => "static Kimi Code safe floor",
65 Self::Catalog => "catalog",
66 Self::NameSuffixHint => "model-name hint",
67 Self::Fallback => "fallback",
68 }
69 }
70
71 /// Recover the rung a serialized report wrote, so a surface holding only
72 /// the label still reads verification off the enum instead of matching
73 /// strings. An unrecognized label is nobody's rung.
74 #[must_use]
75 pub(crate) fn from_label(label: &str) -> Option<Self> {
76 Self::ALL.into_iter().find(|rung| rung.label() == label)
77 }
78
79 /// Whether the window rests on evidence about this exact route. The
80 /// name-suffix hint and the fallback rung are guesses — one parsed from a
81 /// naming convention, one made because nothing described the model — so
82 /// no surface may present either as a capability we checked (#5239,
83 /// #5441).
84 #[must_use]
85 pub(crate) const fn is_verified(self) -> bool {
86 !matches!(
87 self,
88 Self::NameSuffixHint | Self::Fallback | Self::UserDeclared
89 )
90 }
91
92 /// Suffix every rendered window carries: verified rungs stay bare,
93 /// guesses say so next to the number that drives the budget.
94 #[must_use]
95 pub(crate) const fn honesty_suffix(self) -> &'static str {
96 if self.is_verified() {
97 ""
98 } else {
99 " (unverified)"
100 }
101 }
102
103 /// [`Self::label`] plus [`Self::honesty_suffix`], ready for inline
104 /// rendering (status line, `/status`, `/config` rows).
105 #[must_use]
106 pub(crate) fn display_label(self) -> String {
107 format!("{}{}", self.label(), self.honesty_suffix())
108 }
109 }
110
111 /// Context window carried alongside an exact runtime route.
112 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
113 pub(crate) struct ContextWindowResolution {
114 pub(crate) tokens: u32,
115 pub(crate) source: ContextWindowSource,
116 }
117
118 /// Resolve the effective context window for a host holding no fully resolved
119 /// route candidate: an `auto` selection, a model switch that keeps the current
120 /// endpoint, or a route resolution that failed.
121 ///
122 /// Only the rungs derivable without an endpoint-scoped candidate are reachable
123 /// here — operator config, then offering/catalog limits, then the conservative
124 /// capability fallback. The provider-reported and Kimi Code safe-floor rungs
125 /// need a resolved candidate and stay in [`plan_limit_overrides`].
126 ///
127 /// The catalog predicate must stay identical to the one in
128 /// [`crate::route_budget::route_context_window_tokens`]: the pressure meter and
129 /// compaction trigger read their number from there, so any divergence would
130 /// print one rung's number under another rung's label.
131 #[must_use]
132 pub(crate) fn resolve_context_window(
133 provider: ProviderKind,
134 model: &str,
135 route_limits: Option<RouteLimits>,
136 context_window_override: Option<u32>,
137 model_context_windows: Option<&BTreeMap<String, u32>>,
138 ) -> ContextWindowResolution {
139 if let Some(tokens) = model_context_windows
140 .and_then(|table| table.get(model).copied())
141 .filter(|tokens| *tokens > 0)
142 {
143 return ContextWindowResolution {
144 tokens,
145 source: ContextWindowSource::ConfiguredModel,
146 };
147 }
148 if let Some(tokens) = context_window_override.filter(|tokens| *tokens > 0) {
149 return ContextWindowResolution {
150 tokens,
151 source: ContextWindowSource::Configured,
152 };
153 }
154 if let Some(tokens) = route_limits
155 .and_then(|limits| limits.context_tokens)
156 .and_then(|tokens| u32::try_from(tokens).ok())
157 .filter(|tokens| *tokens > 0)
158 {
159 return ContextWindowResolution {
160 tokens,
161 source: ContextWindowSource::Catalog,
162 };
163 }
164 let tokens = crate::route_budget::route_context_window_tokens(provider, model, None);
165 ContextWindowResolution {
166 tokens,
167 source: classify_capability_fallback_window(model, tokens),
168 }
169 }
170
171 /// Classify a window the provider/model capability fallback produced, so the
172 /// receipt names the rung the number actually came from (#5239, #5441).
173 ///
174 /// A value parsed from an `_Nk` model-name suffix is its own optimistic rung:
175 /// the serving engine may not honor its own naming convention. Everything
176 /// else the fallback produced — vendor-family heuristics, provider floors,
177 /// the conservative default — is the plain fallback rung. Both are
178 /// unverified; the ladder keeps them apart because they fail differently
179 /// (a hint that overstates the window delays compaction past the provider's
180 /// real limit).
181 fn classify_capability_fallback_window(model: &str, tokens: u32) -> ContextWindowSource {
182 if codewhale_models::name_suffix_context_window_hint(model) == Some(tokens) {
183 ContextWindowSource::NameSuffixHint
184 } else {
185 ContextWindowSource::Fallback
186 }
187 }
188
189 /// Authenticated Kimi Code `/models` metadata that a caller has already
190 /// validated. This is intentionally route-scoped: generic Moonshot metadata
191 /// can never promote a bare `k3` route. The current runtime has no implicit
192 /// network probe; an authenticated model-listing consumer may pass this value
193 /// to [`resolve_route_candidate_with_context_metadata`].
194 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
195 pub(crate) struct ProviderReportedKimiCodeContext {
196 pub(crate) context_tokens: u32,
197 pub(crate) observed_at: DateTime<Utc>,
198 }
199
200 const KIMI_CODE_REPORTED_CONTEXT_MAX_AGE_HOURS: i64 = 24;
201
202 #[derive(Debug)]
203 pub(crate) struct RouteCandidateResolution {
204 pub(crate) candidate: ReadyRouteCandidate,
205 pub(crate) context_window: ContextWindowResolution,
206 }
207
208 #[derive(Clone)]
209 pub(crate) struct ResolvedRuntimeRoute {
210 pub(crate) identity: ProviderIdentity,
211 pub(crate) candidate: ReadyRouteCandidate,
212 pub(crate) config: Box<Config>,
213 pub(crate) model: String,
214 pub(crate) context_window: ContextWindowResolution,
215 preflighted_client: Option<Box<CodewhaleClient>>,
216 }
217
218 impl std::fmt::Debug for ResolvedRuntimeRoute {
219 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220 f.debug_struct("ResolvedRuntimeRoute")
221 .field("provider_identity", &self.identity.key)
222 .field("provider", &self.identity.provider)
223 .field("model", &self.model)
224 .finish_non_exhaustive()
225 }
226 }
227
228 /// One exact provider route, fully resolved and client-preflighted before a
229 /// host mutates session/runtime state. The config and client may contain
230 /// credentials, so diagnostics intentionally expose only non-secret receipt
231 /// fields.
232 #[derive(Clone)]
233 pub(crate) struct ValidatedRuntimeRoute {
234 pub(crate) identity: ProviderIdentity,
235 pub(crate) candidate: ReadyRouteCandidate,
236 pub(crate) config: Box<Config>,
237 pub(crate) model: String,
238 pub(crate) context_window: ContextWindowResolution,
239 pub(crate) client: CodewhaleClient,
240 }
241
242 impl std::fmt::Debug for ValidatedRuntimeRoute {
243 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244 f.debug_struct("ValidatedRuntimeRoute")
245 .field("provider_identity", &self.identity.key)
246 .field("provider", &self.identity.provider)
247 .field("model", &self.model)
248 .finish_non_exhaustive()
249 }
250 }
251
252 /// Who reads a route preflight failure. The interactive app can run slash
253 /// commands; a headless caller only has the CLI. Only `codewhale exec` asks
254 /// for [`Self::Headless`] today (through [`ResolvedRuntimeRoute::validate_for`]);
255 /// `preflight` and other callers keep the interactive wording.
256 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
257 pub(crate) enum RouteErrorSurface {
258 Interactive,
259 Headless,
260 }
261
262 impl ResolvedRuntimeRoute {
263 pub(crate) fn preflight(mut self) -> Result<Self, String> {
264 self.config.verify_provider_identity(&self.identity)?;
265 if self.preflighted_client.is_none() {
266 self.preflighted_client = Some(Box::new(
267 CodewhaleClient::from_candidate(&self.config, &self.candidate).map_err(|err| {
268 format_provider_route_preflight_error(
269 self.identity.key.as_str(),
270 &self.model,
271 &err,
272 RouteErrorSurface::Interactive,
273 )
274 })?,
275 ));
276 }
277 Ok(self)
278 }
279
280 pub(crate) fn validate(self) -> Result<ValidatedRuntimeRoute, String> {
281 self.validate_for(RouteErrorSurface::Interactive)
282 }
283
284 /// [`Self::validate`] with next steps worded for `surface`.
285 pub(crate) fn validate_for(
286 mut self,
287 surface: RouteErrorSurface,
288 ) -> Result<ValidatedRuntimeRoute, String> {
289 self.config.verify_provider_identity(&self.identity)?;
290 let client = match self.preflighted_client.take() {
291 Some(client) => *client,
292 None => {
293 CodewhaleClient::from_candidate(&self.config, &self.candidate).map_err(|err| {
294 format_provider_route_preflight_error(
295 self.identity.key.as_str(),
296 &self.model,
297 &err,
298 surface,
299 )
300 })?
301 }
302 };
303 if client.admitted_provider_identity() != &self.identity {
304 return Err("preflighted client does not match captured provider identity".into());
305 }
306 Ok(ValidatedRuntimeRoute {
307 identity: self.identity,
308 candidate: self.candidate,
309 config: self.config,
310 model: self.model,
311 context_window: self.context_window,
312 client,
313 })
314 }
315
316 pub(crate) fn take_preflighted_client(&mut self) -> Option<CodewhaleClient> {
317 self.preflighted_client.take().map(|client| *client)
318 }
319 }
320
321 fn format_provider_route_preflight_error(
322 identity_key: &str,
323 model: &str,
324 err: &anyhow::Error,
325 surface: RouteErrorSurface,
326 ) -> String {
327 let reason = err.to_string();
328 let reason = reason.trim();
329 // Multi-line guidance ends in a command to copy; a period appended to it
330 // would break the paste, so it gets a line break instead.
331 let multi_line = reason.contains('\n');
332 let mut message = if multi_line {
333 format!("{reason}\nFailed to configure provider route {identity_key} / {model}.")
334 } else {
335 format!(
336 "{}. Failed to configure provider route {identity_key} / {model}.",
337 reason.trim_end_matches('.')
338 )
339 };
340 if let Some(next_step) =
341 classify_provider_route_preflight_next_step(identity_key, reason, surface)
342 {
343 message.push_str(if multi_line { "\n" } else { " " });
344 message.push_str("Next step: ");
345 message.push_str(&next_step);
346 }
347 message
348 }
349
350 fn classify_provider_route_preflight_next_step(
351 identity_key: &str,
352 reason: &str,
353 surface: RouteErrorSurface,
354 ) -> Option<String> {
355 let headless = surface == RouteErrorSurface::Headless;
356 let lower = reason.to_ascii_lowercase();
357 if lower.contains("chatgpt credentials are only available on the official public api route") {
358 return Some(if headless {
359 format!(
360 "Remove the custom base_url from [providers.{identity_key}]; ChatGPT plan access only works on the official public API route."
361 )
362 } else {
363 format!(
364 "Run /provider setup {identity_key} and remove its custom base URL; ChatGPT plan access only works on the official public API route."
365 )
366 });
367 }
368 if lower.contains("sign in with chatgpt")
369 || lower.contains("chatgpt credentials")
370 || lower.contains("chatgpt grant")
371 || lower.contains("chatgpt registration")
372 || lower.contains("openai codex oauth credentials are unavailable")
373 || lower.contains("codex access token")
374 {
375 return Some(if headless {
376 "Run `codewhale auth chatgpt` to Sign in with ChatGPT.".to_string()
377 } else {
378 format!(
379 "Run `codewhale auth chatgpt` or /provider setup {identity_key} to Sign in with ChatGPT."
380 )
381 });
382 }
383 if lower.contains("api key not found")
384 || lower.contains("access token")
385 || (lower.contains("credential")
386 && (lower.contains("not found")
387 || lower.contains("missing")
388 || lower.contains("unsupported")))
389 {
390 if !headless {
391 return Some(format!(
392 "Run /auth or /provider setup {identity_key} to configure credentials."
393 ));
394 }
395 // The reason usually names its own command (`codewhale auth set`,
396 // `codewhale auth chatgpt`, `codewhale auth xai-device`, ...);
397 // repeating one would make two instructions out of one.
398 if lower.contains("codewhale auth") {
399 return None;
400 }
401 // `auth set` stores an API key. An access-token or other credential
402 // failure may belong to an OAuth route, where it is the wrong fix.
403 if !lower.contains("api key") {
404 return Some(
405 "Run `codewhale doctor` to see which credential this route needs.".to_string(),
406 );
407 }
408 return Some(
409 match ProviderKind::parse(identity_key).filter(|p| *p != ProviderKind::Custom) {
410 Some(provider) => {
411 format!("Run `codewhale auth set --provider {}`.", provider.as_str())
412 }
413 // A custom identity's key is its `[providers.<name>]` table.
414 None => format!(
415 "Add api_key or api_key_env to [providers.{identity_key}] in config.toml."
416 ),
417 },
418 );
419 }
420 if lower.contains("tls certificate")
421 || lower.contains("ssl_cert_file")
422 || lower.contains("certificate verification")
423 || lower.contains("insecure_skip_tls_verify")
424 || lower.contains("base url")
425 || lower.contains("invalid url")
426 {
427 return Some(if headless {
428 format!("Fix base_url/TLS settings in [providers.{identity_key}] of config.toml.")
429 } else {
430 format!("Run /provider setup {identity_key} to fix base URL/TLS settings.")
431 });
432 }
433 if lower.contains("provider")
434 && lower.contains("model")
435 && (lower.contains("pin")
436 || lower.contains("mismatch")
437 || lower.contains("unknown")
438 || lower.contains("not found"))
439 {
440 return Some(if headless {
441 "Pass a model this provider serves with --model (`codewhale models` lists them)."
442 .to_string()
443 } else {
444 "Run /models (or open the model picker) and choose a model valid for this provider."
445 .to_string()
446 });
447 }
448 if lower.contains("fleet") || lower.contains("profile") || lower.contains("partial route") {
449 return Some(
450 "Review Fleet profile provider/model overrides; keep route fields atomic (#5042)."
451 .to_string(),
452 );
453 }
454 Some(if headless {
455 "Run `codewhale doctor` to review this route configuration.".to_string()
456 } else {
457 format!("Run /provider setup {identity_key} to review this route configuration.")
458 })
459 }
460
461 impl ValidatedRuntimeRoute {
462 /// Preserve the preflighted client with the exact resolved route receipt
463 /// so the engine does not repeat environment-sensitive client discovery.
464 pub(crate) fn into_resolved(self) -> ResolvedRuntimeRoute {
465 ResolvedRuntimeRoute {
466 identity: self.identity,
467 candidate: self.candidate,
468 config: self.config,
469 model: self.model,
470 context_window: self.context_window,
471 preflighted_client: Some(Box::new(self.client)),
472 }
473 }
474 }
475
476 pub(crate) fn resolve_route_candidate(
477 provider: ProviderKind,
478 model_selector: Option<&str>,
479 saved_provider_model: Option<&str>,
480 base_url_override: Option<String>,
481 context_window_override: Option<u32>,
482 model_context_windows: Option<&BTreeMap<String, u32>>,
483 ) -> Result<ReadyRouteCandidate, String> {
484 resolve_route_candidate_with_context_metadata(
485 provider,
486 model_selector,
487 saved_provider_model,
488 base_url_override,
489 context_window_override,
490 model_context_windows,
491 None,
492 )
493 .map(|resolution| resolution.candidate)
494 }
495
496 /// Reject only a provider-less model mismatch that existing route knowledge
497 /// proves foreign. Partial catalogs are not allowlists: unknown ids, local
498 /// runtimes, gateways, and custom endpoints remain provider-authoritative.
499 pub(crate) fn validate_unpinned_model_provider(
500 provider: ProviderKind,
501 model: &str,
502 base_url: &str,
503 ) -> Result<(), String> {
504 let kind = provider;
505 let Some(owner) = codewhale_config::known_foreign_model_owner(kind, model, base_url) else {
506 return Ok(());
507 };
508 Err(format!(
509 "Model `{}` was supplied without an explicit provider pin, but the resolved route is `{}` and the owning provider is `{}`. Pin the provider together with the model, or inherit the session route.",
510 model.trim(),
511 provider.as_str(),
512 owner.as_str()
513 ))
514 }
515
516 /// Resolve a provider-less fixed model to the provider's exact wire id before
517 /// child admission. This shares the runtime resolver used by Fleet receipts,
518 /// including aggregator alias translation, without making a live request.
519 #[cfg(test)]
520 pub(crate) fn resolve_unpinned_model_candidate(
521 provider: ProviderKind,
522 model: &str,
523 base_url: &str,
524 ) -> Result<ReadyRouteCandidate, String> {
525 validate_unpinned_model_provider(provider, model, base_url)?;
526 resolve_route_candidate(
527 provider,
528 Some(model),
529 None,
530 Some(base_url.to_string()),
531 None,
532 None,
533 )
534 }
535
536 /// Resolve a candidate together with a non-secret context-window provenance
537 /// receipt. `provider_reported_context` is accepted only for the exact Kimi
538 /// Code bare-K3 endpoint, only at the documented 1M entitlement, and only
539 /// while fresh; this prevents generic Moonshot or stale metadata from being
540 /// inherited by a membership-plan route.
541 /// Resolve a manual selection from the App's loaded, non-secret metadata
542 /// snapshot. This shares the same scoped resolver and limit precedence as
543 /// config-backed runtime selection, without loading credentials while typing.
544 pub(crate) fn resolve_declared_model_candidate(
545 provider: ProviderKind,
546 identity: &str,
547 model: &str,
548 base_url: &str,
549 context_window: Option<u32>,
550 model_context_windows: Option<&BTreeMap<String, u32>>,
551 models: &[codewhale_config::catalog::configured::ConfiguredModel],
552 ) -> Result<RouteCandidateResolution, String> {
553 let resolver =
554 RouteResolver::new().with_configured_models(models, identity, provider, base_url);
555 resolve_route_candidate_with_catalog_resolver(
556 provider,
557 Some(model),
558 None,
559 Some(base_url.into()),
560 context_window,
561 model_context_windows,
562 None,
563 None,
564 &resolver,
565 false,
566 false,
567 )
568 }
569
570 pub(crate) fn resolve_route_candidate_with_context_metadata(
571 provider: ProviderKind,
572 model_selector: Option<&str>,
573 saved_provider_model: Option<&str>,
574 base_url_override: Option<String>,
575 context_window_override: Option<u32>,
576 model_context_windows: Option<&BTreeMap<String, u32>>,
577 provider_reported_context: Option<ProviderReportedKimiCodeContext>,
578 ) -> Result<RouteCandidateResolution, String> {
579 resolve_route_candidate_with_catalog_resolver(
580 provider,
581 model_selector,
582 saved_provider_model,
583 base_url_override,
584 context_window_override,
585 model_context_windows,
586 provider_reported_context,
587 None,
588 &RouteResolver::new(),
589 false,
590 false,
591 )
592 }
593
594 /// #6705: OpenCode Zen is the one model-aware route whose protocol roster
595 /// comes from the Models.dev snapshot, and only the provider-lake resolver
596 /// carries that snapshot. There, an unproven Zen model may simply be newer than
597 /// the loaded catalog, so name the refresh that can prove it. Anywhere else a
598 /// refresh cannot change the answer and is not offered.
599 fn route_error_text(
600 provider: ProviderKind,
601 resolver_reads_models_dev: bool,
602 err: &RouteError,
603 ) -> String {
604 let text = err.to_string();
605 match err {
606 RouteError::UnsupportedModelProtocol { endpoint_key, .. }
607 if resolver_reads_models_dev
608 && provider == ProviderKind::OpencodeZen
609 && endpoint_key == "unproven" =>
610 {
611 format!("{text}, or refresh the Models.dev catalog with `codewhale models --update`")
612 }
613 _ => text,
614 }
615 }
616
617 fn resolve_route_candidate_with_catalog_resolver(
618 provider: ProviderKind,
619 model_selector: Option<&str>,
620 saved_provider_model: Option<&str>,
621 base_url_override: Option<String>,
622 context_window_override: Option<u32>,
623 model_context_windows: Option<&BTreeMap<String, u32>>,
624 provider_reported_context: Option<ProviderReportedKimiCodeContext>,
625 codex_roster: Option<&CodexModelRoster>,
626 resolver: &RouteResolver,
627 endpoint_catalog_authoritative: bool,
628 resolver_reads_models_dev: bool,
629 ) -> Result<RouteCandidateResolution, String> {
630 let effective_base_url = base_url_override
631 .as_deref()
632 .unwrap_or_else(|| provider.provider().default_base_url());
633 if let Some(model) = model_selector.or(saved_provider_model) {
634 validate_kimi_code_api_model_id(provider, effective_base_url, model)?;
635 }
636 let base_request = RouteRequest {
637 explicit_provider: Some(provider),
638 model_selector: model_selector.map(|model| LogicalModelRef::from(model.to_string())),
639 saved_provider_model: saved_provider_model
640 .map(|model| WireModelId::from(model.to_string())),
641 base_url_override,
642 limit_overrides: Vec::new(),
643 };
644 // First pass: resolve the route without overrides to learn the effective
645 // endpoint, wire model id, and catalog limits. Candidates are immutable, so
646 // limit adjustments are planned from this read-only resolution and then
647 // requested through `RouteRequest::limit_overrides` on a second pass; the
648 // resolver applies them BEFORE minting the final candidate and records
649 // their provenance on it.
650 let resolve = |request: &RouteRequest| {
651 if endpoint_catalog_authoritative {
652 resolver.resolve_with_endpoint_catalog_authority(request)
653 } else {
654 resolver.resolve(request)
655 }
656 };
657 let route_error = |err: RouteError| route_error_text(provider, resolver_reads_models_dev, &err);
658 let resolved = resolve(&base_request).map_err(route_error)?;
659 let plan = plan_limit_overrides(
660 provider,
661 &resolved,
662 context_window_override,
663 model_context_windows,
664 provider_reported_context,
665 codex_roster,
666 );
667 let candidate = if plan.overrides.is_empty() {
668 resolved
669 } else {
670 resolve(&RouteRequest {
671 limit_overrides: plan.overrides,
672 ..base_request
673 })
674 .map_err(route_error)?
675 };
676 Ok(RouteCandidateResolution {
677 candidate,
678 context_window: plan.context_window,
679 })
680 }
681
682 /// The sourced limit overrides a route needs, plus the context-window receipt
683 /// describing the effective context value they produce.
684 struct LimitOverridePlan {
685 overrides: Vec<SourcedLimitOverride>,
686 context_window: ContextWindowResolution,
687 }
688
689 /// Plan the limit overrides for a resolved route.
690 ///
691 /// Precedence (unchanged from the previous post-hoc mutation order):
692 /// provider-scoped roster/API corrections and exact-route documented output
693 /// facts first, then operator-configured context, then fresh route-scoped
694 /// provider-reported context, then the membership-plan safe floor, then
695 /// catalog data, then the conservative fallback.
696 fn plan_limit_overrides(
697 provider: ProviderKind,
698 resolved: &ReadyRouteCandidate,
699 context_window_override: Option<u32>,
700 model_context_windows: Option<&BTreeMap<String, u32>>,
701 provider_reported_context: Option<ProviderReportedKimiCodeContext>,
702 codex_roster: Option<&CodexModelRoster>,
703 ) -> LimitOverridePlan {
704 let mut overrides = Vec::new();
705 let declared_field = |field| {
706 resolved
707 .applied_limit_overrides()
708 .iter()
709 .rev()
710 .find(|entry| entry.field == field)
711 .is_some_and(|entry| entry.source == OverrideSource::UserModelMetadata)
712 };
713 // An exact wire-id hit in `model_context_windows` is a sharper operator
714 // declaration than the provider default, so it wins (#6108).
715 let model_configured = model_context_windows
716 .and_then(|table| table.get(resolved.wire_model_id().as_str()).copied())
717 .filter(|window| *window > 0);
718 let configured =
719 model_configured.or_else(|| context_window_override.filter(|window| *window > 0));
720 let mut effective_context = resolved.limits().context_tokens;
721 if !declared_field(LimitField::OutputTokens)
722 && is_exact_direct_moonshot_k3_route(
723 provider,
724 &resolved.endpoint().base_url,
725 resolved.wire_model_id().as_str(),
726 )
727 {
728 overrides.push(SourcedLimitOverride {
729 field: LimitField::OutputTokens,
730 value: Some(u64::from(DIRECT_KIMI_K3_MAX_OUTPUT_TOKENS)),
731 source: OverrideSource::DocumentedRouteOutputMaximum,
732 });
733 }
734 if provider == ProviderKind::OpenaiCodex {
735 // Models.dev describes the public API offering, not the account-scoped
736 // ChatGPT OAuth route. Strip API-only limits, then carry the fresh
737 // Codex roster's per-model context into every runtime consumer.
738 overrides.push(SourcedLimitOverride {
739 field: LimitField::InputTokens,
740 value: None,
741 source: OverrideSource::CodexPublicApiLimitStrip,
742 });
743 overrides.push(SourcedLimitOverride {
744 field: LimitField::OutputTokens,
745 value: None,
746 source: OverrideSource::CodexPublicApiLimitStrip,
747 });
748 if configured.is_none() {
749 let roster_context = codex_roster
750 .filter(|roster| roster.freshness == CodexModelCacheFreshness::Fresh)
751 .and_then(|roster| roster.metadata_for(resolved.wire_model_id().as_str()))
752 .and_then(|metadata| metadata.context_window)
753 .map(u64::from);
754 effective_context = roster_context;
755 overrides.push(SourcedLimitOverride {
756 field: LimitField::ContextTokens,
757 value: roster_context,
758 source: OverrideSource::CodexRosterCorrection,
759 });
760 }
761 }
762
763 if let Some(context_window) = configured {
764 let per_model = model_configured.is_some();
765 overrides.push(SourcedLimitOverride {
766 field: LimitField::ContextTokens,
767 value: Some(u64::from(context_window)),
768 source: if per_model {
769 OverrideSource::UserModelContextWindow
770 } else {
771 OverrideSource::UserContextWindow
772 },
773 });
774 return LimitOverridePlan {
775 overrides,
776 context_window: ContextWindowResolution {
777 tokens: context_window,
778 source: if per_model {
779 ContextWindowSource::ConfiguredModel
780 } else {
781 ContextWindowSource::Configured
782 },
783 },
784 };
785 }
786
787 // Exact operator metadata wins over inferred/catalog/provider-family facts.
788 // A missing field remains unknown, with only the conservative budget floor.
789 if declared_field(LimitField::ContextTokens) {
790 let context_window = effective_context
791 .and_then(|tokens| u32::try_from(tokens).ok())
792 .map(|tokens| ContextWindowResolution {
793 tokens,
794 source: ContextWindowSource::UserDeclared,
795 })
796 .unwrap_or(ContextWindowResolution {
797 tokens: 128_000,
798 source: ContextWindowSource::Fallback,
799 });
800 return LimitOverridePlan {
801 overrides,
802 context_window,
803 };
804 }
805
806 let is_exact_kimi_code_k3 = is_exact_kimi_code_bare_k3_route(
807 provider,
808 &resolved.endpoint().base_url,
809 resolved.wire_model_id().as_str(),
810 );
811 let now = Utc::now();
812 if is_exact_kimi_code_k3
813 && provider_reported_context.is_some_and(|reported| {
814 reported.context_tokens == 1_048_576
815 && reported.observed_at <= now
816 && now.signed_duration_since(reported.observed_at)
817 <= Duration::hours(KIMI_CODE_REPORTED_CONTEXT_MAX_AGE_HOURS)
818 })
819 {
820 let reported = provider_reported_context.expect("checked above");
821 overrides.push(SourcedLimitOverride {
822 field: LimitField::ContextTokens,
823 value: Some(u64::from(reported.context_tokens)),
824 source: OverrideSource::ProviderReportedContextWindow,
825 });
826 return LimitOverridePlan {
827 overrides,
828 context_window: ContextWindowResolution {
829 tokens: reported.context_tokens,
830 source: ContextWindowSource::ProviderReported,
831 },
832 };
833 }
834
835 // Kimi Code's bare `k3` is a membership-plan route, not an alias for
836 // Moonshot's public `kimi-k3` catalog entry. The safe all-plan floor is
837 // the route's next precedence after an explicit config or fresh, scoped
838 // provider report.
839 if is_exact_kimi_code_k3 {
840 overrides.push(SourcedLimitOverride {
841 field: LimitField::ContextTokens,
842 value: Some(u64::from(KIMI_CODE_K3_CONTEXT_WINDOW_TOKENS)),
843 source: OverrideSource::MembershipPlanSafeFloor,
844 });
845 return LimitOverridePlan {
846 overrides,
847 context_window: ContextWindowResolution {
848 tokens: KIMI_CODE_K3_CONTEXT_WINDOW_TOKENS,
849 source: ContextWindowSource::StaticKimiCodeSafeFloor,
850 },
851 };
852 }
853
854 if let Some(tokens) = effective_context.and_then(|tokens| u32::try_from(tokens).ok()) {
855 return LimitOverridePlan {
856 overrides,
857 context_window: ContextWindowResolution {
858 tokens,
859 source: ContextWindowSource::Catalog,
860 },
861 };
862 }
863
864 let fallback_tokens =
865 crate::config::provider_capability(provider, resolved.wire_model_id().as_str())
866 .context_window;
867 LimitOverridePlan {
868 overrides,
869 context_window: ContextWindowResolution {
870 tokens: fallback_tokens,
871 source: classify_capability_fallback_window(
872 resolved.wire_model_id().as_str(),
873 fallback_tokens,
874 ),
875 },
876 }
877 }
878
879 #[cfg(test)]
880 pub(crate) fn resolve_runtime_route(
881 config: &Config,
882 provider: ProviderKind,
883 model_selector: Option<&str>,
884 ) -> Result<ResolvedRuntimeRoute, String> {
885 let identity = if config
886 .active_provider_identity()
887 .is_ok_and(|active| active.provider == provider)
888 {
889 config.active_provider_identity()?
890 } else {
891 config
892 .resolve_persisted_provider_identity(Some(provider.as_str()), Some(provider.as_str()))?
893 };
894 resolve_runtime_route_for_identity(config, &identity, model_selector)
895 }
896
897 /// Resolve one persisted/live identity into a scoped runtime config and route
898 /// candidate. Identity is revalidated against the live registry before any
899 /// endpoint, model, credential, or client material is read.
900 pub(crate) fn resolve_runtime_route_for_identity(
901 config: &Config,
902 identity: &ProviderIdentity,
903 model_selector: Option<&str>,
904 ) -> Result<ResolvedRuntimeRoute, String> {
905 if identity.provider == ProviderKind::Antigravity {
906 return Err(codewhale_config::LEGACY_ANTIGRAVITY_TOMBSTONE_MESSAGE.to_string());
907 }
908 config.verify_provider_identity(identity)?;
909 let original_identity = identity;
910 let provider = identity.provider;
911 let mut route_config = prepared_route_config(config, identity, model_selector)?;
912 let identity = route_config.active_provider_identity()?;
913 // The operator's effective default for the active provider is the
914 // route's default too; mirror `provider_default_model` precedence so a
915 // configured choice is not displaced by the provider catalog's default
916 // (deepseek-flash). `auto` stays the resolver's sentinel.
917 let configured_default = (config
918 .active_provider_identity()
919 .is_ok_and(|active| active == *original_identity)
920 && config.default_text_model.is_some())
921 .then(|| config.default_model())
922 .filter(|model| {
923 let model = model.trim();
924 !model.is_empty() && !model.eq_ignore_ascii_case("auto")
925 });
926 let saved_provider_model =
927 configured_model_for_route(&route_config, &identity).or(configured_default.as_deref());
928 // #5034: with no explicit selector and no saved model, a Codex route
929 // would fall back to the resolver's static seed offering. Prefer the
930 // live Codex roster head so a provider switch lands on the current
931 // flagship model; a missing/stale roster keeps the seed offering.
932 let codex_roster =
933 (provider == ProviderKind::OpenaiCodex).then(|| model_roster_for(&route_config));
934 let roster_preferred = (provider == ProviderKind::OpenaiCodex
935 && model_selector.is_none()
936 && saved_provider_model.is_none())
937 .then(|| {
938 codex_roster
939 .as_ref()
940 .and_then(CodexModelRoster::preferred_model_id)
941 .map(str::to_string)
942 })
943 .flatten();
944 let model_selector = model_selector.or(roster_preferred.as_deref());
945 let base_url = route_config.active_route_base_url();
946 // Every refreshed provider shares the same exact identity/endpoint gate.
947 // Codex keeps its separate authenticated account roster and protocol seam.
948 let resolution = if provider != ProviderKind::OpenaiCodex {
949 let status = crate::provider_catalog_live::status_for_route(
950 provider,
951 identity.key.as_str(),
952 &base_url,
953 );
954 let mut catalog = crate::provider_lake::runtime_catalog_resolver_for_identity(
955 provider,
956 Some(identity.key.as_str()),
957 &base_url,
958 status,
959 );
960 catalog.resolver = catalog.resolver.with_configured_models(
961 route_config.custom_models.as_deref().unwrap_or_default(),
962 identity.key.as_str(),
963 provider,
964 &base_url,
965 );
966 // Local Ollama's placeholder is never an executable model. Resolve
967 // an unset/auto/placeholder selection from this endpoint's fresh roster,
968 // while preserving an explicit or saved real tag verbatim.
969 let needs_local_default = provider == ProviderKind::Ollama
970 && model_selector.or(saved_provider_model).is_none_or(|model| {
971 model.trim().eq_ignore_ascii_case("auto")
972 || crate::config::is_unresolved_local_ollama_model(model)
973 });
974 if needs_local_default && !catalog.endpoint_catalog_authoritative {
975 return Err(
976 "Local Ollama has no fresh model catalog for this endpoint; select an explicit model or refresh its catalog."
977 .to_string(),
978 );
979 }
980 let cloud_default = (model_selector.is_none()
981 && saved_provider_model.is_none()
982 && !catalog.endpoint_catalog_authoritative)
983 .then(|| {
984 codewhale_config::cloud_facts::cloud_default_model_for_route(provider, &base_url)
985 .map(|(model, _)| model)
986 })
987 .flatten();
988 resolve_route_candidate_with_catalog_resolver(
989 provider,
990 model_selector
991 .or(cloud_default.as_deref())
992 .filter(|_| !needs_local_default),
993 saved_provider_model.filter(|_| !needs_local_default),
994 Some(base_url),
995 route_config.context_window_for_provider_config(&identity),
996 route_config.model_context_windows_for(&identity),
997 None,
998 None,
999 &catalog.resolver,
1000 catalog.endpoint_catalog_authoritative,
1001 true,
1002 )?
1003 } else {
1004 resolve_route_candidate_with_catalog_resolver(
1005 provider,
1006 model_selector,
1007 saved_provider_model,
1008 Some(base_url),
1009 route_config.context_window_for_provider_config(&identity),
1010 route_config.model_context_windows_for(&identity),
1011 None,
1012 codex_roster.as_ref(),
1013 &RouteResolver::new(),
1014 false,
1015 false,
1016 )?
1017 };
1018 let candidate = resolution.candidate;
1019 let model = candidate.wire_model_id().as_str().to_string();
1020 if provider == ProviderKind::Ollama && crate::config::is_unresolved_local_ollama_model(&model) {
1021 return Err("Local Ollama did not report an executable default model.".to_string());
1022 }
1023 set_model_for_route(&mut route_config, &identity, &model)?;
1024 let identity = route_config.active_provider_identity()?;
1025
1026 Ok(ResolvedRuntimeRoute {
1027 identity,
1028 candidate,
1029 config: Box::new(route_config),
1030 model,
1031 context_window: resolution.context_window,
1032 preflighted_client: None,
1033 })
1034 }
1035
1036 fn prepared_route_config(
1037 config: &Config,
1038 identity: &ProviderIdentity,
1039 model_selector: Option<&str>,
1040 ) -> Result<Config, String> {
1041 config.verify_provider_identity(identity)?;
1042 let mut route_config = config.clone();
1043 route_config.scope_to_provider_identity(identity)?;
1044 if let Some(model) = model_selector {
1045 set_model_for_route(&mut route_config, identity, model)?;
1046 }
1047 Ok(route_config)
1048 }
1049
1050 fn configured_model_for_route<'a>(
1051 config: &'a Config,
1052 identity: &ProviderIdentity,
1053 ) -> Option<&'a str> {
1054 config
1055 .provider_config_for(identity)
1056 .and_then(|entry| entry.model.as_deref())
1057 }
1058
1059 fn set_model_for_route(
1060 config: &mut Config,
1061 identity: &ProviderIdentity,
1062 model: &str,
1063 ) -> Result<(), String> {
1064 config
1065 .set_provider_model_override(identity, Some(model.to_string()))
1066 .map_err(|error| error.to_string())
1067 }
1068
1069 #[cfg(test)]
1070 mod tests {
1071 use super::*;
1072 use crate::config::{DEFAULT_TEXT_MODEL, DEFAULT_ZAI_MODEL, ProviderConfig, ProvidersConfig};
1073
1074 #[test]
1075 fn configured_model_limits_precede_provider_defaults() {
1076 let _env = crate::test_support::lock_test_env();
1077 let _catalog = crate::provider_lake::lock_live_snapshot();
1078 for (provider, identity, base, model) in [
1079 (
1080 ProviderKind::Moonshot,
1081 "moonshot",
1082 "https://api.moonshot.ai/v1",
1083 "kimi-k3",
1084 ),
1085 (
1086 ProviderKind::Moonshot,
1087 "moonshot",
1088 "https://api.kimi.com/coding/v1",
1089 "k3",
1090 ),
1091 (
1092 ProviderKind::Deepseek,
1093 "deepseek-cn",
1094 "https://models.example.test/v1",
1095 "deepseek-v4.1-flash-expires-on-0910",
1096 ),
1097 ] {
1098 let mut config: Config = toml::from_str(include_str!(
1099 "../../config/tests/fixtures/custom_models.toml"
1100 ))
1101 .unwrap();
1102 config.provider = Some(identity.into());
1103 config.providers = None;
1104 config
1105 .set_provider_base_url_override(
1106 &config.test_identity_for_kind(provider),
1107 Some(base.into()),
1108 )
1109 .unwrap();
1110 let declaration = &mut config.custom_models.as_mut().unwrap()[0];
1111 declaration.provider = identity.into();
1112 declaration.base_url = base.into();
1113 declaration.id = model.into();
1114 let route = resolve_runtime_route(&config, provider, Some(model)).unwrap();
1115 assert_eq!(route.model, model);
1116 assert_eq!(route.candidate.limits().context_tokens, Some(96000));
1117 assert_eq!(route.candidate.limits().output_tokens, Some(8000));
1118 assert_eq!(
1119 route.context_window.source,
1120 ContextWindowSource::UserDeclared
1121 );
1122 config.custom_models.as_mut().unwrap()[0].limit = None;
1123 let unknown = resolve_runtime_route(&config, provider, Some(model)).unwrap();
1124 assert_eq!(unknown.candidate.limits().context_tokens, None);
1125 assert_eq!(unknown.candidate.limits().output_tokens, None);
1126 assert_eq!(unknown.context_window.source, ContextWindowSource::Fallback);
1127 }
1128 }
1129
1130 /// Every rung keeps its own label and round-trips through it, and only
1131 /// the guesses read as unverified. Two rungs sharing a label would let a
1132 /// guess be displayed as evidence.
1133 #[test]
1134 fn every_context_window_rung_round_trips_its_own_label() {
1135 let mut seen = Vec::new();
1136 for source in ContextWindowSource::ALL {
1137 let label = source.label();
1138 assert!(!label.is_empty(), "{source:?} must carry a label");
1139 assert!(
1140 !seen.contains(&label),
1141 "{source:?} reuses the label {label}"
1142 );
1143 seen.push(label);
1144 assert_eq!(ContextWindowSource::from_label(label), Some(source));
1145 assert_eq!(
1146 source.is_verified(),
1147 !matches!(
1148 source,
1149 ContextWindowSource::Fallback
1150 | ContextWindowSource::NameSuffixHint
1151 | ContextWindowSource::UserDeclared
1152 ),
1153 "{source:?} misreports whether its window rests on route evidence"
1154 );
1155 assert_eq!(
1156 source.honesty_suffix(),
1157 if source.is_verified() {
1158 ""
1159 } else {
1160 " (unverified)"
1161 },
1162 "{source:?} must mark every guess it renders"
1163 );
1164 }
1165 assert_eq!(ContextWindowSource::from_label("configured "), None);
1166 }
1167
1168 /// #5441: a window parsed from an `_Nk` model-name suffix is its own
1169 /// unverified rung — optimistic, unlike the conservative fallback — and
1170 /// any concrete fact about the route still beats it.
1171 #[test]
1172 fn name_suffix_hint_is_its_own_unverified_rung_below_catalog() {
1173 let resolved =
1174 resolve_context_window(ProviderKind::Custom, "qwen3-32b-256k", None, None, None);
1175
1176 assert_eq!(resolved.tokens, 256_000);
1177 assert_eq!(resolved.source, ContextWindowSource::NameSuffixHint);
1178 assert!(!resolved.source.is_verified());
1179 assert_eq!(resolved.source.label(), "model-name hint");
1180 assert_eq!(
1181 resolved.source.display_label(),
1182 "model-name hint (unverified)"
1183 );
1184
1185 // The ladder is positional and the hint sits below catalog data: an
1186 // offering that describes the same id wins.
1187 let offering = Some(RouteLimits {
1188 context_tokens: Some(131_072),
1189 ..RouteLimits::default()
1190 });
1191 let catalog =
1192 resolve_context_window(ProviderKind::Custom, "qwen3-32b-256k", offering, None, None);
1193 assert_eq!(catalog.tokens, 131_072);
1194 assert_eq!(catalog.source, ContextWindowSource::Catalog);
1195 assert!(catalog.source.is_verified());
1196
1197 // An operator override beats both, exactly as before.
1198 let configured = resolve_context_window(
1199 ProviderKind::Custom,
1200 "qwen3-32b-256k",
1201 offering,
1202 Some(1_048_576),
1203 None,
1204 );
1205 assert_eq!(configured.source, ContextWindowSource::Configured);
1206 }
1207
1208 /// #5239: an id nothing describes must land on the fallback rung and say
1209 /// so, rather than borrowing the configured rung's authority for a guess.
1210 #[test]
1211 fn unknown_model_resolves_to_the_honest_fallback_rung() {
1212 let resolved = resolve_context_window(
1213 ProviderKind::Custom,
1214 "private-1m-deployment-v9",
1215 None,
1216 None,
1217 None,
1218 );
1219
1220 assert_eq!(resolved.source, ContextWindowSource::Fallback);
1221 assert_eq!(resolved.source.label(), "fallback");
1222 assert!(!resolved.source.is_verified());
1223 assert_eq!(
1224 resolved.tokens,
1225 crate::route_budget::route_context_window_tokens(
1226 ProviderKind::Custom,
1227 "private-1m-deployment-v9",
1228 None,
1229 )
1230 );
1231 }
1232
1233 /// The same unknown id with an operator override is a configured 1M route,
1234 /// not a 128K one — and the rung must say which of the two it is.
1235 #[test]
1236 fn configured_override_outranks_offering_limits_and_the_fallback() {
1237 let offering = Some(RouteLimits {
1238 context_tokens: Some(131_072),
1239 ..RouteLimits::default()
1240 });
1241
1242 for limits in [None, offering] {
1243 let resolved = resolve_context_window(
1244 ProviderKind::Custom,
1245 "private-1m-deployment-v9",
1246 limits,
1247 Some(1_048_576),
1248 None,
1249 );
1250 assert_eq!(resolved.tokens, 1_048_576);
1251 assert_eq!(resolved.source, ContextWindowSource::Configured);
1252 }
1253
1254 let catalog = resolve_context_window(
1255 ProviderKind::Custom,
1256 "private-1m-deployment-v9",
1257 offering,
1258 None,
1259 None,
1260 );
1261 assert_eq!(catalog.tokens, 131_072);
1262 assert_eq!(catalog.source, ContextWindowSource::Catalog);
1263 }
1264
1265 /// A zero or absent override is not a configuration decision; it must not
1266 /// promote a guess to the configured rung.
1267 #[test]
1268 fn empty_override_and_empty_offering_stay_on_the_fallback_rung() {
1269 for (limits, over) in [
1270 (None, Some(0)),
1271 (
1272 Some(RouteLimits {
1273 context_tokens: Some(0),
1274 ..RouteLimits::default()
1275 }),
1276 None,
1277 ),
1278 ] {
1279 assert_eq!(
1280 resolve_context_window(
1281 ProviderKind::Custom,
1282 "private-1m-deployment-v9",
1283 limits,
1284 over,
1285 None,
1286 )
1287 .source,
1288 ContextWindowSource::Fallback
1289 );
1290 }
1291 }
1292
1293 #[test]
1294 fn resolved_runtime_route_keeps_large_config_off_async_stacks() {
1295 assert!(
1296 std::mem::size_of::<ResolvedRuntimeRoute>() <= 1024,
1297 "resolved routes cross several async boundaries and must keep Config boxed"
1298 );
1299 assert!(
1300 std::mem::size_of::<ResolvedRuntimeRoute>() < std::mem::size_of::<Config>(),
1301 "resolved routes must remain smaller than their scoped Config payload"
1302 );
1303 }
1304
1305 #[test]
1306 fn provider_route_preflight_missing_key_error_surfaces_reason_and_auth_step() {
1307 let err = anyhow::anyhow!(
1308 "Custom provider 'lm-studio' API key not found. Run 'codewhale auth set --provider custom'."
1309 );
1310 let formatted = format_provider_route_preflight_error(
1311 "lm-studio",
1312 "local-model",
1313 &err,
1314 RouteErrorSurface::Interactive,
1315 );
1316
1317 assert!(formatted.starts_with("Custom provider 'lm-studio' API key not found."));
1318 assert!(formatted.contains("Failed to configure provider route lm-studio / local-model."));
1319 assert!(formatted.contains(
1320 "Next step: Run /auth or /provider setup lm-studio to configure credentials."
1321 ));
1322 }
1323
1324 #[test]
1325 fn provider_route_preflight_headless_missing_key_is_one_cli_message() {
1326 let single = anyhow::anyhow!(
1327 "Custom provider 'lm-studio' API key not found. Run 'codewhale auth set --provider custom'."
1328 );
1329 let formatted = format_provider_route_preflight_error(
1330 "lm-studio",
1331 "local-model",
1332 &single,
1333 RouteErrorSurface::Headless,
1334 );
1335 assert!(!formatted.contains(".."), "{formatted}");
1336 assert!(!formatted.contains("/auth"), "{formatted}");
1337 assert!(!formatted.contains("/provider"), "{formatted}");
1338 assert!(!formatted.contains("Next step"), "{formatted}");
1339
1340 let bare = anyhow::anyhow!("DeepSeek API key not found.");
1341 let formatted = format_provider_route_preflight_error(
1342 "deepseek",
1343 "deepseek-v4-flash",
1344 &bare,
1345 RouteErrorSurface::Headless,
1346 );
1347 assert_eq!(
1348 formatted,
1349 "DeepSeek API key not found. Failed to configure provider route deepseek / deepseek-v4-flash. Next step: Run `codewhale auth set --provider deepseek`."
1350 );
1351
1352 // An OAuth access-token failure is not an `auth set` (API key) fix.
1353 let oauth = anyhow::anyhow!("OAuth access token is empty");
1354 let formatted = format_provider_route_preflight_error(
1355 "xai",
1356 "grok-4",
1357 &oauth,
1358 RouteErrorSurface::Headless,
1359 );
1360 assert!(!formatted.contains("auth set"), "{formatted}");
1361 assert!(formatted.contains("codewhale doctor"), "{formatted}");
1362
1363 let multi = anyhow::anyhow!(
1364 "DeepSeek API key not found.\n\n codewhale auth set --provider deepseek"
1365 );
1366 let formatted = format_provider_route_preflight_error(
1367 "deepseek",
1368 "deepseek-v4-flash",
1369 &multi,
1370 RouteErrorSurface::Headless,
1371 );
1372 assert!(
1373 formatted.contains(
1374 "\n codewhale auth set --provider deepseek\nFailed to configure provider route"
1375 ),
1376 "{formatted}"
1377 );
1378 }
1379
1380 #[test]
1381 fn provider_route_preflight_codex_oauth_errors_surface_the_right_next_step() {
1382 let missing = anyhow::anyhow!("OpenAI Codex OAuth credentials are unavailable.");
1383 let missing_formatted = format_provider_route_preflight_error(
1384 "openai-codex",
1385 "gpt-5.6-sol",
1386 &missing,
1387 RouteErrorSurface::Interactive,
1388 );
1389 assert!(missing_formatted.contains(
1390 "Next step: Run `codewhale auth chatgpt` or /provider setup openai-codex to Sign in with ChatGPT."
1391 ));
1392
1393 let custom = anyhow::anyhow!(
1394 "ChatGPT credentials are only available on the official public API route"
1395 );
1396 let custom_formatted = format_provider_route_preflight_error(
1397 "openai-codex",
1398 "gpt-5.6-sol",
1399 &custom,
1400 RouteErrorSurface::Interactive,
1401 );
1402 assert!(custom_formatted.contains(
1403 "Next step: Run /provider setup openai-codex and remove its custom base URL; ChatGPT plan access only works on the official public API route."
1404 ));
1405 }
1406
1407 #[test]
1408 fn provider_route_preflight_tls_error_surfaces_route_and_setup_step() {
1409 let err = anyhow::anyhow!(
1410 "TLS certificate verification cannot be disabled for provider custom; configure SSL_CERT_FILE with a trusted custom CA bundle instead"
1411 );
1412 let formatted = format_provider_route_preflight_error(
1413 "lm-studio",
1414 "local-model",
1415 &err,
1416 RouteErrorSurface::Interactive,
1417 );
1418
1419 assert!(
1420 formatted
1421 .starts_with("TLS certificate verification cannot be disabled for provider custom")
1422 );
1423 assert!(formatted.contains("Failed to configure provider route lm-studio / local-model."));
1424 assert!(
1425 formatted
1426 .contains("Next step: Run /provider setup lm-studio to fix base URL/TLS settings.")
1427 );
1428 }
1429
1430 #[test]
1431 fn chatgpt_route_ignores_external_context_and_drops_api_only_limits() {
1432 let _lock = crate::test_support::lock_test_env();
1433 let codex_home = tempfile::tempdir().expect("Codex home");
1434 let _home = crate::test_support::EnvVarGuard::set("CODEX_HOME", codex_home.path());
1435 std::fs::write(
1436 codex_home.path().join("models_cache.json"),
1437 serde_json::to_vec(&serde_json::json!({
1438 "fetched_at": chrono::Utc::now(),
1439 "models": [{
1440 "slug": crate::config::DEFAULT_OPENAI_CODEX_MODEL,
1441 "priority": 1,
1442 "context_window": 128000,
1443 "supported_reasoning_levels": [{"effort": "high"}]
1444 }]
1445 }))
1446 .expect("serialize cache"),
1447 )
1448 .expect("write cache");
1449
1450 let candidate = resolve_route_candidate(
1451 ProviderKind::OpenaiCodex,
1452 Some(crate::config::DEFAULT_OPENAI_CODEX_MODEL),
1453 None,
1454 None,
1455 None,
1456 None,
1457 )
1458 .expect("Codex route");
1459
1460 assert_eq!(candidate.limits().context_tokens, None);
1461 assert_eq!(candidate.limits().input_tokens, None);
1462 assert_eq!(candidate.limits().output_tokens, None);
1463 assert_eq!(
1464 crate::route_budget::route_context_window_tokens(
1465 ProviderKind::OpenaiCodex,
1466 crate::config::DEFAULT_OPENAI_CODEX_MODEL,
1467 Some(candidate.limits()),
1468 ),
1469 128_000
1470 );
1471 }
1472
1473 #[test]
1474 fn codex_switch_without_saved_model_prefers_fresh_roster_head() {
1475 // #5034: switching to openai-codex with no saved model must land on
1476 // the roster's current flagship, not the static seed constant.
1477 let _lock = crate::test_support::lock_test_env();
1478 let home = tempfile::tempdir().expect("Codewhale home");
1479 let home_path = home
1480 .path()
1481 .canonicalize()
1482 .expect("canonical Codewhale home");
1483 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home_path);
1484 let mut config = crate::config::Config::default();
1485 crate::oauth::install_test_chatgpt_registration(&mut config).expect("owned grant");
1486 crate::codex_model_cache::install_test_chatgpt_roster(
1487 &config,
1488 &[
1489 "gpt-test-flagship",
1490 crate::config::DEFAULT_OPENAI_CODEX_MODEL,
1491 ],
1492 )
1493 .expect("account roster");
1494 let route = resolve_runtime_route(&config, ProviderKind::OpenaiCodex, None)
1495 .expect("codex route resolves");
1496 assert_eq!(route.model, "gpt-test-flagship");
1497
1498 // An explicit selector or saved provider model still wins.
1499 let explicit = resolve_runtime_route(
1500 &config,
1501 ProviderKind::OpenaiCodex,
1502 Some(crate::config::DEFAULT_OPENAI_CODEX_MODEL),
1503 )
1504 .expect("explicit codex route resolves");
1505 assert_eq!(explicit.model, crate::config::DEFAULT_OPENAI_CODEX_MODEL);
1506 }
1507
1508 #[test]
1509 fn opencode_go_kimi_k3_route_uses_1m_context() {
1510 // OpenCode Go may not own a models.dev row for kimi-k3; capability and
1511 // budget resolution still must use the 1M K3 contract, never the 128K
1512 // legacy fallback or the 131K max-output field.
1513 let cap = crate::config::provider_capability(ProviderKind::OpencodeGo, "kimi-k3");
1514 assert_eq!(cap.context_window, 1_048_576);
1515 assert_eq!(cap.max_output, Some(131_072));
1516 assert_ne!(Some(cap.context_window), cap.max_output);
1517
1518 let candidate = resolve_route_candidate(
1519 ProviderKind::OpencodeGo,
1520 Some("kimi-k3"),
1521 None,
1522 None,
1523 None,
1524 None,
1525 )
1526 .expect("OpenCode Go Kimi K3 route");
1527 assert_eq!(candidate.wire_model_id().as_str(), "kimi-k3");
1528 // Prefer catalog/route limits when present; otherwise the capability
1529 // path above is the source of truth for picker/budget display.
1530 if let Some(ctx) = candidate.limits().context_tokens {
1531 assert_eq!(ctx, 1_048_576);
1532 } else {
1533 assert_eq!(
1534 crate::route_budget::route_context_window_tokens(
1535 ProviderKind::OpencodeGo,
1536 "kimi-k3",
1537 Some(candidate.limits()),
1538 ),
1539 1_048_576
1540 );
1541 }
1542 }
1543
1544 #[test]
1545 fn direct_moonshot_k3_route_uses_documented_1m_limits_with_provenance() {
1546 let candidate = resolve_route_candidate(
1547 ProviderKind::Moonshot,
1548 Some("kimi-k3"),
1549 None,
1550 None,
1551 None,
1552 None,
1553 )
1554 .expect("Moonshot Kimi K3 route");
1555
1556 assert_eq!(candidate.wire_model_id().as_str(), "kimi-k3");
1557 assert_eq!(candidate.limits().context_tokens, Some(1_048_576));
1558 assert_eq!(candidate.limits().output_tokens, Some(1_048_576));
1559 assert!(candidate.applied_limit_overrides().contains(
1560 &codewhale_config::route::SourcedLimitOverride {
1561 field: codewhale_config::route::LimitField::OutputTokens,
1562 value: Some(1_048_576),
1563 source: codewhale_config::route::OverrideSource::DocumentedRouteOutputMaximum,
1564 }
1565 ));
1566 assert_eq!(
1567 crate::route_budget::route_context_window_tokens(
1568 ProviderKind::Moonshot,
1569 "kimi-k3",
1570 Some(candidate.limits()),
1571 ),
1572 1_048_576
1573 );
1574 assert_eq!(
1575 crate::route_budget::effective_max_output_tokens_for_route(
1576 ProviderKind::Moonshot,
1577 "kimi-k3",
1578 Some(candidate.limits()),
1579 ),
1580 65_536,
1581 "the documented catalogue output ceiling remains a ceiling; the safe default request must not reserve it in full"
1582 );
1583 }
1584
1585 #[test]
1586 fn kimi_code_bare_k3_keeps_tier_safe_floor_not_legacy_128k() {
1587 // Bare `k3` membership context is plan-tier dependent (256K on lower
1588 // tiers, up to 1M on higher ones), so the static route baseline stays
1589 // the safe floor. Higher entitlements come from an explicit provider
1590 // `context_window` override — never from assuming the top tier, and
1591 // never from the 128K legacy default.
1592 let candidate = resolve_route_candidate(
1593 ProviderKind::Moonshot,
1594 Some("k3"),
1595 None,
1596 Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
1597 None,
1598 None,
1599 )
1600 .expect("Kimi Code K3 route");
1601
1602 assert_eq!(candidate.wire_model_id().as_str(), "k3");
1603 assert_eq!(candidate.limits().context_tokens, Some(262_144));
1604 // Output remains a conservative generic default because the
1605 // membership API does not publish a distinct maximum. Never project
1606 // it as context or inherit the direct-platform 1M maximum.
1607 assert_ne!(
1608 candidate.limits().context_tokens,
1609 candidate.limits().output_tokens
1610 );
1611 assert_eq!(
1612 crate::config::provider_capability(
1613 ProviderKind::Moonshot,
1614 crate::config::KIMI_CODE_K3_MODEL
1615 )
1616 .context_window,
1617 262_144
1618 );
1619 assert_ne!(candidate.limits().output_tokens, Some(1_048_576));
1620 }
1621
1622 #[test]
1623 fn kimi_code_context_resolution_records_precedence_and_rejects_bad_metadata() {
1624 let base = Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string());
1625 let static_floor = resolve_route_candidate_with_context_metadata(
1626 ProviderKind::Moonshot,
1627 Some("k3"),
1628 None,
1629 base.clone(),
1630 None,
1631 None,
1632 None,
1633 )
1634 .expect("Kimi Code route");
1635 assert_eq!(static_floor.context_window.tokens, 262_144);
1636 assert_eq!(
1637 static_floor.context_window.source,
1638 ContextWindowSource::StaticKimiCodeSafeFloor
1639 );
1640
1641 let configured = resolve_route_candidate_with_context_metadata(
1642 ProviderKind::Moonshot,
1643 Some("k3"),
1644 None,
1645 base.clone(),
1646 Some(1_048_576),
1647 None,
1648 Some(ProviderReportedKimiCodeContext {
1649 context_tokens: 1_048_576,
1650 observed_at: Utc::now(),
1651 }),
1652 )
1653 .expect("configured route");
1654 assert_eq!(configured.context_window.tokens, 1_048_576);
1655 assert_eq!(
1656 configured.context_window.source,
1657 ContextWindowSource::Configured
1658 );
1659
1660 let reported = resolve_route_candidate_with_context_metadata(
1661 ProviderKind::Moonshot,
1662 Some("k3"),
1663 None,
1664 base.clone(),
1665 None,
1666 None,
1667 Some(ProviderReportedKimiCodeContext {
1668 context_tokens: 1_048_576,
1669 observed_at: Utc::now(),
1670 }),
1671 )
1672 .expect("fresh documented provider metadata");
1673 assert_eq!(reported.context_window.tokens, 1_048_576);
1674 assert_eq!(
1675 reported.context_window.source,
1676 ContextWindowSource::ProviderReported
1677 );
1678
1679 let stale = resolve_route_candidate_with_context_metadata(
1680 ProviderKind::Moonshot,
1681 Some("k3"),
1682 None,
1683 base,
1684 None,
1685 None,
1686 Some(ProviderReportedKimiCodeContext {
1687 context_tokens: 1_048_576,
1688 observed_at: Utc::now() - Duration::hours(25),
1689 }),
1690 )
1691 .expect("stale metadata falls back safely");
1692 assert_eq!(
1693 stale.context_window.source,
1694 ContextWindowSource::StaticKimiCodeSafeFloor
1695 );
1696
1697 let generic_err = resolve_route_candidate_with_context_metadata(
1698 ProviderKind::Moonshot,
1699 Some("k3"),
1700 None,
1701 Some(crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string()),
1702 None,
1703 None,
1704 Some(ProviderReportedKimiCodeContext {
1705 context_tokens: 1_048_576,
1706 observed_at: Utc::now(),
1707 }),
1708 )
1709 .expect_err("bare k3 is rejected on the direct Moonshot endpoint (#4687)");
1710 assert!(
1711 generic_err.contains("kimi-k3"),
1712 "error should guide the user to kimi-k3: {generic_err}"
1713 );
1714 }
1715
1716 #[test]
1717 fn kimi_code_k3_context_override_wins_over_conservative_baseline() {
1718 let candidate = resolve_route_candidate(
1719 ProviderKind::Moonshot,
1720 Some("k3"),
1721 None,
1722 Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
1723 Some(1_048_576),
1724 None,
1725 )
1726 .expect("Kimi Code K3 route");
1727
1728 assert_eq!(
1729 candidate.wire_model_id().as_str(),
1730 crate::config::KIMI_CODE_K3_MODEL,
1731 "the 1M entitlement changes limits, never the provider wire id"
1732 );
1733 assert!(crate::config::is_exact_kimi_code_bare_k3_route(
1734 ProviderKind::Moonshot,
1735 &candidate.endpoint().base_url,
1736 candidate.wire_model_id().as_str(),
1737 ));
1738 assert_eq!(candidate.limits().context_tokens, Some(1_048_576));
1739 }
1740
1741 #[test]
1742 fn model_context_windows_exact_hit_wins_over_provider_default() {
1743 let windows = BTreeMap::from([
1744 ("kimi-k3".to_string(), 512_000u32),
1745 ("MiniMaxAI/MiniMax-M2.5".to_string(), 204_800),
1746 ]);
1747 let resolution = resolve_route_candidate_with_context_metadata(
1748 ProviderKind::Moonshot,
1749 Some("kimi-k3"),
1750 None,
1751 None,
1752 Some(1_048_576),
1753 Some(&windows),
1754 None,
1755 )
1756 .expect("Moonshot route with a per-model override");
1757
1758 assert_eq!(resolution.context_window.tokens, 512_000);
1759 assert_eq!(
1760 resolution.context_window.source,
1761 ContextWindowSource::ConfiguredModel
1762 );
1763 assert_eq!(resolution.candidate.limits().context_tokens, Some(512_000));
1764 assert!(
1765 resolution
1766 .candidate
1767 .applied_limit_overrides()
1768 .iter()
1769 .any(|entry| entry.field == LimitField::ContextTokens
1770 && entry.value == Some(512_000)
1771 && entry.source == OverrideSource::UserModelContextWindow),
1772 "candidate provenance must name the per-model override source"
1773 );
1774 }
1775
1776 #[test]
1777 fn model_context_windows_miss_falls_back_to_provider_default() {
1778 let windows = BTreeMap::from([("unrelated-model".to_string(), 512_000u32)]);
1779 let resolution = resolve_route_candidate_with_context_metadata(
1780 ProviderKind::Moonshot,
1781 Some("kimi-k3"),
1782 None,
1783 None,
1784 Some(1_048_576),
1785 Some(&windows),
1786 None,
1787 )
1788 .expect("provider default applies when no model key matches");
1789
1790 assert_eq!(resolution.context_window.tokens, 1_048_576);
1791 assert_eq!(
1792 resolution.context_window.source,
1793 ContextWindowSource::Configured
1794 );
1795 assert!(
1796 resolution
1797 .candidate
1798 .applied_limit_overrides()
1799 .iter()
1800 .any(|entry| entry.field == LimitField::ContextTokens
1801 && entry.source == OverrideSource::UserContextWindow),
1802 "a table miss must stay on the provider-level provenance"
1803 );
1804 }
1805
1806 #[test]
1807 fn resolve_context_window_per_model_rung_precedes_provider_override() {
1808 let windows = BTreeMap::from([("qwen3-32b-256k".to_string(), 100_000u32)]);
1809 let hit = resolve_context_window(
1810 ProviderKind::Custom,
1811 "qwen3-32b-256k",
1812 None,
1813 Some(999_999),
1814 Some(&windows),
1815 );
1816 assert_eq!(hit.tokens, 100_000);
1817 assert_eq!(hit.source, ContextWindowSource::ConfiguredModel);
1818 assert_eq!(hit.source.label(), "configured (per-model)");
1819
1820 // A miss on the exact wire id falls through to the provider default.
1821 let miss = resolve_context_window(
1822 ProviderKind::Custom,
1823 "unrelated-model",
1824 None,
1825 Some(999_999),
1826 Some(&windows),
1827 );
1828 assert_eq!(miss.tokens, 999_999);
1829 assert_eq!(miss.source, ContextWindowSource::Configured);
1830
1831 // A zero entry is ignored, never treated as a configured window.
1832 let zeroed = BTreeMap::from([("qwen3-32b-256k".to_string(), 0u32)]);
1833 let fallback = resolve_context_window(
1834 ProviderKind::Custom,
1835 "qwen3-32b-256k",
1836 None,
1837 Some(999_999),
1838 Some(&zeroed),
1839 );
1840 assert_eq!(fallback.tokens, 999_999);
1841 assert_eq!(fallback.source, ContextWindowSource::Configured);
1842 }
1843
1844 #[test]
1845 fn kimi_code_rejects_claude_only_k3_1m_alias_for_selected_and_saved_models() {
1846 for (selected, saved) in [(Some("k3[1m]"), None), (None, Some("k3[1m]"))] {
1847 let error = resolve_route_candidate(
1848 ProviderKind::Moonshot,
1849 selected,
1850 saved,
1851 Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
1852 None,
1853 None,
1854 )
1855 .expect_err("Claude Code's context hint is not a Kimi Code API model id");
1856
1857 assert!(error.contains("model = \"k3\""), "{error}");
1858 assert!(error.contains("context_window = 1048576"), "{error}");
1859 assert!(error.contains("plan includes 1M context"), "{error}");
1860 assert!(error.contains("262144 safe default"), "{error}");
1861 }
1862 }
1863
1864 #[test]
1865 fn k3_route_rejects_cross_paired_model_ids_and_allows_canonical_pairs() {
1866 use crate::config::{
1867 DEFAULT_KIMI_CODE_BASE_URL, DEFAULT_MOONSHOT_BASE_URL, KIMI_CODE_K3_MODEL,
1868 MOONSHOT_KIMI_K3_MODEL, moonshot_k3_route_display_name,
1869 validate_kimi_code_api_model_id,
1870 };
1871
1872 // Canonical pairs succeed.
1873 validate_kimi_code_api_model_id(
1874 ProviderKind::Moonshot,
1875 DEFAULT_KIMI_CODE_BASE_URL,
1876 KIMI_CODE_K3_MODEL,
1877 )
1878 .expect("kimi code + k3");
1879 validate_kimi_code_api_model_id(
1880 ProviderKind::Moonshot,
1881 DEFAULT_MOONSHOT_BASE_URL,
1882 MOONSHOT_KIMI_K3_MODEL,
1883 )
1884 .expect("direct + kimi-k3");
1885
1886 // Trailing slash normalization still enforces.
1887 let err = validate_kimi_code_api_model_id(
1888 ProviderKind::Moonshot,
1889 "https://api.kimi.com/coding/v1/",
1890 "kimi-k3",
1891 )
1892 .expect_err("kimi code + kimi-k3");
1893 assert!(err.contains("k3"), "{err}");
1894 assert!(err.contains("kimi-k3"), "{err}");
1895
1896 let err = validate_kimi_code_api_model_id(
1897 ProviderKind::Moonshot,
1898 "https://api.moonshot.ai/v1/",
1899 "k3",
1900 )
1901 .expect_err("direct + k3");
1902 assert!(err.contains("kimi-k3"), "{err}");
1903
1904 // Custom gateway is not rejected for either model id.
1905 validate_kimi_code_api_model_id(
1906 ProviderKind::Moonshot,
1907 "https://gateway.example.com/v1",
1908 "k3",
1909 )
1910 .expect("custom + k3");
1911 validate_kimi_code_api_model_id(
1912 ProviderKind::Moonshot,
1913 "https://gateway.example.com/v1",
1914 "kimi-k3",
1915 )
1916 .expect("custom + kimi-k3");
1917
1918 // Runtime resolve fails closed the same way.
1919 let err = resolve_route_candidate(
1920 ProviderKind::Moonshot,
1921 Some("kimi-k3"),
1922 None,
1923 Some(DEFAULT_KIMI_CODE_BASE_URL.to_string()),
1924 None,
1925 None,
1926 )
1927 .expect_err("resolve kimi code + kimi-k3");
1928 assert!(err.contains("k3"), "{err}");
1929
1930 let err = resolve_route_candidate(
1931 ProviderKind::Moonshot,
1932 Some("k3"),
1933 None,
1934 Some(DEFAULT_MOONSHOT_BASE_URL.to_string()),
1935 None,
1936 None,
1937 )
1938 .expect_err("resolve direct + k3");
1939 assert!(err.contains("kimi-k3"), "{err}");
1940
1941 assert_eq!(
1942 moonshot_k3_route_display_name(DEFAULT_KIMI_CODE_BASE_URL, "k3"),
1943 Some("Kimi Code membership / k3")
1944 );
1945 assert_eq!(
1946 moonshot_k3_route_display_name(DEFAULT_MOONSHOT_BASE_URL, "kimi-k3"),
1947 Some("Moonshot direct / kimi-k3")
1948 );
1949 }
1950
1951 #[test]
1952 fn kimi_code_k3_baseline_does_not_leak_to_other_moonshot_routes() {
1953 let kimi_code_endpoint = Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string());
1954 let direct_moonshot = resolve_route_candidate(
1955 ProviderKind::Moonshot,
1956 Some(crate::config::MOONSHOT_KIMI_K3_MODEL),
1957 None,
1958 Some(crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string()),
1959 None,
1960 None,
1961 )
1962 .expect("direct Moonshot K3 route");
1963 assert_eq!(direct_moonshot.limits().context_tokens, Some(1_048_576));
1964
1965 // Bare k3 on the direct platform endpoint is fail-closed (#4687).
1966 let generic_err = resolve_route_candidate(
1967 ProviderKind::Moonshot,
1968 Some("k3"),
1969 None,
1970 Some(crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string()),
1971 None,
1972 None,
1973 )
1974 .expect_err("bare k3 on direct Moonshot must fail closed");
1975 assert!(generic_err.contains("kimi-k3"), "{generic_err}");
1976
1977 // A non-K3 direct model must not inherit the Kimi Code 262k floor.
1978 let generic_moonshot = resolve_route_candidate(
1979 ProviderKind::Moonshot,
1980 Some("moonshot-v1-128k"),
1981 None,
1982 Some(crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string()),
1983 None,
1984 None,
1985 )
1986 .expect("generic Moonshot route");
1987 assert_ne!(generic_moonshot.limits().context_tokens, Some(262_144));
1988
1989 let kimi_code_default = resolve_route_candidate(
1990 ProviderKind::Moonshot,
1991 Some(crate::config::DEFAULT_KIMI_CODE_MODEL),
1992 None,
1993 kimi_code_endpoint,
1994 None,
1995 None,
1996 )
1997 .expect("Kimi Code default route");
1998 assert_ne!(kimi_code_default.limits().context_tokens, Some(262_144));
1999 }
2000
2001 #[test]
2002 fn runtime_route_without_model_uses_target_provider_default() {
2003 let config = Config {
2004 provider: Some("openrouter".to_string()),
2005 providers: Some(ProvidersConfig {
2006 openrouter: ProviderConfig {
2007 model: Some("deepseek/deepseek-v4-pro".to_string()),
2008 ..Default::default()
2009 },
2010 ..Default::default()
2011 }),
2012 ..Default::default()
2013 };
2014
2015 let route = resolve_runtime_route(&config, ProviderKind::Zai, None)
2016 .expect("target provider default should resolve");
2017
2018 assert_eq!(route.model, DEFAULT_ZAI_MODEL);
2019 assert_eq!(route.config.provider.as_deref(), Some("zai"));
2020 assert_eq!(
2021 route
2022 .config
2023 .providers
2024 .as_ref()
2025 .and_then(|providers| providers.zai.model.as_deref()),
2026 Some(DEFAULT_ZAI_MODEL)
2027 );
2028 assert_eq!(
2029 route
2030 .config
2031 .providers
2032 .as_ref()
2033 .and_then(|providers| providers.openrouter.model.as_deref()),
2034 Some("deepseek/deepseek-v4-pro")
2035 );
2036 }
2037
2038 #[test]
2039 fn runtime_route_rejects_foreign_direct_model_before_config_snapshot() {
2040 let config = Config {
2041 provider: Some("deepseek".to_string()),
2042 providers: Some(ProvidersConfig {
2043 deepseek: ProviderConfig {
2044 model: Some(DEFAULT_TEXT_MODEL.to_string()),
2045 ..Default::default()
2046 },
2047 ..Default::default()
2048 }),
2049 ..Default::default()
2050 };
2051
2052 let err = resolve_runtime_route(&config, ProviderKind::Zai, Some("deepseek-v4-pro"))
2053 .expect_err("foreign direct-provider model should reject");
2054
2055 assert!(err.contains("not served by direct provider zai"));
2056 assert_eq!(config.provider.as_deref(), Some("deepseek"));
2057 assert_eq!(
2058 config
2059 .providers
2060 .as_ref()
2061 .and_then(|providers| providers.zai.model.as_deref()),
2062 None
2063 );
2064 }
2065
2066 #[test]
2067 fn unpinned_spawn_route_is_conservative_and_returns_exact_wire_id() {
2068 let err = resolve_unpinned_model_candidate(
2069 ProviderKind::Moonshot,
2070 "deepseek-v4-pro",
2071 ProviderKind::Moonshot.provider().default_base_url(),
2072 )
2073 .expect_err("official Moonshot cannot inherit a DeepSeek-owned pin");
2074 assert!(err.contains("deepseek-v4-pro"), "names model: {err}");
2075 assert!(err.contains("moonshot"), "names route: {err}");
2076 assert!(err.contains("deepseek"), "names owner: {err}");
2077
2078 let openrouter = resolve_unpinned_model_candidate(
2079 ProviderKind::Openrouter,
2080 "deepseek-v4-pro",
2081 ProviderKind::Openrouter.provider().default_base_url(),
2082 )
2083 .expect("aggregator alias should resolve offline");
2084 assert_eq!(
2085 openrouter.wire_model_id().as_str(),
2086 crate::config::DEFAULT_OPENROUTER_MODEL,
2087 );
2088
2089 let vllm = resolve_unpinned_model_candidate(
2090 ProviderKind::Vllm,
2091 "deepseek-v4-pro",
2092 ProviderKind::Vllm.provider().default_base_url(),
2093 )
2094 .expect("local runtime model ids stay provider-authoritative");
2095 assert!(!vllm.wire_model_id().as_str().is_empty());
2096
2097 let custom = resolve_unpinned_model_candidate(
2098 ProviderKind::Moonshot,
2099 "deepseek-v4-pro",
2100 "https://gateway.example.test/v1",
2101 )
2102 .expect("a custom endpoint owns its model namespace");
2103 assert_eq!(custom.wire_model_id().as_str(), "deepseek-v4-pro");
2104 }
2105
2106 fn live_catalog_offering(
2107 provider: &str,
2108 model: &str,
2109 base_url: &str,
2110 ) -> codewhale_config::catalog::CatalogOffering {
2111 codewhale_config::catalog::CatalogOffering {
2112 provider: provider.to_string(),
2113 wire_model_id: model.to_string(),
2114 endpoint_key: "chat".to_string(),
2115 default_for_provider: true,
2116 limit: Some(codewhale_config::models_dev::ModelsDevLimit {
2117 context: Some(654_321),
2118 input: Some(600_000),
2119 output: Some(54_321),
2120 }),
2121 cost: Some(codewhale_config::models_dev::ModelsDevCost {
2122 input: Some(1.25),
2123 output: Some(3.5),
2124 cache_read: None,
2125 cache_write: None,
2126 }),
2127 modalities: Some(codewhale_config::models_dev::ModelsDevModalities {
2128 input: vec!["text".to_string(), "image".to_string()],
2129 output: vec!["text".to_string()],
2130 }),
2131 attachment: Some(true),
2132 reasoning: Some(true),
2133 tool_call: Some(true),
2134 structured_output: Some(true),
2135 source: codewhale_config::catalog::CatalogSource::Live {
2136 base_url_fingerprint: codewhale_config::catalog::base_url_fingerprint(base_url),
2137 fetched_at: codewhale_config::catalog::now_unix(),
2138 },
2139 ..Default::default()
2140 }
2141 }
2142
2143 fn assert_live_catalog_route_facts(route: &ResolvedRuntimeRoute) {
2144 use codewhale_config::route::{CapabilityState, PricingSku};
2145
2146 assert_eq!(route.candidate.limits().context_tokens, Some(654_321));
2147 assert_eq!(route.candidate.limits().input_tokens, Some(600_000));
2148 assert_eq!(route.candidate.limits().output_tokens, Some(54_321));
2149 assert_eq!(route.context_window.tokens, 654_321);
2150 assert_eq!(route.context_window.source, ContextWindowSource::Catalog);
2151 let capabilities = route.candidate.capabilities();
2152 assert_eq!(capabilities.attachments, CapabilityState::Supported);
2153 assert_eq!(capabilities.image_input, CapabilityState::Supported);
2154 assert_eq!(capabilities.reasoning, CapabilityState::Supported);
2155 assert_eq!(capabilities.native_tool_calls, CapabilityState::Supported);
2156 assert_eq!(capabilities.structured_output, CapabilityState::Supported);
2157 match route.candidate.pricing() {
2158 Some(PricingSku::Token {
2159 input_per_mtok,
2160 output_per_mtok,
2161 }) => {
2162 assert_eq!(*input_per_mtok, Some(1.25));
2163 assert_eq!(*output_per_mtok, Some(3.5));
2164 }
2165 other => panic!("expected provider-live token pricing, got {other:?}"),
2166 }
2167 }
2168
2169 #[test]
2170 fn live_only_openrouter_model_facts_reach_runtime_and_fail_closed_on_refresh_error() {
2171 use codewhale_config::catalog::{CatalogRefreshError, ProviderCatalogDelta};
2172 use codewhale_config::route::{CapabilityState, PricingSku};
2173
2174 let _env = crate::test_support::lock_test_env();
2175 let _live = crate::provider_lake::lock_live_snapshot();
2176 let home = tempfile::tempdir().expect("home");
2177 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
2178 crate::provider_catalog_live::reset_cache_for_test();
2179 crate::provider_lake::clear_live_snapshot();
2180
2181 let base_url = "https://synthetic.openrouter.invalid/api/v1";
2182 let model = "synthetic/live-only-openrouter-model";
2183 let config = Config {
2184 provider: Some("openrouter".to_string()),
2185 providers: Some(ProvidersConfig {
2186 openrouter: ProviderConfig {
2187 base_url: Some(base_url.to_string()),
2188 model: Some(model.to_string()),
2189 ..Default::default()
2190 },
2191 ..Default::default()
2192 }),
2193 ..Default::default()
2194 };
2195 let fingerprint = codewhale_config::catalog::base_url_fingerprint(base_url);
2196 crate::provider_catalog_live::record_success(ProviderCatalogDelta {
2197 provider: "openrouter".to_string(),
2198 base_url_fingerprint: fingerprint.clone(),
2199 fetched_at: codewhale_config::catalog::now_unix(),
2200 offerings: vec![live_catalog_offering("openrouter", model, base_url)],
2201 });
2202
2203 let route = resolve_runtime_route(&config, ProviderKind::Openrouter, Some(model))
2204 .expect("live-only OpenRouter route resolves");
2205 assert_eq!(route.model, model);
2206 assert_live_catalog_route_facts(&route);
2207
2208 crate::provider_catalog_live::record_failure(
2209 "openrouter",
2210 &fingerprint,
2211 CatalogRefreshError::Network,
2212 );
2213 let failed = resolve_runtime_route(&config, ProviderKind::Openrouter, Some(model))
2214 .expect("wire id remains routable after a failed refresh");
2215 assert!(!failed.candidate.limits().has_known_limit());
2216 assert_eq!(
2217 failed.candidate.capabilities().image_input,
2218 CapabilityState::Unknown
2219 );
2220 assert!(matches!(
2221 failed.candidate.pricing(),
2222 Some(PricingSku::UnknownOrStale)
2223 ));
2224
2225 crate::provider_catalog_live::reset_cache_for_test();
2226 crate::provider_lake::clear_live_snapshot();
2227 }
2228
2229 #[test]
2230 fn unrelated_live_roster_cannot_change_direct_model_ownership() {
2231 use codewhale_config::catalog::ProviderCatalogDelta;
2232
2233 let _env = crate::test_support::lock_test_env();
2234 let _live = crate::provider_lake::lock_live_snapshot();
2235 let home = tempfile::tempdir().unwrap();
2236 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
2237 crate::provider_catalog_live::reset_cache_for_test();
2238 crate::provider_lake::clear_live_snapshot();
2239 let config = Config {
2240 provider: Some("deepseek".into()),
2241 ..Default::default()
2242 };
2243 let model = "unlisted-future-direct-model";
2244 let before = resolve_runtime_route(&config, ProviderKind::Deepseek, Some(model)).unwrap();
2245 let endpoint = "https://other-provider.catalog.invalid/v1";
2246 let ticket = crate::provider_catalog_live::begin_refresh_for_identity(
2247 ProviderKind::Telecomjs,
2248 "telecomjs",
2249 endpoint,
2250 );
2251 crate::provider_catalog_live::record_success_if_current(
2252 &ticket,
2253 ProviderCatalogDelta {
2254 provider: "telecomjs".into(),
2255 base_url_fingerprint: codewhale_config::catalog::base_url_fingerprint(endpoint),
2256 fetched_at: codewhale_config::catalog::now_unix(),
2257 offerings: vec![live_catalog_offering("telecomjs", model, endpoint)],
2258 },
2259 );
2260 let after = resolve_runtime_route(&config, ProviderKind::Deepseek, Some(model)).unwrap();
2261 assert_eq!(after.model, before.model);
2262 assert_eq!(
2263 after.candidate.endpoint().base_url,
2264 before.candidate.endpoint().base_url
2265 );
2266 assert_eq!(
2267 after.candidate.endpoint().endpoint_key,
2268 before.candidate.endpoint().endpoint_key
2269 );
2270 assert_eq!(
2271 after.candidate.endpoint().protocol,
2272 before.candidate.endpoint().protocol
2273 );
2274 assert_eq!(after.candidate.limits(), before.candidate.limits());
2275 crate::provider_catalog_live::reset_cache_for_test();
2276 crate::provider_lake::clear_live_snapshot();
2277 }
2278
2279 #[test]
2280 fn every_refreshed_provider_uses_fresh_exact_endpoint_route_facts() {
2281 use codewhale_config::catalog::{CatalogRefreshError, ProviderCatalogDelta};
2282
2283 let _env = crate::test_support::lock_test_env();
2284 let _live = crate::provider_lake::lock_live_snapshot();
2285 let home = tempfile::tempdir().unwrap();
2286 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
2287 crate::provider_catalog_live::reset_cache_for_test();
2288 crate::provider_lake::clear_live_snapshot();
2289 for provider in [
2290 ProviderKind::Ollama,
2291 ProviderKind::Codewhale,
2292 ProviderKind::Concentrate,
2293 ProviderKind::Telecomjs,
2294 ProviderKind::Edenai,
2295 ProviderKind::Zenmux,
2296 ] {
2297 let identity = provider.as_str();
2298 let endpoint = format!("https://{identity}.catalog.invalid/v1");
2299 let model = "synthetic-live-model";
2300 let mut config = Config {
2301 provider: Some(identity.into()),
2302 ..Default::default()
2303 };
2304 config
2305 .provider_config_for_mut(&config.test_identity_for_kind(provider))
2306 .unwrap()
2307 .base_url = Some(endpoint.clone());
2308 let ticket = crate::provider_catalog_live::begin_refresh_for_identity(
2309 provider, identity, &endpoint,
2310 );
2311 let fingerprint = codewhale_config::catalog::base_url_fingerprint(&endpoint);
2312 assert!(
2313 crate::provider_catalog_live::record_success_if_current(
2314 &ticket,
2315 ProviderCatalogDelta {
2316 provider: identity.into(),
2317 base_url_fingerprint: fingerprint.clone(),
2318 fetched_at: codewhale_config::catalog::now_unix(),
2319 offerings: vec![live_catalog_offering(identity, model, &endpoint)],
2320 }
2321 )
2322 .is_some()
2323 );
2324 let route = resolve_runtime_route(&config, provider, Some(model)).unwrap();
2325 assert_eq!(route.candidate.endpoint().base_url, endpoint);
2326 assert_live_catalog_route_facts(&route);
2327 let default = resolve_runtime_route(&config, provider, None).unwrap();
2328 assert_eq!(
2329 default.model, model,
2330 "{identity} default must come from its own roster"
2331 );
2332 let mut other = config.clone();
2333 other
2334 .provider_config_for_mut(&other.test_identity_for_kind(provider))
2335 .unwrap()
2336 .base_url = Some("https://other.catalog.invalid/v1".into());
2337 let unowned = resolve_runtime_route(&other, provider, Some(model)).unwrap();
2338 assert!(
2339 !unowned.candidate.limits().has_known_limit(),
2340 "{identity} must not reuse another endpoint's limits"
2341 );
2342 crate::provider_catalog_live::record_failure_if_current(
2343 &ticket,
2344 identity,
2345 &fingerprint,
2346 CatalogRefreshError::Network,
2347 );
2348 let failed = resolve_runtime_route(&config, provider, Some(model)).unwrap();
2349 assert!(
2350 !failed.candidate.limits().has_known_limit(),
2351 "{identity} failed refresh must revoke executable live facts"
2352 );
2353 }
2354 crate::provider_catalog_live::reset_cache_for_test();
2355 crate::provider_lake::clear_live_snapshot();
2356 }
2357
2358 #[test]
2359 fn ollama_default_requires_fresh_endpoint_tags_and_preserves_explicit_choices() {
2360 use codewhale_config::catalog::{CatalogRefreshError, ProviderCatalogDelta};
2361
2362 let _env = crate::test_support::lock_test_env();
2363 let _live = crate::provider_lake::lock_live_snapshot();
2364 let home = tempfile::tempdir().unwrap();
2365 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
2366 crate::provider_catalog_live::reset_cache_for_test();
2367 crate::provider_lake::clear_live_snapshot();
2368 let endpoint = "http://localhost:11451/v1";
2369 let mut config = Config {
2370 provider: Some("ollama".into()),
2371 ..Default::default()
2372 };
2373 config
2374 .provider_config_for_mut(&config.test_identity_for_kind(ProviderKind::Ollama))
2375 .unwrap()
2376 .base_url = Some(endpoint.into());
2377 for selector in [None, Some("auto"), Some("unknown")] {
2378 assert!(resolve_runtime_route(&config, ProviderKind::Ollama, selector).is_err());
2379 }
2380 assert_eq!(
2381 resolve_runtime_route(&config, ProviderKind::Ollama, Some("chosen:tag"))
2382 .unwrap()
2383 .model,
2384 "chosen:tag"
2385 );
2386 let fingerprint = codewhale_config::catalog::base_url_fingerprint(endpoint);
2387 let ticket = crate::provider_catalog_live::begin_refresh_for_identity(
2388 ProviderKind::Ollama,
2389 "ollama",
2390 endpoint,
2391 );
2392 let offerings = ["zeta:tag", "alpha:tag"]
2393 .into_iter()
2394 .map(|model| {
2395 let mut row = live_catalog_offering("ollama", model, endpoint);
2396 row.default_for_provider = false; // Real Ollama tags have no default flag.
2397 row
2398 })
2399 .collect();
2400 crate::provider_catalog_live::record_success_if_current(
2401 &ticket,
2402 ProviderCatalogDelta {
2403 provider: "ollama".into(),
2404 base_url_fingerprint: fingerprint.clone(),
2405 fetched_at: codewhale_config::catalog::now_unix(),
2406 offerings,
2407 },
2408 );
2409 for selector in [None, Some("auto"), Some("unknown")] {
2410 assert_eq!(
2411 resolve_runtime_route(&config, ProviderKind::Ollama, selector)
2412 .unwrap()
2413 .model,
2414 "alpha:tag"
2415 );
2416 }
2417 config
2418 .set_provider_model_override(
2419 &config.test_identity_for_kind(ProviderKind::Ollama),
2420 Some("saved:tag".into()),
2421 )
2422 .unwrap();
2423 assert_eq!(
2424 resolve_runtime_route(&config, ProviderKind::Ollama, None)
2425 .unwrap()
2426 .model,
2427 "saved:tag"
2428 );
2429 assert_eq!(
2430 resolve_runtime_route(&config, ProviderKind::Ollama, Some("explicit:tag"))
2431 .unwrap()
2432 .model,
2433 "explicit:tag"
2434 );
2435 config
2436 .set_provider_model_override(
2437 &config.test_identity_for_kind(ProviderKind::Ollama),
2438 Some("unknown".into()),
2439 )
2440 .unwrap();
2441 assert_eq!(
2442 resolve_runtime_route(&config, ProviderKind::Ollama, None)
2443 .unwrap()
2444 .model,
2445 "alpha:tag"
2446 );
2447 crate::provider_catalog_live::record_failure_if_current(
2448 &ticket,
2449 "ollama",
2450 &fingerprint,
2451 CatalogRefreshError::Network,
2452 );
2453 assert!(resolve_runtime_route(&config, ProviderKind::Ollama, None).is_err());
2454 assert_eq!(
2455 resolve_runtime_route(&config, ProviderKind::Ollama, Some("explicit:tag"))
2456 .unwrap()
2457 .model,
2458 "explicit:tag"
2459 );
2460 crate::provider_catalog_live::reset_cache_for_test();
2461 crate::provider_lake::clear_live_snapshot();
2462 }
2463
2464 #[test]
2465 fn named_baseten_live_facts_reach_exact_custom_runtime_without_leaking() {
2466 use codewhale_config::catalog::ProviderCatalogDelta;
2467
2468 let _env = crate::test_support::lock_test_env();
2469 let _live = crate::provider_lake::lock_live_snapshot();
2470 let home = tempfile::tempdir().expect("home");
2471 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
2472 crate::provider_catalog_live::reset_cache_for_test();
2473 crate::provider_lake::clear_live_snapshot();
2474
2475 let base_url = codewhale_config::catalog::BASETEN_BASE_URL;
2476 let model = "synthetic-live-baseten-model";
2477 let mut custom = std::collections::HashMap::new();
2478 custom.insert(
2479 codewhale_config::catalog::BASETEN_PROVIDER_ID.to_string(),
2480 ProviderConfig {
2481 kind: Some("openai-compatible".to_string()),
2482 base_url: Some(base_url.to_string()),
2483 model: Some(model.to_string()),
2484 ..Default::default()
2485 },
2486 );
2487 let config = Config {
2488 provider: Some(codewhale_config::catalog::BASETEN_PROVIDER_ID.to_string()),
2489 providers: Some(ProvidersConfig {
2490 custom,
2491 ..Default::default()
2492 }),
2493 ..Default::default()
2494 };
2495 crate::provider_catalog_live::record_success(ProviderCatalogDelta {
2496 provider: codewhale_config::catalog::BASETEN_PROVIDER_ID.to_string(),
2497 base_url_fingerprint: codewhale_config::catalog::base_url_fingerprint(base_url),
2498 fetched_at: codewhale_config::catalog::now_unix(),
2499 offerings: vec![live_catalog_offering(
2500 codewhale_config::catalog::BASETEN_PROVIDER_ID,
2501 model,
2502 base_url,
2503 )],
2504 });
2505
2506 let route = resolve_runtime_route(&config, ProviderKind::Custom, Some(model))
2507 .expect("named Baseten route resolves");
2508 assert_eq!(
2509 route.identity.key.as_str(),
2510 codewhale_config::catalog::BASETEN_PROVIDER_ID
2511 );
2512 assert_eq!(route.model, model);
2513 assert_live_catalog_route_facts(&route);
2514
2515 let alias_identity = "base-ten";
2516 let alias_model = "synthetic-alias-baseten-model";
2517 let mut alias_custom = std::collections::HashMap::new();
2518 alias_custom.insert(
2519 alias_identity.to_string(),
2520 ProviderConfig {
2521 kind: Some("openai-compatible".to_string()),
2522 base_url: Some(base_url.to_string()),
2523 model: Some(alias_model.to_string()),
2524 ..Default::default()
2525 },
2526 );
2527 let alias_config = Config {
2528 provider: Some(alias_identity.to_string()),
2529 providers: Some(ProvidersConfig {
2530 custom: alias_custom,
2531 ..Default::default()
2532 }),
2533 ..Default::default()
2534 };
2535 crate::provider_catalog_live::record_success(ProviderCatalogDelta {
2536 provider: alias_identity.to_string(),
2537 base_url_fingerprint: codewhale_config::catalog::base_url_fingerprint(base_url),
2538 fetched_at: codewhale_config::catalog::now_unix(),
2539 offerings: vec![live_catalog_offering(alias_identity, alias_model, base_url)],
2540 });
2541 let alias_route =
2542 resolve_runtime_route(&alias_config, ProviderKind::Custom, Some(alias_model))
2543 .expect("Baseten schema alias route resolves");
2544 assert_eq!(alias_route.identity.key.as_str(), alias_identity);
2545 assert_live_catalog_route_facts(&alias_route);
2546 assert!(
2547 crate::provider_lake::catalog_offering_for_model_identity(
2548 ProviderKind::Custom,
2549 Some(codewhale_config::catalog::BASETEN_PROVIDER_ID),
2550 alias_model,
2551 )
2552 .is_none(),
2553 "a Baseten schema alias must not share another exact table's live roster"
2554 );
2555
2556 let unrelated = custom_config("https://other-compatible.invalid/v1", model);
2557 let unrelated_route = resolve_runtime_route(&unrelated, ProviderKind::Custom, Some(model))
2558 .expect("another compatible provider remains routable");
2559 assert!(!unrelated_route.candidate.limits().has_known_limit());
2560 assert_eq!(
2561 unrelated_route.candidate.capabilities(),
2562 codewhale_config::route::RouteCapabilities::default()
2563 );
2564
2565 crate::provider_catalog_live::reset_cache_for_test();
2566 crate::provider_lake::clear_live_snapshot();
2567 }
2568
2569 fn custom_config(base_url: &str, model: &str) -> Config {
2570 let mut custom = std::collections::HashMap::new();
2571 custom.insert(
2572 "my_thing".to_string(),
2573 ProviderConfig {
2574 kind: Some("openai-compatible".to_string()),
2575 base_url: Some(base_url.to_string()),
2576 model: Some(model.to_string()),
2577 api_key_env: Some("EXAMPLE_API_KEY".to_string()),
2578 ..Default::default()
2579 },
2580 );
2581 Config {
2582 provider: Some("my_thing".to_string()),
2583 providers: Some(ProvidersConfig {
2584 custom,
2585 ..Default::default()
2586 }),
2587 ..Default::default()
2588 }
2589 }
2590
2591 #[test]
2592 fn custom_provider_resolves_to_custom_endpoint_and_verbatim_model() {
2593 use codewhale_config::route::RequestProtocol;
2594
2595 let config = custom_config("https://api.example.com/v1", "vendor/custom-model-v1");
2596 let route = resolve_runtime_route(&config, ProviderKind::Custom, None)
2597 .expect("custom provider should resolve");
2598
2599 // Endpoint + model come from the named table; the prefixed model id is
2600 // preserved verbatim as the wire id (no provider-prefix sniffing).
2601 assert_eq!(
2602 route.candidate.endpoint().base_url,
2603 "https://api.example.com/v1"
2604 );
2605 assert_eq!(
2606 route.candidate.wire_model_id().as_str(),
2607 "vendor/custom-model-v1"
2608 );
2609 assert_eq!(route.model, "vendor/custom-model-v1");
2610 assert_eq!(route.candidate.protocol(), RequestProtocol::ChatCompletions);
2611 // HTTPS endpoint: route is valid with no insecure-http advisory.
2612 assert!(route.candidate.validation().ok);
2613 assert!(route.candidate.validation().messages.is_empty());
2614 // The selected provider name is preserved (not overwritten with "custom").
2615 assert_eq!(route.config.provider.as_deref(), Some("my_thing"));
2616 }
2617
2618 #[test]
2619 fn custom_provider_context_window_overrides_unknown_route_limit() {
2620 let mut custom = std::collections::HashMap::new();
2621 custom.insert(
2622 "dashscope".to_string(),
2623 ProviderConfig {
2624 kind: Some("openai-compatible".to_string()),
2625 base_url: Some("https://dashscope.example.com/compatible-mode/v1".to_string()),
2626 model: Some("qwen3.7".to_string()),
2627 context_window: Some(1_000_000),
2628 api_key_env: Some("DASHSCOPE_API_KEY".to_string()),
2629 ..Default::default()
2630 },
2631 );
2632 let config = Config {
2633 provider: Some("dashscope".to_string()),
2634 providers: Some(ProvidersConfig {
2635 custom,
2636 ..Default::default()
2637 }),
2638 ..Config::default()
2639 };
2640
2641 let route = resolve_runtime_route(&config, ProviderKind::Custom, None)
2642 .expect("custom route should resolve");
2643
2644 assert_eq!(route.model, "qwen3.7");
2645 assert_eq!(route.candidate.limits().context_tokens, Some(1_000_000));
2646 }
2647
2648 #[test]
2649 fn custom_provider_http_non_loopback_fires_insecure_advisory() {
2650 let config = custom_config("http://gpu.internal.example:8000/v1", "custom-model-v1");
2651 let route = resolve_runtime_route(&config, ProviderKind::Custom, None)
2652 .expect("custom http provider should resolve");
2653
2654 // Advisory only: the route still validates (ok == true) but warns that
2655 // credentials would be sent in plaintext over a non-loopback http URL.
2656 assert!(route.candidate.validation().ok);
2657 assert!(
2658 route
2659 .candidate
2660 .validation()
2661 .messages
2662 .iter()
2663 .any(|message| message.contains("insecure http")),
2664 "expected insecure-http advisory, got {:?}",
2665 route.candidate.validation().messages
2666 );
2667 assert_eq!(
2668 route.candidate.endpoint().base_url,
2669 "http://gpu.internal.example:8000/v1"
2670 );
2671 }
2672
2673 /// #6705: only the lake-backed OpenCode Zen route reads a refreshed
2674 /// Models.dev catalog, so only it is told to refresh.
2675 #[test]
2676 fn catalog_refresh_remedy_is_scoped_to_the_lake_backed_zen_route() {
2677 let unproven = |provider: &str| RouteError::UnsupportedModelProtocol {
2678 provider: provider.into(),
2679 model: "new-model".to_string(),
2680 endpoint_key: "unproven".to_string(),
2681 };
2682 let remedy = "codewhale models --update";
2683 assert!(
2684 route_error_text(ProviderKind::OpencodeZen, true, &unproven("opencode-zen"))
2685 .contains(remedy)
2686 );
2687 // OpenCode Go's roster is compiled, and a bundled-only resolver
2688 // never sees the refreshed catalog.
2689 assert!(
2690 !route_error_text(ProviderKind::OpencodeGo, true, &unproven("opencode-go"))
2691 .contains(remedy)
2692 );
2693 assert!(
2694 !route_error_text(ProviderKind::OpencodeZen, false, &unproven("opencode-zen"))
2695 .contains(remedy)
2696 );
2697 let deprecated = RouteError::UnsupportedModelProtocol {
2698 provider: "opencode-zen".into(),
2699 model: "claude-2-retired".to_string(),
2700 endpoint_key: "deprecated".to_string(),
2701 };
2702 let text = route_error_text(ProviderKind::OpencodeZen, true, &deprecated);
2703 assert!(
2704 text.contains("deprecated") && !text.contains(remedy),
2705 "{text}"
2706 );
2707 }
2708 #[test]
2709 fn owned_chatgpt_roster_context_reaches_runtime_only_and_never_unscoped_resolver() {
2710 let _lock = crate::test_support::lock_test_env();
2711 let home = tempfile::tempdir().expect("owned registration home");
2712 let root = home.path().canonicalize().expect("canonical owned home");
2713 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &root);
2714 let mut config = Config::default();
2715 crate::oauth::install_test_chatgpt_registration(&mut config).expect("owned grant");
2716 let model = crate::config::DEFAULT_OPENAI_CODEX_MODEL;
2717 crate::codex_model_cache::install_test_chatgpt_roster_with_metadata(
2718 &config,
2719 vec![crate::codex_model_cache::CodexModelMetadata {
2720 id: model.into(),
2721 display_name: None,
2722 context_window: Some(272_000),
2723 reasoning: Some(true),
2724 efforts: vec!["high".into()],
2725 }],
2726 )
2727 .expect("account-scoped roster");
2728 let runtime = resolve_runtime_route(&config, ProviderKind::OpenaiCodex, Some(model))
2729 .expect("owned runtime route");
2730 assert_eq!(runtime.candidate.limits().context_tokens, Some(272_000));
2731 assert_eq!(runtime.context_window.tokens, 272_000);
2732 assert_eq!(runtime.candidate.limits().input_tokens, None);
2733 assert_eq!(runtime.candidate.limits().output_tokens, None);
2734 let unscoped = resolve_route_candidate_with_context_metadata(
2735 ProviderKind::OpenaiCodex,
2736 Some(model),
2737 None,
2738 None,
2739 None,
2740 None,
2741 None,
2742 )
2743 .expect("unscoped descriptor");
2744 assert_eq!(unscoped.candidate.limits().context_tokens, None);
2745 }
2746 }
2747
2747 lines RUST