返回 CodeWhale
route_runtime.rs
根目录 / crates / tui / src / route_runtime.rs
1 use chrono::{DateTime, Duration, Utc};
2 use codewhale_config::route::{
3 LimitField, LogicalModelRef, OverrideSource, ReadyRouteCandidate, RouteRequest, RouteResolver,
4 SourcedLimitOverride, WireModelId,
5 };
6 use serde::Serialize;
7
8 use crate::client::DeepSeekClient;
9 use crate::codex_model_cache::{CodexModelCacheFreshness, model_roster};
10 use crate::config::{
11 ApiProvider, Config, DEFAULT_NVIDIA_NIM_BASE_URL, KIMI_CODE_K3_CONTEXT_WINDOW_TOKENS,
12 ProviderIdentity, is_exact_direct_moonshot_k3_route, is_exact_kimi_code_k3_route,
13 validate_kimi_code_api_model_id,
14 };
15 use crate::models::DIRECT_KIMI_K3_MAX_OUTPUT_TOKENS;
16
17 /// Why a route is using its effective context-window value. Keep this
18 /// receipt separate from the numeric route limits so every consumer can state
19 /// whether the number is operator-configured, freshly provider-reported, a
20 /// Kimi Code safety floor, catalog data, or a conservative fallback.
21 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
22 #[serde(rename_all = "snake_case")]
23 pub(crate) enum ContextWindowSource {
24 Configured,
25 ProviderReported,
26 StaticKimiCodeSafeFloor,
27 Catalog,
28 Fallback,
29 }
30
31 impl ContextWindowSource {
32 #[must_use]
33 pub(crate) const fn label(self) -> &'static str {
34 match self {
35 Self::Configured => "configured",
36 Self::ProviderReported => "provider-reported",
37 Self::StaticKimiCodeSafeFloor => "static Kimi Code safe floor",
38 Self::Catalog => "catalog",
39 Self::Fallback => "fallback",
40 }
41 }
42 }
43
44 /// Context window carried alongside an exact runtime route.
45 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
46 pub(crate) struct ContextWindowResolution {
47 pub(crate) tokens: u32,
48 pub(crate) source: ContextWindowSource,
49 }
50
51 /// Authenticated Kimi Code `/models` metadata that a caller has already
52 /// validated. This is intentionally route-scoped: generic Moonshot metadata
53 /// can never promote a bare `k3` route. The current runtime has no implicit
54 /// network probe; an authenticated model-listing consumer may pass this value
55 /// to [`resolve_route_candidate_with_context_metadata`].
56 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
57 pub(crate) struct ProviderReportedKimiCodeContext {
58 pub(crate) context_tokens: u32,
59 pub(crate) observed_at: DateTime<Utc>,
60 }
61
62 const KIMI_CODE_REPORTED_CONTEXT_MAX_AGE_HOURS: i64 = 24;
63
64 #[derive(Debug)]
65 pub(crate) struct RouteCandidateResolution {
66 pub(crate) candidate: ReadyRouteCandidate,
67 pub(crate) context_window: ContextWindowResolution,
68 }
69
70 #[derive(Clone)]
71 pub(crate) struct ResolvedRuntimeRoute {
72 pub(crate) identity: ProviderIdentity,
73 pub(crate) candidate: ReadyRouteCandidate,
74 pub(crate) config: Box<Config>,
75 pub(crate) model: String,
76 pub(crate) context_window: ContextWindowResolution,
77 preflighted_client: Option<DeepSeekClient>,
78 }
79
80 impl std::fmt::Debug for ResolvedRuntimeRoute {
81 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82 f.debug_struct("ResolvedRuntimeRoute")
83 .field("provider_identity", &self.identity.key)
84 .field("provider", &self.identity.provider)
85 .field("model", &self.model)
86 .finish_non_exhaustive()
87 }
88 }
89
90 /// One exact provider route, fully resolved and client-preflighted before a
91 /// host mutates session/runtime state. The config and client may contain
92 /// credentials, so diagnostics intentionally expose only non-secret receipt
93 /// fields.
94 #[derive(Clone)]
95 pub(crate) struct ValidatedRuntimeRoute {
96 pub(crate) identity: ProviderIdentity,
97 pub(crate) candidate: ReadyRouteCandidate,
98 pub(crate) config: Box<Config>,
99 pub(crate) model: String,
100 pub(crate) context_window: ContextWindowResolution,
101 pub(crate) client: DeepSeekClient,
102 }
103
104 impl std::fmt::Debug for ValidatedRuntimeRoute {
105 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106 f.debug_struct("ValidatedRuntimeRoute")
107 .field("provider_identity", &self.identity.key)
108 .field("provider", &self.identity.provider)
109 .field("model", &self.model)
110 .finish_non_exhaustive()
111 }
112 }
113
114 impl ResolvedRuntimeRoute {
115 pub(crate) fn preflight(mut self) -> Result<Self, String> {
116 if self.preflighted_client.is_none() {
117 self.preflighted_client = Some(
118 DeepSeekClient::from_candidate(&self.config, &self.candidate).map_err(|err| {
119 format_provider_route_preflight_error(&self.identity.key, &self.model, &err)
120 })?,
121 );
122 }
123 Ok(self)
124 }
125
126 pub(crate) fn validate(mut self) -> Result<ValidatedRuntimeRoute, String> {
127 let client = match self.preflighted_client.take() {
128 Some(client) => client,
129 None => {
130 DeepSeekClient::from_candidate(&self.config, &self.candidate).map_err(|err| {
131 format_provider_route_preflight_error(&self.identity.key, &self.model, &err)
132 })?
133 }
134 };
135 Ok(ValidatedRuntimeRoute {
136 identity: self.identity,
137 candidate: self.candidate,
138 config: self.config,
139 model: self.model,
140 context_window: self.context_window,
141 client,
142 })
143 }
144
145 pub(crate) fn take_preflighted_client(&mut self) -> Option<DeepSeekClient> {
146 self.preflighted_client.take()
147 }
148 }
149
150 fn format_provider_route_preflight_error(
151 identity_key: &str,
152 model: &str,
153 err: &anyhow::Error,
154 ) -> String {
155 let reason = err.to_string().trim().to_string();
156 let mut message = format!(
157 "{}. Failed to configure provider route {} / {}.",
158 reason, identity_key, model
159 );
160 if let Some(next_step) = classify_provider_route_preflight_next_step(identity_key, &reason) {
161 message.push_str(" Next step: ");
162 message.push_str(&next_step);
163 }
164 message
165 }
166
167 fn classify_provider_route_preflight_next_step(identity_key: &str, reason: &str) -> Option<String> {
168 let lower = reason.to_ascii_lowercase();
169 if lower
170 .contains("codex oauth credentials are only available on the official openai codex route")
171 {
172 return Some(format!(
173 "Run /provider setup {identity_key} and remove its custom base URL; Codex OAuth only works on the official route."
174 ));
175 }
176 if lower.contains("openai codex oauth credentials are unavailable")
177 || lower.contains("codex access token")
178 {
179 return Some(format!(
180 "Run `codex login`, then retry {identity_key}; Codewhale reads that official CLI login without modifying it."
181 ));
182 }
183 if lower.contains("api key not found")
184 || lower.contains("access token")
185 || (lower.contains("credential")
186 && (lower.contains("not found")
187 || lower.contains("missing")
188 || lower.contains("unsupported")))
189 {
190 return Some(format!(
191 "Run /auth or /provider setup {identity_key} to configure credentials."
192 ));
193 }
194 if lower.contains("tls certificate")
195 || lower.contains("ssl_cert_file")
196 || lower.contains("certificate verification")
197 || lower.contains("insecure_skip_tls_verify")
198 || lower.contains("base url")
199 || lower.contains("invalid url")
200 {
201 return Some(format!(
202 "Run /provider setup {identity_key} to fix base URL/TLS settings."
203 ));
204 }
205 if lower.contains("provider")
206 && lower.contains("model")
207 && (lower.contains("pin")
208 || lower.contains("mismatch")
209 || lower.contains("unknown")
210 || lower.contains("not found"))
211 {
212 return Some(
213 "Run /models (or open the model picker) and choose a model valid for this provider."
214 .to_string(),
215 );
216 }
217 if lower.contains("fleet") || lower.contains("profile") || lower.contains("partial route") {
218 return Some(
219 "Review Fleet profile provider/model overrides; keep route fields atomic (#5042)."
220 .to_string(),
221 );
222 }
223 Some(format!(
224 "Run /provider setup {identity_key} to review this route configuration."
225 ))
226 }
227
228 impl ValidatedRuntimeRoute {
229 /// Preserve the preflighted client with the exact resolved route receipt
230 /// so the engine does not repeat environment-sensitive client discovery.
231 pub(crate) fn into_resolved(self) -> ResolvedRuntimeRoute {
232 ResolvedRuntimeRoute {
233 identity: self.identity,
234 candidate: self.candidate,
235 config: self.config,
236 model: self.model,
237 context_window: self.context_window,
238 preflighted_client: Some(self.client),
239 }
240 }
241 }
242
243 pub(crate) fn resolve_route_candidate(
244 provider: ApiProvider,
245 model_selector: Option<&str>,
246 saved_provider_model: Option<&str>,
247 base_url_override: Option<String>,
248 context_window_override: Option<u32>,
249 ) -> Result<ReadyRouteCandidate, String> {
250 resolve_route_candidate_with_context_metadata(
251 provider,
252 model_selector,
253 saved_provider_model,
254 base_url_override,
255 context_window_override,
256 None,
257 )
258 .map(|resolution| resolution.candidate)
259 }
260
261 /// Reject only a provider-less model mismatch that existing route knowledge
262 /// proves foreign. Partial catalogs are not allowlists: unknown ids, local
263 /// runtimes, gateways, and custom endpoints remain provider-authoritative.
264 pub(crate) fn validate_unpinned_model_provider(
265 provider: ApiProvider,
266 model: &str,
267 base_url: &str,
268 ) -> Result<(), String> {
269 let Some(kind) = provider.kind() else {
270 return Ok(());
271 };
272 let Some(owner) = codewhale_config::known_foreign_model_owner(kind, model, base_url) else {
273 return Ok(());
274 };
275 Err(format!(
276 "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.",
277 model.trim(),
278 provider.as_str(),
279 owner.as_str()
280 ))
281 }
282
283 /// Resolve a provider-less fixed model to the provider's exact wire id before
284 /// child admission. This shares the runtime resolver used by Fleet receipts,
285 /// including aggregator alias translation, without making a live request.
286 pub(crate) fn resolve_unpinned_model_candidate(
287 provider: ApiProvider,
288 model: &str,
289 base_url: &str,
290 ) -> Result<ReadyRouteCandidate, String> {
291 validate_unpinned_model_provider(provider, model, base_url)?;
292 resolve_route_candidate(
293 provider,
294 Some(model),
295 None,
296 Some(base_url.to_string()),
297 None,
298 )
299 }
300
301 /// Resolve a candidate together with a non-secret context-window provenance
302 /// receipt. `provider_reported_context` is accepted only for the exact Kimi
303 /// Code bare-K3 endpoint, only at the documented 1M entitlement, and only
304 /// while fresh; this prevents generic Moonshot or stale metadata from being
305 /// inherited by a membership-plan route.
306 pub(crate) fn resolve_route_candidate_with_context_metadata(
307 provider: ApiProvider,
308 model_selector: Option<&str>,
309 saved_provider_model: Option<&str>,
310 base_url_override: Option<String>,
311 context_window_override: Option<u32>,
312 provider_reported_context: Option<ProviderReportedKimiCodeContext>,
313 ) -> Result<RouteCandidateResolution, String> {
314 let effective_base_url = base_url_override
315 .as_deref()
316 .unwrap_or_else(|| provider.default_base_url());
317 if let Some(model) = model_selector.or(saved_provider_model) {
318 validate_kimi_code_api_model_id(provider, effective_base_url, model)?;
319 }
320 let resolver = RouteResolver::new();
321 let base_request = RouteRequest {
322 explicit_provider: provider.kind(),
323 model_selector: model_selector.map(|model| LogicalModelRef::from(model.to_string())),
324 saved_provider_model: saved_provider_model
325 .map(|model| WireModelId::from(model.to_string())),
326 base_url_override,
327 limit_overrides: Vec::new(),
328 };
329 // First pass: resolve the route without overrides to learn the effective
330 // endpoint, wire model id, and catalog limits. Candidates are immutable, so
331 // limit adjustments are planned from this read-only resolution and then
332 // requested through `RouteRequest::limit_overrides` on a second pass; the
333 // resolver applies them BEFORE minting the final candidate and records
334 // their provenance on it.
335 let resolved = resolver
336 .resolve(&base_request)
337 .map_err(|err| err.to_string())?;
338 let plan = plan_limit_overrides(
339 provider,
340 &resolved,
341 context_window_override,
342 provider_reported_context,
343 );
344 let candidate = if plan.overrides.is_empty() {
345 resolved
346 } else {
347 resolver
348 .resolve(&RouteRequest {
349 limit_overrides: plan.overrides,
350 ..base_request
351 })
352 .map_err(|err| err.to_string())?
353 };
354 Ok(RouteCandidateResolution {
355 candidate,
356 context_window: plan.context_window,
357 })
358 }
359
360 /// The sourced limit overrides a route needs, plus the context-window receipt
361 /// describing the effective context value they produce.
362 struct LimitOverridePlan {
363 overrides: Vec<SourcedLimitOverride>,
364 context_window: ContextWindowResolution,
365 }
366
367 /// Plan the limit overrides for a resolved route.
368 ///
369 /// Precedence (unchanged from the previous post-hoc mutation order):
370 /// provider-scoped roster/API corrections and exact-route documented output
371 /// facts first, then operator-configured context, then fresh route-scoped
372 /// provider-reported context, then the membership-plan safe floor, then
373 /// catalog data, then the conservative fallback.
374 fn plan_limit_overrides(
375 provider: ApiProvider,
376 resolved: &ReadyRouteCandidate,
377 context_window_override: Option<u32>,
378 provider_reported_context: Option<ProviderReportedKimiCodeContext>,
379 ) -> LimitOverridePlan {
380 let mut overrides = Vec::new();
381 let configured = context_window_override.filter(|window| *window > 0);
382 let mut effective_context = resolved.limits().context_tokens;
383 if is_exact_direct_moonshot_k3_route(
384 provider,
385 &resolved.endpoint().base_url,
386 resolved.wire_model_id().as_str(),
387 ) {
388 overrides.push(SourcedLimitOverride {
389 field: LimitField::OutputTokens,
390 value: Some(u64::from(DIRECT_KIMI_K3_MAX_OUTPUT_TOKENS)),
391 source: OverrideSource::DocumentedRouteOutputMaximum,
392 });
393 }
394 if provider == ApiProvider::OpenaiCodex {
395 // Models.dev describes the public API offering, not the account-scoped
396 // ChatGPT OAuth route. Strip API-only limits, then carry the fresh
397 // Codex roster's per-model context into every runtime consumer.
398 overrides.push(SourcedLimitOverride {
399 field: LimitField::InputTokens,
400 value: None,
401 source: OverrideSource::CodexPublicApiLimitStrip,
402 });
403 overrides.push(SourcedLimitOverride {
404 field: LimitField::OutputTokens,
405 value: None,
406 source: OverrideSource::CodexPublicApiLimitStrip,
407 });
408 if configured.is_none() {
409 let roster = model_roster();
410 let roster_context = if roster.freshness == CodexModelCacheFreshness::Fresh {
411 roster
412 .metadata_for(resolved.wire_model_id().as_str())
413 .and_then(|metadata| metadata.context_window)
414 .map(u64::from)
415 } else {
416 None
417 };
418 effective_context = roster_context;
419 overrides.push(SourcedLimitOverride {
420 field: LimitField::ContextTokens,
421 value: roster_context,
422 source: OverrideSource::CodexRosterCorrection,
423 });
424 }
425 }
426
427 if let Some(context_window) = configured {
428 overrides.push(SourcedLimitOverride {
429 field: LimitField::ContextTokens,
430 value: Some(u64::from(context_window)),
431 source: OverrideSource::UserContextWindow,
432 });
433 return LimitOverridePlan {
434 overrides,
435 context_window: ContextWindowResolution {
436 tokens: context_window,
437 source: ContextWindowSource::Configured,
438 },
439 };
440 }
441
442 let is_exact_kimi_code_k3 = is_exact_kimi_code_k3_route(
443 provider,
444 &resolved.endpoint().base_url,
445 resolved.wire_model_id().as_str(),
446 );
447 let now = Utc::now();
448 if is_exact_kimi_code_k3
449 && provider_reported_context.is_some_and(|reported| {
450 reported.context_tokens == 1_048_576
451 && reported.observed_at <= now
452 && now.signed_duration_since(reported.observed_at)
453 <= Duration::hours(KIMI_CODE_REPORTED_CONTEXT_MAX_AGE_HOURS)
454 })
455 {
456 let reported = provider_reported_context.expect("checked above");
457 overrides.push(SourcedLimitOverride {
458 field: LimitField::ContextTokens,
459 value: Some(u64::from(reported.context_tokens)),
460 source: OverrideSource::ProviderReportedContextWindow,
461 });
462 return LimitOverridePlan {
463 overrides,
464 context_window: ContextWindowResolution {
465 tokens: reported.context_tokens,
466 source: ContextWindowSource::ProviderReported,
467 },
468 };
469 }
470
471 // Kimi Code's bare `k3` is a membership-plan route, not an alias for
472 // Moonshot's public `kimi-k3` catalog entry. The safe all-plan floor is
473 // the route's next precedence after an explicit config or fresh, scoped
474 // provider report.
475 if is_exact_kimi_code_k3 {
476 overrides.push(SourcedLimitOverride {
477 field: LimitField::ContextTokens,
478 value: Some(u64::from(KIMI_CODE_K3_CONTEXT_WINDOW_TOKENS)),
479 source: OverrideSource::MembershipPlanSafeFloor,
480 });
481 return LimitOverridePlan {
482 overrides,
483 context_window: ContextWindowResolution {
484 tokens: KIMI_CODE_K3_CONTEXT_WINDOW_TOKENS,
485 source: ContextWindowSource::StaticKimiCodeSafeFloor,
486 },
487 };
488 }
489
490 if let Some(tokens) = effective_context.and_then(|tokens| u32::try_from(tokens).ok()) {
491 return LimitOverridePlan {
492 overrides,
493 context_window: ContextWindowResolution {
494 tokens,
495 source: ContextWindowSource::Catalog,
496 },
497 };
498 }
499
500 let fallback_tokens =
501 crate::config::provider_capability(provider, resolved.wire_model_id().as_str())
502 .context_window;
503 LimitOverridePlan {
504 overrides,
505 context_window: ContextWindowResolution {
506 tokens: fallback_tokens,
507 source: ContextWindowSource::Fallback,
508 },
509 }
510 }
511
512 pub(crate) fn resolve_runtime_route(
513 config: &Config,
514 provider: ApiProvider,
515 model_selector: Option<&str>,
516 ) -> Result<ResolvedRuntimeRoute, String> {
517 let identity = if provider == ApiProvider::Custom {
518 config.active_provider_identity(provider)?
519 } else {
520 config
521 .resolve_persisted_provider_identity(Some(provider.as_str()), Some(provider.as_str()))?
522 };
523 resolve_runtime_route_for_identity(config, &identity, model_selector)
524 }
525
526 /// Resolve one persisted/live identity into a scoped runtime config and route
527 /// candidate. Identity is revalidated against the live registry before any
528 /// endpoint, model, credential, or client material is read.
529 pub(crate) fn resolve_runtime_route_for_identity(
530 config: &Config,
531 identity: &ProviderIdentity,
532 model_selector: Option<&str>,
533 ) -> Result<ResolvedRuntimeRoute, String> {
534 let identity = config.resolve_persisted_provider_identity(
535 Some(identity.provider.as_str()),
536 identity.persisted_id(),
537 )?;
538 let provider = identity.provider;
539 let mut route_config = prepared_route_config(config, &identity, model_selector);
540 let saved_provider_model = configured_model_for_route(&route_config, provider);
541 // #5034: with no explicit selector and no saved model, a Codex route
542 // would fall back to the resolver's static seed offering. Prefer the
543 // live Codex roster head so a provider switch lands on the current
544 // flagship model; a missing/stale roster keeps the seed offering.
545 let roster_preferred = (provider == ApiProvider::OpenaiCodex
546 && model_selector.is_none()
547 && saved_provider_model.is_none())
548 .then(|| model_roster().preferred_model_id().map(str::to_string))
549 .flatten();
550 let model_selector = model_selector.or(roster_preferred.as_deref());
551 let resolution = resolve_route_candidate_with_context_metadata(
552 provider,
553 model_selector,
554 saved_provider_model,
555 Some(route_config.deepseek_base_url()),
556 route_config.context_window_for_provider_config(provider),
557 None,
558 )?;
559 let candidate = resolution.candidate;
560 let model = candidate.wire_model_id().as_str().to_string();
561 set_model_for_route(&mut route_config, provider, &model);
562
563 Ok(ResolvedRuntimeRoute {
564 identity,
565 candidate,
566 config: Box::new(route_config),
567 model,
568 context_window: resolution.context_window,
569 preflighted_client: None,
570 })
571 }
572
573 fn prepared_route_config(
574 config: &Config,
575 identity: &ProviderIdentity,
576 model_selector: Option<&str>,
577 ) -> Config {
578 let mut route_config = config.clone();
579 route_config.scope_to_provider_identity(identity);
580 let provider = identity.provider;
581 if matches!(provider, ApiProvider::NvidiaNim)
582 && route_config
583 .base_url
584 .as_deref()
585 .map(|base| !base.contains("integrate.api.nvidia.com"))
586 .unwrap_or(true)
587 {
588 route_config.base_url = Some(DEFAULT_NVIDIA_NIM_BASE_URL.to_string());
589 }
590 if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)
591 && route_config
592 .base_url
593 .as_deref()
594 .map(root_base_url_belongs_to_non_deepseek_provider)
595 .unwrap_or(false)
596 {
597 route_config.base_url = None;
598 }
599 if let Some(model) = model_selector {
600 set_model_for_route(&mut route_config, provider, model);
601 }
602 route_config
603 }
604
605 fn configured_model_for_route(config: &Config, provider: ApiProvider) -> Option<&str> {
606 if provider == ApiProvider::Custom && config.uses_legacy_literal_custom_route() {
607 return config.default_text_model.as_deref();
608 }
609 config
610 .provider_config_for(provider)
611 .and_then(|provider| provider.model.as_deref())
612 }
613
614 fn set_model_for_route(config: &mut Config, provider: ApiProvider, model: &str) {
615 config.set_provider_model_override(provider, Some(model.to_string()));
616 }
617
618 fn root_base_url_belongs_to_non_deepseek_provider(base_url: &str) -> bool {
619 let lower = base_url.to_ascii_lowercase();
620 [
621 "integrate.api.nvidia.com",
622 "api.openai.com",
623 "api.atlascloud.ai",
624 "maas-openapi.wanjiedata.com",
625 "volces.com",
626 "openrouter.ai",
627 "xiaomimimo.com",
628 "novita.ai",
629 "fireworks.ai",
630 "siliconflow",
631 "arcee.ai",
632 "moonshot.ai",
633 "api.kimi.com",
634 ]
635 .iter()
636 .any(|needle| lower.contains(needle))
637 }
638
639 #[cfg(test)]
640 mod tests {
641 use super::*;
642 use crate::config::{DEFAULT_TEXT_MODEL, DEFAULT_ZAI_MODEL, ProviderConfig, ProvidersConfig};
643
644 #[test]
645 fn resolved_runtime_route_keeps_large_config_off_async_stacks() {
646 assert!(
647 std::mem::size_of::<ResolvedRuntimeRoute>() <= 1024,
648 "resolved routes cross several async boundaries and must keep Config boxed"
649 );
650 assert!(
651 std::mem::size_of::<ResolvedRuntimeRoute>() < std::mem::size_of::<Config>(),
652 "resolved routes must remain smaller than their scoped Config payload"
653 );
654 }
655
656 #[test]
657 fn provider_route_preflight_missing_key_error_surfaces_reason_and_auth_step() {
658 let err = anyhow::anyhow!(
659 "Custom provider 'lm-studio' API key not found. Run 'codewhale auth set --provider custom'."
660 );
661 let formatted = format_provider_route_preflight_error("lm-studio", "local-model", &err);
662
663 assert!(formatted.starts_with("Custom provider 'lm-studio' API key not found."));
664 assert!(formatted.contains("Failed to configure provider route lm-studio / local-model."));
665 assert!(formatted.contains(
666 "Next step: Run /auth or /provider setup lm-studio to configure credentials."
667 ));
668 }
669
670 #[test]
671 fn provider_route_preflight_codex_oauth_errors_surface_the_right_next_step() {
672 let missing = anyhow::anyhow!("OpenAI Codex OAuth credentials are unavailable.");
673 let missing_formatted =
674 format_provider_route_preflight_error("openai-codex", "gpt-5.6-sol", &missing);
675 assert!(missing_formatted.contains(
676 "Next step: Run `codex login`, then retry openai-codex; Codewhale reads that official CLI login without modifying it."
677 ));
678
679 let custom = anyhow::anyhow!(
680 "Codex OAuth credentials are only available on the official OpenAI Codex route"
681 );
682 let custom_formatted =
683 format_provider_route_preflight_error("openai-codex", "gpt-5.6-sol", &custom);
684 assert!(custom_formatted.contains(
685 "Next step: Run /provider setup openai-codex and remove its custom base URL; Codex OAuth only works on the official route."
686 ));
687 }
688
689 #[test]
690 fn provider_route_preflight_tls_error_surfaces_route_and_setup_step() {
691 let err = anyhow::anyhow!(
692 "TLS certificate verification cannot be disabled for provider custom; configure SSL_CERT_FILE with a trusted custom CA bundle instead"
693 );
694 let formatted = format_provider_route_preflight_error("lm-studio", "local-model", &err);
695
696 assert!(
697 formatted
698 .starts_with("TLS certificate verification cannot be disabled for provider custom")
699 );
700 assert!(formatted.contains("Failed to configure provider route lm-studio / local-model."));
701 assert!(
702 formatted
703 .contains("Next step: Run /provider setup lm-studio to fix base URL/TLS settings.")
704 );
705 }
706
707 #[test]
708 fn codex_route_uses_fresh_account_context_and_drops_api_only_limits() {
709 let _lock = crate::test_support::lock_test_env();
710 let codex_home = tempfile::tempdir().expect("Codex home");
711 let _home = crate::test_support::EnvVarGuard::set("CODEX_HOME", codex_home.path());
712 std::fs::write(
713 codex_home.path().join("models_cache.json"),
714 serde_json::to_vec(&serde_json::json!({
715 "fetched_at": chrono::Utc::now(),
716 "models": [{
717 "slug": crate::config::DEFAULT_OPENAI_CODEX_MODEL,
718 "priority": 1,
719 "context_window": 128000,
720 "supported_reasoning_levels": [{"effort": "high"}]
721 }]
722 }))
723 .expect("serialize cache"),
724 )
725 .expect("write cache");
726
727 let candidate = resolve_route_candidate(
728 ApiProvider::OpenaiCodex,
729 Some(crate::config::DEFAULT_OPENAI_CODEX_MODEL),
730 None,
731 None,
732 None,
733 )
734 .expect("Codex route");
735
736 assert_eq!(candidate.limits().context_tokens, Some(128_000));
737 assert_eq!(candidate.limits().input_tokens, None);
738 assert_eq!(candidate.limits().output_tokens, None);
739 assert_eq!(
740 crate::route_budget::route_context_window_tokens(
741 ApiProvider::OpenaiCodex,
742 crate::config::DEFAULT_OPENAI_CODEX_MODEL,
743 Some(candidate.limits()),
744 ),
745 128_000
746 );
747 }
748
749 #[test]
750 fn codex_switch_without_saved_model_prefers_fresh_roster_head() {
751 // #5034: switching to openai-codex with no saved model must land on
752 // the roster's current flagship, not the static seed constant.
753 let _lock = crate::test_support::lock_test_env();
754 let codex_home = tempfile::tempdir().expect("Codex home");
755 let _home = crate::test_support::EnvVarGuard::set("CODEX_HOME", codex_home.path());
756 std::fs::write(
757 codex_home.path().join("models_cache.json"),
758 serde_json::to_vec(&serde_json::json!({
759 "fetched_at": chrono::Utc::now(),
760 "models": [
761 {"slug": "gpt-test-flagship", "priority": 1, "context_window": 256000},
762 {"slug": crate::config::DEFAULT_OPENAI_CODEX_MODEL, "priority": 7}
763 ]
764 }))
765 .expect("serialize cache"),
766 )
767 .expect("write cache");
768
769 let config = crate::config::Config::default();
770 let route = resolve_runtime_route(&config, ApiProvider::OpenaiCodex, None)
771 .expect("codex route resolves");
772 assert_eq!(route.model, "gpt-test-flagship");
773
774 // An explicit selector or saved provider model still wins.
775 let explicit = resolve_runtime_route(
776 &config,
777 ApiProvider::OpenaiCodex,
778 Some(crate::config::DEFAULT_OPENAI_CODEX_MODEL),
779 )
780 .expect("explicit codex route resolves");
781 assert_eq!(explicit.model, crate::config::DEFAULT_OPENAI_CODEX_MODEL);
782 }
783
784 #[test]
785 fn opencode_go_kimi_k3_route_uses_1m_context() {
786 // OpenCode Go may not own a models.dev row for kimi-k3; capability and
787 // budget resolution still must use the 1M K3 contract, never the 128K
788 // legacy fallback or the 131K max-output field.
789 let cap = crate::config::provider_capability(ApiProvider::OpencodeGo, "kimi-k3");
790 assert_eq!(cap.context_window, 1_048_576);
791 assert_eq!(cap.max_output, Some(131_072));
792 assert_ne!(Some(cap.context_window), cap.max_output);
793
794 let candidate =
795 resolve_route_candidate(ApiProvider::OpencodeGo, Some("kimi-k3"), None, None, None)
796 .expect("OpenCode Go Kimi K3 route");
797 assert_eq!(candidate.wire_model_id().as_str(), "kimi-k3");
798 // Prefer catalog/route limits when present; otherwise the capability
799 // path above is the source of truth for picker/budget display.
800 if let Some(ctx) = candidate.limits().context_tokens {
801 assert_eq!(ctx, 1_048_576);
802 } else {
803 assert_eq!(
804 crate::route_budget::route_context_window_tokens(
805 ApiProvider::OpencodeGo,
806 "kimi-k3",
807 Some(candidate.limits()),
808 ),
809 1_048_576
810 );
811 }
812 }
813
814 #[test]
815 fn direct_moonshot_k3_route_uses_documented_1m_limits_with_provenance() {
816 let candidate =
817 resolve_route_candidate(ApiProvider::Moonshot, Some("kimi-k3"), None, None, None)
818 .expect("Moonshot Kimi K3 route");
819
820 assert_eq!(candidate.wire_model_id().as_str(), "kimi-k3");
821 assert_eq!(candidate.limits().context_tokens, Some(1_048_576));
822 assert_eq!(candidate.limits().output_tokens, Some(1_048_576));
823 assert!(candidate.applied_limit_overrides().contains(
824 &codewhale_config::route::SourcedLimitOverride {
825 field: codewhale_config::route::LimitField::OutputTokens,
826 value: Some(1_048_576),
827 source: codewhale_config::route::OverrideSource::DocumentedRouteOutputMaximum,
828 }
829 ));
830 assert_eq!(
831 crate::route_budget::route_context_window_tokens(
832 ApiProvider::Moonshot,
833 "kimi-k3",
834 Some(candidate.limits()),
835 ),
836 1_048_576
837 );
838 assert_eq!(
839 crate::route_budget::effective_max_output_tokens_for_route(
840 ApiProvider::Moonshot,
841 "kimi-k3",
842 Some(candidate.limits()),
843 ),
844 65_536,
845 "route metadata must not raise the ordinary request cap"
846 );
847 }
848
849 #[test]
850 fn kimi_code_bare_k3_keeps_tier_safe_floor_not_legacy_128k() {
851 // Bare `k3` membership context is plan-tier dependent (256K on lower
852 // tiers, up to 1M on higher ones), so the static route baseline stays
853 // the safe floor. Higher entitlements come from an explicit provider
854 // `context_window` override — never from assuming the top tier, and
855 // never from the 128K legacy default.
856 let candidate = resolve_route_candidate(
857 ApiProvider::Moonshot,
858 Some("k3"),
859 None,
860 Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
861 None,
862 )
863 .expect("Kimi Code K3 route");
864
865 assert_eq!(candidate.wire_model_id().as_str(), "k3");
866 assert_eq!(candidate.limits().context_tokens, Some(262_144));
867 // Output remains a conservative generic default because the
868 // membership API does not publish a distinct maximum. Never project
869 // it as context or inherit the direct-platform 1M maximum.
870 assert_ne!(
871 candidate.limits().context_tokens,
872 candidate.limits().output_tokens
873 );
874 assert_eq!(
875 crate::config::provider_capability(
876 ApiProvider::Moonshot,
877 crate::config::KIMI_CODE_K3_MODEL
878 )
879 .context_window,
880 262_144
881 );
882 assert_ne!(candidate.limits().output_tokens, Some(1_048_576));
883 }
884
885 #[test]
886 fn kimi_code_context_resolution_records_precedence_and_rejects_bad_metadata() {
887 let base = Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string());
888 let static_floor = resolve_route_candidate_with_context_metadata(
889 ApiProvider::Moonshot,
890 Some("k3"),
891 None,
892 base.clone(),
893 None,
894 None,
895 )
896 .expect("Kimi Code route");
897 assert_eq!(static_floor.context_window.tokens, 262_144);
898 assert_eq!(
899 static_floor.context_window.source,
900 ContextWindowSource::StaticKimiCodeSafeFloor
901 );
902
903 let configured = resolve_route_candidate_with_context_metadata(
904 ApiProvider::Moonshot,
905 Some("k3"),
906 None,
907 base.clone(),
908 Some(1_048_576),
909 Some(ProviderReportedKimiCodeContext {
910 context_tokens: 1_048_576,
911 observed_at: Utc::now(),
912 }),
913 )
914 .expect("configured route");
915 assert_eq!(configured.context_window.tokens, 1_048_576);
916 assert_eq!(
917 configured.context_window.source,
918 ContextWindowSource::Configured
919 );
920
921 let reported = resolve_route_candidate_with_context_metadata(
922 ApiProvider::Moonshot,
923 Some("k3"),
924 None,
925 base.clone(),
926 None,
927 Some(ProviderReportedKimiCodeContext {
928 context_tokens: 1_048_576,
929 observed_at: Utc::now(),
930 }),
931 )
932 .expect("fresh documented provider metadata");
933 assert_eq!(reported.context_window.tokens, 1_048_576);
934 assert_eq!(
935 reported.context_window.source,
936 ContextWindowSource::ProviderReported
937 );
938
939 let stale = resolve_route_candidate_with_context_metadata(
940 ApiProvider::Moonshot,
941 Some("k3"),
942 None,
943 base,
944 None,
945 Some(ProviderReportedKimiCodeContext {
946 context_tokens: 1_048_576,
947 observed_at: Utc::now() - Duration::hours(25),
948 }),
949 )
950 .expect("stale metadata falls back safely");
951 assert_eq!(
952 stale.context_window.source,
953 ContextWindowSource::StaticKimiCodeSafeFloor
954 );
955
956 let generic_err = resolve_route_candidate_with_context_metadata(
957 ApiProvider::Moonshot,
958 Some("k3"),
959 None,
960 Some(crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string()),
961 None,
962 Some(ProviderReportedKimiCodeContext {
963 context_tokens: 1_048_576,
964 observed_at: Utc::now(),
965 }),
966 )
967 .expect_err("bare k3 is rejected on the direct Moonshot endpoint (#4687)");
968 assert!(
969 generic_err.contains("kimi-k3"),
970 "error should guide the user to kimi-k3: {generic_err}"
971 );
972 }
973
974 #[test]
975 fn kimi_code_k3_context_override_wins_over_conservative_baseline() {
976 let candidate = resolve_route_candidate(
977 ApiProvider::Moonshot,
978 Some("k3"),
979 None,
980 Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
981 Some(1_048_576),
982 )
983 .expect("Kimi Code K3 route");
984
985 assert_eq!(
986 candidate.wire_model_id().as_str(),
987 crate::config::KIMI_CODE_K3_MODEL,
988 "the 1M entitlement changes limits, never the provider wire id"
989 );
990 assert!(crate::config::is_exact_kimi_code_k3_route(
991 ApiProvider::Moonshot,
992 &candidate.endpoint().base_url,
993 candidate.wire_model_id().as_str(),
994 ));
995 assert_eq!(candidate.limits().context_tokens, Some(1_048_576));
996 }
997
998 #[test]
999 fn kimi_code_rejects_claude_only_k3_1m_alias_for_selected_and_saved_models() {
1000 for (selected, saved) in [(Some("k3[1m]"), None), (None, Some("k3[1m]"))] {
1001 let error = resolve_route_candidate(
1002 ApiProvider::Moonshot,
1003 selected,
1004 saved,
1005 Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
1006 None,
1007 )
1008 .expect_err("Claude Code's context hint is not a Kimi Code API model id");
1009
1010 assert!(error.contains("model = \"k3\""), "{error}");
1011 assert!(error.contains("context_window = 1048576"), "{error}");
1012 assert!(error.contains("plan includes 1M context"), "{error}");
1013 assert!(error.contains("262144 safe default"), "{error}");
1014 }
1015 }
1016
1017 #[test]
1018 fn k3_route_rejects_cross_paired_model_ids_and_allows_canonical_pairs() {
1019 use crate::config::{
1020 DEFAULT_KIMI_CODE_BASE_URL, DEFAULT_MOONSHOT_BASE_URL, KIMI_CODE_K3_MODEL,
1021 MOONSHOT_KIMI_K3_MODEL, moonshot_k3_route_display_name,
1022 validate_kimi_code_api_model_id,
1023 };
1024
1025 // Canonical pairs succeed.
1026 validate_kimi_code_api_model_id(
1027 ApiProvider::Moonshot,
1028 DEFAULT_KIMI_CODE_BASE_URL,
1029 KIMI_CODE_K3_MODEL,
1030 )
1031 .expect("kimi code + k3");
1032 validate_kimi_code_api_model_id(
1033 ApiProvider::Moonshot,
1034 DEFAULT_MOONSHOT_BASE_URL,
1035 MOONSHOT_KIMI_K3_MODEL,
1036 )
1037 .expect("direct + kimi-k3");
1038
1039 // Trailing slash normalization still enforces.
1040 let err = validate_kimi_code_api_model_id(
1041 ApiProvider::Moonshot,
1042 "https://api.kimi.com/coding/v1/",
1043 "kimi-k3",
1044 )
1045 .expect_err("kimi code + kimi-k3");
1046 assert!(err.contains("k3"), "{err}");
1047 assert!(err.contains("kimi-k3"), "{err}");
1048
1049 let err = validate_kimi_code_api_model_id(
1050 ApiProvider::Moonshot,
1051 "https://api.moonshot.ai/v1/",
1052 "k3",
1053 )
1054 .expect_err("direct + k3");
1055 assert!(err.contains("kimi-k3"), "{err}");
1056
1057 // Custom gateway is not rejected for either model id.
1058 validate_kimi_code_api_model_id(
1059 ApiProvider::Moonshot,
1060 "https://gateway.example.com/v1",
1061 "k3",
1062 )
1063 .expect("custom + k3");
1064 validate_kimi_code_api_model_id(
1065 ApiProvider::Moonshot,
1066 "https://gateway.example.com/v1",
1067 "kimi-k3",
1068 )
1069 .expect("custom + kimi-k3");
1070
1071 // Runtime resolve fails closed the same way.
1072 let err = resolve_route_candidate(
1073 ApiProvider::Moonshot,
1074 Some("kimi-k3"),
1075 None,
1076 Some(DEFAULT_KIMI_CODE_BASE_URL.to_string()),
1077 None,
1078 )
1079 .expect_err("resolve kimi code + kimi-k3");
1080 assert!(err.contains("k3"), "{err}");
1081
1082 let err = resolve_route_candidate(
1083 ApiProvider::Moonshot,
1084 Some("k3"),
1085 None,
1086 Some(DEFAULT_MOONSHOT_BASE_URL.to_string()),
1087 None,
1088 )
1089 .expect_err("resolve direct + k3");
1090 assert!(err.contains("kimi-k3"), "{err}");
1091
1092 assert_eq!(
1093 moonshot_k3_route_display_name(DEFAULT_KIMI_CODE_BASE_URL, "k3"),
1094 Some("Kimi Code membership / k3")
1095 );
1096 assert_eq!(
1097 moonshot_k3_route_display_name(DEFAULT_MOONSHOT_BASE_URL, "kimi-k3"),
1098 Some("Moonshot direct / kimi-k3")
1099 );
1100 }
1101
1102 #[test]
1103 fn kimi_code_k3_baseline_does_not_leak_to_other_moonshot_routes() {
1104 let kimi_code_endpoint = Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string());
1105 let direct_moonshot = resolve_route_candidate(
1106 ApiProvider::Moonshot,
1107 Some(crate::config::MOONSHOT_KIMI_K3_MODEL),
1108 None,
1109 Some(crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string()),
1110 None,
1111 )
1112 .expect("direct Moonshot K3 route");
1113 assert_eq!(direct_moonshot.limits().context_tokens, Some(1_048_576));
1114
1115 // Bare k3 on the direct platform endpoint is fail-closed (#4687).
1116 let generic_err = resolve_route_candidate(
1117 ApiProvider::Moonshot,
1118 Some("k3"),
1119 None,
1120 Some(crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string()),
1121 None,
1122 )
1123 .expect_err("bare k3 on direct Moonshot must fail closed");
1124 assert!(generic_err.contains("kimi-k3"), "{generic_err}");
1125
1126 // A non-K3 direct model must not inherit the Kimi Code 262k floor.
1127 let generic_moonshot = resolve_route_candidate(
1128 ApiProvider::Moonshot,
1129 Some("moonshot-v1-128k"),
1130 None,
1131 Some(crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string()),
1132 None,
1133 )
1134 .expect("generic Moonshot route");
1135 assert_ne!(generic_moonshot.limits().context_tokens, Some(262_144));
1136
1137 let kimi_code_default = resolve_route_candidate(
1138 ApiProvider::Moonshot,
1139 Some(crate::config::DEFAULT_KIMI_CODE_MODEL),
1140 None,
1141 kimi_code_endpoint,
1142 None,
1143 )
1144 .expect("Kimi Code default route");
1145 assert_ne!(kimi_code_default.limits().context_tokens, Some(262_144));
1146 }
1147
1148 #[test]
1149 fn runtime_route_without_model_uses_target_provider_default() {
1150 let config = Config {
1151 provider: Some("openrouter".to_string()),
1152 providers: Some(ProvidersConfig {
1153 openrouter: ProviderConfig {
1154 model: Some("deepseek/deepseek-v4-pro".to_string()),
1155 ..Default::default()
1156 },
1157 ..Default::default()
1158 }),
1159 ..Default::default()
1160 };
1161
1162 let route = resolve_runtime_route(&config, ApiProvider::Zai, None)
1163 .expect("target provider default should resolve");
1164
1165 assert_eq!(route.model, DEFAULT_ZAI_MODEL);
1166 assert_eq!(route.config.provider.as_deref(), Some("zai"));
1167 assert_eq!(
1168 route
1169 .config
1170 .providers
1171 .as_ref()
1172 .and_then(|providers| providers.zai.model.as_deref()),
1173 Some(DEFAULT_ZAI_MODEL)
1174 );
1175 assert_eq!(
1176 route
1177 .config
1178 .providers
1179 .as_ref()
1180 .and_then(|providers| providers.openrouter.model.as_deref()),
1181 Some("deepseek/deepseek-v4-pro")
1182 );
1183 }
1184
1185 #[test]
1186 fn runtime_route_rejects_foreign_direct_model_before_config_snapshot() {
1187 let config = Config {
1188 provider: Some("deepseek".to_string()),
1189 providers: Some(ProvidersConfig {
1190 deepseek: ProviderConfig {
1191 model: Some(DEFAULT_TEXT_MODEL.to_string()),
1192 ..Default::default()
1193 },
1194 ..Default::default()
1195 }),
1196 ..Default::default()
1197 };
1198
1199 let err = resolve_runtime_route(&config, ApiProvider::Zai, Some("deepseek-v4-pro"))
1200 .expect_err("foreign direct-provider model should reject");
1201
1202 assert!(err.contains("not served by direct provider zai"));
1203 assert_eq!(config.provider.as_deref(), Some("deepseek"));
1204 assert_eq!(
1205 config
1206 .providers
1207 .as_ref()
1208 .and_then(|providers| providers.zai.model.as_deref()),
1209 None
1210 );
1211 }
1212
1213 #[test]
1214 fn unpinned_spawn_route_is_conservative_and_returns_exact_wire_id() {
1215 let err = resolve_unpinned_model_candidate(
1216 ApiProvider::Moonshot,
1217 "deepseek-v4-pro",
1218 ApiProvider::Moonshot.default_base_url(),
1219 )
1220 .expect_err("official Moonshot cannot inherit a DeepSeek-owned pin");
1221 assert!(err.contains("deepseek-v4-pro"), "names model: {err}");
1222 assert!(err.contains("moonshot"), "names route: {err}");
1223 assert!(err.contains("deepseek"), "names owner: {err}");
1224
1225 let openrouter = resolve_unpinned_model_candidate(
1226 ApiProvider::Openrouter,
1227 "deepseek-v4-pro",
1228 ApiProvider::Openrouter.default_base_url(),
1229 )
1230 .expect("aggregator alias should resolve offline");
1231 assert_eq!(
1232 openrouter.wire_model_id().as_str(),
1233 crate::config::DEFAULT_OPENROUTER_MODEL,
1234 );
1235
1236 let vllm = resolve_unpinned_model_candidate(
1237 ApiProvider::Vllm,
1238 "deepseek-v4-pro",
1239 ApiProvider::Vllm.default_base_url(),
1240 )
1241 .expect("local runtime model ids stay provider-authoritative");
1242 assert!(!vllm.wire_model_id().as_str().is_empty());
1243
1244 let custom = resolve_unpinned_model_candidate(
1245 ApiProvider::Moonshot,
1246 "deepseek-v4-pro",
1247 "https://gateway.example.test/v1",
1248 )
1249 .expect("a custom endpoint owns its model namespace");
1250 assert_eq!(custom.wire_model_id().as_str(), "deepseek-v4-pro");
1251 }
1252
1253 fn custom_config(base_url: &str, model: &str) -> Config {
1254 let mut custom = std::collections::HashMap::new();
1255 custom.insert(
1256 "my_thing".to_string(),
1257 ProviderConfig {
1258 kind: Some("openai-compatible".to_string()),
1259 base_url: Some(base_url.to_string()),
1260 model: Some(model.to_string()),
1261 api_key_env: Some("EXAMPLE_API_KEY".to_string()),
1262 ..Default::default()
1263 },
1264 );
1265 Config {
1266 provider: Some("my_thing".to_string()),
1267 providers: Some(ProvidersConfig {
1268 custom,
1269 ..Default::default()
1270 }),
1271 ..Default::default()
1272 }
1273 }
1274
1275 #[test]
1276 fn custom_provider_resolves_to_custom_endpoint_and_verbatim_model() {
1277 use codewhale_config::route::RequestProtocol;
1278
1279 let config = custom_config("https://api.example.com/v1", "vendor/custom-model-v1");
1280 let route = resolve_runtime_route(&config, ApiProvider::Custom, None)
1281 .expect("custom provider should resolve");
1282
1283 // Endpoint + model come from the named table; the prefixed model id is
1284 // preserved verbatim as the wire id (no provider-prefix sniffing).
1285 assert_eq!(
1286 route.candidate.endpoint().base_url,
1287 "https://api.example.com/v1"
1288 );
1289 assert_eq!(
1290 route.candidate.wire_model_id().as_str(),
1291 "vendor/custom-model-v1"
1292 );
1293 assert_eq!(route.model, "vendor/custom-model-v1");
1294 assert_eq!(route.candidate.protocol(), RequestProtocol::ChatCompletions);
1295 // HTTPS endpoint: route is valid with no insecure-http advisory.
1296 assert!(route.candidate.validation().ok);
1297 assert!(route.candidate.validation().messages.is_empty());
1298 // The selected provider name is preserved (not overwritten with "custom").
1299 assert_eq!(route.config.provider.as_deref(), Some("my_thing"));
1300 }
1301
1302 #[test]
1303 fn custom_provider_context_window_overrides_unknown_route_limit() {
1304 let mut custom = std::collections::HashMap::new();
1305 custom.insert(
1306 "dashscope".to_string(),
1307 ProviderConfig {
1308 kind: Some("openai-compatible".to_string()),
1309 base_url: Some("https://dashscope.example.com/compatible-mode/v1".to_string()),
1310 model: Some("qwen3.7".to_string()),
1311 context_window: Some(1_000_000),
1312 api_key_env: Some("DASHSCOPE_API_KEY".to_string()),
1313 ..Default::default()
1314 },
1315 );
1316 let config = Config {
1317 provider: Some("dashscope".to_string()),
1318 providers: Some(ProvidersConfig {
1319 custom,
1320 ..Default::default()
1321 }),
1322 ..Config::default()
1323 };
1324
1325 let route = resolve_runtime_route(&config, ApiProvider::Custom, None)
1326 .expect("custom route should resolve");
1327
1328 assert_eq!(route.model, "qwen3.7");
1329 assert_eq!(route.candidate.limits().context_tokens, Some(1_000_000));
1330 }
1331
1332 #[test]
1333 fn custom_provider_http_non_loopback_fires_insecure_advisory() {
1334 let config = custom_config("http://gpu.internal.example:8000/v1", "custom-model-v1");
1335 let route = resolve_runtime_route(&config, ApiProvider::Custom, None)
1336 .expect("custom http provider should resolve");
1337
1338 // Advisory only: the route still validates (ok == true) but warns that
1339 // credentials would be sent in plaintext over a non-loopback http URL.
1340 assert!(route.candidate.validation().ok);
1341 assert!(
1342 route
1343 .candidate
1344 .validation()
1345 .messages
1346 .iter()
1347 .any(|message| message.contains("insecure http")),
1348 "expected insecure-http advisory, got {:?}",
1349 route.candidate.validation().messages
1350 );
1351 assert_eq!(
1352 route.candidate.endpoint().base_url,
1353 "http://gpu.internal.example:8000/v1"
1354 );
1355 }
1356 }
1357
1357 lines RUST