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