diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 64e53f2dea..187380be47 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -1020,13 +1020,23 @@ impl From for ModelsFetchError { // fields that the existing typed provider parsers reject. #[derive(Deserialize)] struct ModelsPage<'a> { - #[serde(borrow, alias = "models")] - data: &'a serde_json::value::RawValue, + #[serde(borrow)] + data: Option<&'a serde_json::value::RawValue>, + #[serde(borrow)] + models: Option<&'a serde_json::value::RawValue>, #[serde(default)] has_more: bool, last_id: Option, } +impl<'a> ModelsPage<'a> { + fn rows(&self) -> Result<&'a serde_json::value::RawValue, CatalogRefreshError> { + self.data + .or(self.models) + .ok_or(CatalogRefreshError::InvalidResponse) + } +} + struct BoundedModelsRows(usize); impl<'de> serde::de::Visitor<'de> for BoundedModelsRows { @@ -1102,7 +1112,7 @@ where let page: ModelsPage<'_> = serde_json::from_str(&body).map_err(|_| CatalogRefreshError::InvalidResponse)?; let page_rows = BoundedModelsRows(limits.rows.saturating_sub(rows.len())) - .deserialize(&mut serde_json::Deserializer::from_str(page.data.get())) + .deserialize(&mut serde_json::Deserializer::from_str(page.rows()?.get())) .map_err(|_| CatalogRefreshError::InvalidResponse)?; rows.extend(page_rows); if !page.has_more { @@ -1850,6 +1860,7 @@ impl CodewhaleClient { auth_disabled, force_http1, config, + AuthHeaderSurface::Inference, )?; let http_client = if plugin_provider.is_some() || api_key_source == "Claude sign-in" { http_client.redirect(reqwest::redirect::Policy::none()) @@ -1866,6 +1877,7 @@ impl CodewhaleClient { auth_disabled, force_http1, config, + AuthHeaderSurface::Listing, )? .redirect(reqwest::redirect::Policy::none()) .build()?; @@ -1881,6 +1893,7 @@ impl CodewhaleClient { auth_disabled, true, config, + AuthHeaderSurface::Inference, )?; let http1_client = if plugin_provider.is_some() || api_key_source == "Claude sign-in" { http1_client.redirect(reqwest::redirect::Policy::none()) @@ -2316,6 +2329,7 @@ impl CodewhaleClient { false, false, &Config::default(), + AuthHeaderSurface::Inference, )? .build() .map_err(Into::into) @@ -2330,6 +2344,7 @@ impl CodewhaleClient { auth_disabled: bool, force_http1: bool, config: &Config, + surface: AuthHeaderSurface, ) -> Result { let headers = build_default_headers( api_key, @@ -2338,6 +2353,7 @@ impl CodewhaleClient { base_url, wire_format, auth_disabled, + surface, )?; let mut builder = crate::tls::reqwest_client_builder() .default_headers(headers) @@ -2379,6 +2395,7 @@ impl CodewhaleClient { crate::config::DEFAULT_DEEPSEEK_BASE_URL, WireFormat::ChatCompletions, false, + AuthHeaderSurface::Inference, ) } @@ -2396,6 +2413,7 @@ impl CodewhaleClient { base_url, provider_default_wire_format(api_provider), false, + AuthHeaderSurface::Inference, ) } @@ -2413,10 +2431,29 @@ impl CodewhaleClient { base_url, provider_default_wire_format(api_provider), true, + AuthHeaderSurface::Inference, ) } } +/// Which request surface a client's default auth header is built for. +/// +/// Anthropic's `x-api-key` convention belongs to Anthropic's own service, not +/// to the Messages wire format by itself. A custom table speaks Messages +/// because its host does, but it is still reached through the +/// OpenAI-compatible config surface, and its listing follows the OpenAI +/// convention: a relay can answer `/v1/messages` on either header while +/// answering `/v1/models` only on `Authorization: Bearer`. Keeping the two +/// surfaces apart stops a listing from inheriting a header chosen for the +/// Messages endpoint. +#[derive(Clone, Copy, PartialEq, Eq)] +enum AuthHeaderSurface { + /// Chat, Messages and Responses requests: the wire dialect decides. + Inference, + /// The provider's own `/models` listing. + Listing, +} + fn build_default_headers( api_key: &str, extra_headers: &HashMap, @@ -2424,6 +2461,7 @@ fn build_default_headers( base_url: &str, wire_format: WireFormat, auth_disabled: bool, + surface: AuthHeaderSurface, ) -> Result { let mut headers = HeaderMap::new(); headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); @@ -2442,10 +2480,17 @@ fn build_default_headers( HeaderValue::from_static("2023-06-01"), ); } + // A custom host's listing never inherits the Messages choice. Its + // `kind = "openai-compatible"` surface means Bearer, and a relay that + // accepts Messages on either header may still reject `x-api-key` on + // `/v1/models` with `401 missing_api_key`. + let listing_on_custom_host = + surface == AuthHeaderSurface::Listing && api_provider == ProviderKind::Custom; let auth_header_name = if auth_disabled { None } else if !api_key.is_empty() && uses_anthropic_messages + && !listing_on_custom_host && !matches!( api_provider, ProviderKind::Openmodel | ProviderKind::Codewhale @@ -2713,6 +2758,7 @@ pub async fn verify_provider_api_key( base_url, provider_default_wire_format(provider), false, + AuthHeaderSurface::Listing, ) .map_err(|err| format!("failed to build auth headers: {err:#}"))?; let client = crate::tls::reqwest_client_builder() @@ -3634,7 +3680,10 @@ impl CodewhaleClient { // at fetch time) and every other custom host. A private route is // the only place its roster exists, and a failed probe stays // non-fatal. - if !crate::provider_catalog_live::provider_owns_live_catalog(provider) { + if !crate::provider_catalog_live::provider_owns_live_catalog( + provider, + &config.active_route_base_url(), + ) { return; } @@ -3713,6 +3762,47 @@ impl CodewhaleClient { } } + /// Best-effort refresh of every configured route that owns its own + /// `/v1/models` roster, so opening `/model` finds each relay already probed + /// instead of a route still waiting for a provider switch. + /// + /// [`Self::spawn_active_provider_catalog_refresh`] keeps its own faster path + /// for the active route (it also publishes that route's persisted scope + /// before the probe). This is the wider sweep behind it, for the relays a + /// session is *not* currently on — the picker lists every configured route, + /// so every one of them has to be able to answer, not just the active one. + /// + /// Each route is probed through the same `update_provider_catalog` the + /// explicit `models --update` command uses, so the TTL, the typed receipt, + /// the credential-change guard and the superseded-refresh ticket are shared + /// rather than reimplemented. Failures stay non-fatal: a route that cannot + /// answer keeps its persisted and configured rows. + pub fn spawn_configured_provider_catalog_refresh(config: &Config) { + #[cfg(test)] + let _ = config; + #[cfg(not(test))] + { + let active = config.active_provider_identity().ok(); + for identity in crate::provider_lake::configured_providers(config) { + if active.as_ref() == Some(&identity) { + continue; + } + let base_url = config.base_url_for_route(&identity); + if !crate::provider_catalog_live::provider_owns_live_catalog( + identity.provider, + &base_url, + ) { + continue; + } + let route_config = config.clone(); + tokio::spawn(async move { + let _ = crate::provider_lake::update_provider_catalog(&route_config, &identity) + .await; + }); + } + } + } + /// Generate speech with Xiaomi MiMo TTS models. /// /// The spoken text is placed in an `assistant` message because Xiaomi diff --git a/crates/tui/src/client/catalog_tests.rs b/crates/tui/src/client/catalog_tests.rs index 5f8e25dda6..4cae36bc82 100644 --- a/crates/tui/src/client/catalog_tests.rs +++ b/crates/tui/src/client/catalog_tests.rs @@ -1118,3 +1118,135 @@ async fn unpaginated_custom_identities_keep_exact_identity_and_endpoint_ownershi } assert_eq!(cache.entries.len(), 3); } + +/// `models` is the wider spelling some relays echo beside the OpenAI `data` +/// roster, and it is often a stub. It is not a second name for `data`: when it +/// was declared as one (`alias = "models"`), a body carrying both keys failed +/// the whole probe with `duplicate field`, so a route whose roster was present +/// and well-formed reported `invalid_response` and listed nothing. `data` is +/// the roster this envelope reads first; `models` stays for hosts that serve +/// only that spelling. +#[tokio::test] +async fn roster_envelope_carrying_data_and_models_reads_the_data_roster() { + let server = MockServer::start().await; + mount_models_json( + &server, + 200, + json!({ + "object": "list", + "data": [{"id": "relay-real-a"}, {"id": "relay-real-b"}], + "models": [{"id": "wider-spelling-stub"}], + "total_count": 2, + }), + ) + .await; + let client = custom_mock_client_for_identity(&server, "relay-private"); + let delta = client + .fetch_catalog_delta() + .await + .expect("both spellings present is a complete roster, not an invalid response"); + assert_eq!( + delta + .offerings + .iter() + .map(|row| row.wire_model_id.as_str()) + .collect::>(), + ["relay-real-a", "relay-real-b"] + ); +} + +/// An empty `data` roster beside a populated `models` array is an empty +/// listing, not a reason to read the other key. A present `data` is the +/// OpenAI-compatible roster; only a host that serves no `data` at all falls +/// back to the wider spelling. +#[tokio::test] +async fn roster_envelope_with_empty_data_is_an_empty_listing_not_a_models_fallback() { + let server = MockServer::start().await; + mount_models_json( + &server, + 200, + json!({ + "object": "list", + "data": [], + "models": [{"id": "wider-spelling-stub"}], + }), + ) + .await; + let client = custom_mock_client_for_identity(&server, "relay-private"); + assert_eq!( + client + .fetch_catalog_delta() + .await + .expect_err("an empty data roster is an empty listing"), + CatalogRefreshError::EmptyList + ); +} + +/// A custom table is reached through the OpenAI-compatible surface, so its +/// `/models` listing follows the OpenAI auth convention even when the table +/// declares `wire = "anthropic"` for Messages. A relay can answer +/// `/v1/messages` on either header and still reject `x-api-key` on +/// `/v1/models` with `401 missing_api_key`, so the listing must not inherit +/// the Messages choice. +#[tokio::test] +async fn custom_messages_wire_listing_authenticates_with_bearer() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let server = MockServer::start().await; + mount_models_json( + &server, + 200, + json!({"object": "list", "data": [{"id": "relay-model"}]}), + ) + .await; + let mut providers = ProvidersConfig::default(); + providers.custom.insert( + "relay-messages-wire".to_string(), + ProviderConfig { + kind: Some("openai-compatible".to_string()), + api_key: Some(KEY.to_string()), + base_url: Some(format!("{}/v1", server.uri())), + model: Some("relay-model".to_string()), + wire: Some("anthropic".to_string()), + ..ProviderConfig::default() + }, + ); + let client = CodewhaleClient::new(&Config { + provider: Some("relay-messages-wire".to_string()), + providers: Some(providers), + ..Config::default() + }) + .expect("custom Messages-wire relay client"); + + let delta = client + .fetch_catalog_delta() + .await + .expect("the relay's listing answers on its own terms"); + assert_eq!( + delta + .offerings + .iter() + .map(|row| row.wire_model_id.as_str()) + .collect::>(), + ["relay-model"] + ); + + let request = server + .received_requests() + .await + .expect("requests") + .pop() + .expect("one listing request"); + let expected = format!("Bearer {KEY}"); + assert_eq!( + request + .headers + .get("authorization") + .and_then(|value| value.to_str().ok()), + Some(expected.as_str()), + "the listing follows the OpenAI auth convention" + ); + assert!( + request.headers.get("x-api-key").is_none(), + "the listing must not inherit the Messages x-api-key convention" + ); +} diff --git a/crates/tui/src/client/system_one.rs b/crates/tui/src/client/system_one.rs index 23e6e85d39..3e7e34329d 100644 --- a/crates/tui/src/client/system_one.rs +++ b/crates/tui/src/client/system_one.rs @@ -288,6 +288,7 @@ impl CodewhaleClient { false, client.force_http1, config, + AuthHeaderSurface::Inference, )? .build()?; client.base_url = base_url; diff --git a/crates/tui/src/client/test_cases_01.rs b/crates/tui/src/client/test_cases_01.rs index 263372db15..4d0dd4f744 100644 --- a/crates/tui/src/client/test_cases_01.rs +++ b/crates/tui/src/client/test_cases_01.rs @@ -35,6 +35,7 @@ "https://openrouter.ai/api/v1", WireFormat::ChatCompletions, false, + AuthHeaderSurface::Inference, ) .expect("headers"); assert_eq!( @@ -78,6 +79,7 @@ "https://example.invalid/v1", WireFormat::ChatCompletions, false, + AuthHeaderSurface::Inference, ) .expect("headers"); assert!( @@ -95,6 +97,7 @@ "https://openrouter.ai/api/v1", WireFormat::ChatCompletions, false, + AuthHeaderSurface::Inference, ) .expect("headers"); // A user title on the legacy header alone must follow onto the @@ -116,6 +119,7 @@ "https://openrouter.ai/api/v1", WireFormat::ChatCompletions, false, + AuthHeaderSurface::Inference, ) .expect("headers"); for name in ["x-title", "x-openrouter-title"] { diff --git a/crates/tui/src/client/test_cases_04.rs b/crates/tui/src/client/test_cases_04.rs index dbf7fcf3c7..fadcd5ccd9 100644 --- a/crates/tui/src/client/test_cases_04.rs +++ b/crates/tui/src/client/test_cases_04.rs @@ -370,6 +370,7 @@ "https://api.codewhale.net/v1", wire, false, + AuthHeaderSurface::Inference, ) .expect("headers"); assert_eq!( diff --git a/crates/tui/src/client/test_cases_06.rs b/crates/tui/src/client/test_cases_06.rs index b6cd966921..0e7a50e8a4 100644 --- a/crates/tui/src/client/test_cases_06.rs +++ b/crates/tui/src/client/test_cases_06.rs @@ -726,6 +726,7 @@ true, false, &config, + AuthHeaderSurface::Inference, ) .unwrap() .no_proxy() diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 0bf5b2ec95..657ab1e0c4 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -13503,6 +13503,10 @@ async fn run_interactive_prepared( // alongside the Models.dev snapshot. Currently active for TelecomJS, whose // model list is not covered by the Models.dev catalog. crate::client::CodewhaleClient::spawn_active_provider_catalog_refresh(config); + // The picker lists every configured route, so the relays this session is + // not currently on are probed too; otherwise a relay's roster stays empty + // until the user switches to it. + crate::client::CodewhaleClient::spawn_configured_provider_catalog_refresh(config); // Boot janitors — snapshot prune (7-day default), spillover prune // (#422), and managed-session cleanup (v0.8.44) — are best-effort disk diff --git a/crates/tui/src/provider_catalog_live.rs b/crates/tui/src/provider_catalog_live.rs index 6f0ad798ec..b71a6ea5dc 100644 --- a/crates/tui/src/provider_catalog_live.rs +++ b/crates/tui/src/provider_catalog_live.rs @@ -514,20 +514,37 @@ fn storage_provider(kind: ProviderKind, identity: &str) -> String { /// Providers whose model list is owned by their own `/v1/models` roster /// rather than the cross-provider Models.dev snapshot: the named live -/// gateways, plus custom hosts whose private roster no snapshot can serve -/// (#6289 widened). The active-provider refresh and the picker's freshness -/// receipt both gate on this one predicate, so they cannot drift apart. +/// gateways, custom hosts whose private roster no snapshot can serve, and any +/// builtin name pointed at an endpoint outside its official family (#6289 +/// widened). +/// +/// `base_url` is what separates the last two: `openai` against +/// `api.openai.com` is the reviewed snapshot row, while `openai` against a +/// relay is a private roster only its own listing can describe. A vendor-named +/// table that actually relays a private host is as unserved by the snapshot as +/// a `[providers.]` table is. The active-provider refresh and the +/// picker's freshness receipt both gate on this one predicate, so they cannot +/// drift apart. #[cfg(test)] #[test] fn orcarouter_and_existing_custom_routes_own_live_catalogs() { - assert!(provider_owns_live_catalog(ProviderKind::Orcarouter)); - assert!(provider_owns_live_catalog(ProviderKind::Custom)); - assert!(provider_owns_live_catalog(ProviderKind::Openrouter)); - assert!(provider_owns_live_catalog(ProviderKind::Ollama)); - assert!(!provider_owns_live_catalog(ProviderKind::Openai)); + assert!(provider_owns_live_catalog(ProviderKind::Orcarouter, "")); + assert!(provider_owns_live_catalog(ProviderKind::Custom, "")); + assert!(provider_owns_live_catalog(ProviderKind::Openrouter, "")); + assert!(provider_owns_live_catalog(ProviderKind::Ollama, "")); + // A builtin name on its own official endpoint keeps the snapshot row. + assert!(!provider_owns_live_catalog( + ProviderKind::Openai, + "https://api.openai.com/v1" + )); + // The same name on a private relay owns its roster. + assert!(provider_owns_live_catalog( + ProviderKind::Openai, + "https://relay.example.test/v1" + )); } -pub(crate) fn provider_owns_live_catalog(provider: ProviderKind) -> bool { +pub(crate) fn provider_owns_live_catalog(provider: ProviderKind, base_url: &str) -> bool { matches!( provider, ProviderKind::Openrouter @@ -539,6 +556,7 @@ pub(crate) fn provider_owns_live_catalog(provider: ProviderKind) -> bool { | ProviderKind::Codewhale | ProviderKind::Ollama ) || provider == ProviderKind::Custom + || codewhale_config::provider_preserves_custom_base_url_model(provider, base_url) } /// Whether a catalog scope holds an account-scoped roster that must never be diff --git a/crates/tui/src/tui/model_picker.rs b/crates/tui/src/tui/model_picker.rs index 49d0d28a01..9f1aef9262 100644 --- a/crates/tui/src/tui/model_picker.rs +++ b/crates/tui/src/tui/model_picker.rs @@ -358,6 +358,15 @@ enum DefaultSection { /// Most rows the default view's recent section shows. const RECENT_ROW_LIMIT: usize = 8; +/// Most rows one custom relay's own roster contributes to the default view. +/// +/// A relay has no bundled snapshot to browse: its roster exists only in the +/// listing that route itself answered. Hiding all of it but the configured +/// default is what made `/model` show one row for a route the provider panel +/// had just listed a dozen for. The bound keeps a gateway fronting a thousand +/// models from taking the view over. +const CUSTOM_ROSTER_ROWS_IN_DEFAULT_VIEW: usize = 20; + /// A fresh live roster for the row's exact route omits its id. Only a fresh /// roster may say so: a stale one lags the provider (and would condemn ids it /// simply never saw). The row stays selectable and nothing is rewritten. @@ -2357,6 +2366,29 @@ fn assign_default_sections( row.default_rank = Some((DefaultSection::ProviderDefault, usize::MAX)); } } + // A custom relay's roster is the reason the route was configured, and the + // default view used to hide every one of its rows but the provider default + // -- the panel listed the route's dozen models and `/model` showed one. + // Surface the route's own live rows, bounded per route so a large gateway + // cannot take the view over. + let mut shown_per_route: BTreeMap = BTreeMap::new(); + for row in rows.iter_mut() { + if row.default_rank.is_some() + || row.provider != Some(ProviderKind::Custom) + || !matches!(row.metadata.source, Some(CatalogSource::Live { .. })) + { + continue; + } + let Some(identity) = row_provider_identity(row).map(str::to_string) else { + continue; + }; + let shown = shown_per_route.entry(identity).or_default(); + if *shown >= CUSTOM_ROSTER_ROWS_IN_DEFAULT_VIEW { + continue; + } + *shown += 1; + row.default_rank = Some((DefaultSection::ProviderDefault, usize::MAX)); + } } /// Model ids this exact route was used with recently, so a model picked @@ -2599,14 +2631,14 @@ fn provider_catalog_receipt_for_route( .ok()?; (admitted.provider == provider).then_some(())?; let identity = admitted.key.as_str(); + let base_url = config.base_url_for_route(&admitted); // One predicate with the active-provider refresh: any route whose roster - // is probed (named live gateways and custom hosts) reports its freshness - // here, so a widened probe cannot leave this receipt behind. - if !crate::provider_catalog_live::provider_owns_live_catalog(provider) { + // is probed (named live gateways, custom hosts, and builtin names on a + // private endpoint) reports its freshness here, so a widened probe cannot + // leave this receipt behind. + if !crate::provider_catalog_live::provider_owns_live_catalog(provider, &base_url) { return None; } - - let base_url = config.base_url_for_route(&admitted); let endpoint_matches = match source { Some(CatalogSource::Live { base_url_fingerprint, @@ -6086,6 +6118,75 @@ model = "deepseek/deepseek-v4-flash" (config, app, home, workspace) } + /// A custom relay's roster exists only in the listing that route itself + /// answered, so nothing else can fill its rows in. Filtering every one of + /// them out of the default view left `/model` showing a single row for a + /// route the provider panel had just listed a dozen for. + #[test] + fn a_custom_relays_live_roster_reaches_the_default_view() { + let _env = crate::test_support::lock_test_env(); + let _live = crate::provider_lake::lock_live_snapshot(); + let (config, app, home, _workspace) = resumed_openrouter_session_with_named_custom_routes(); + let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path()); + crate::provider_catalog_live::reset_cache_for_test(); + crate::provider_lake::clear_live_snapshot(); + + // The listing the relay answered, recorded the way the probe records + // it. `command_code` is not the session's route, so the row can only + // reach the default view through the relay-roster rule. + let base_url = "https://command.example.test/v1"; + let fingerprint = codewhale_config::catalog::base_url_fingerprint(base_url); + let ticket = crate::provider_catalog_live::begin_refresh_for_identity( + ProviderKind::Custom, + "command_code", + base_url, + ); + let offering = |id: &str| codewhale_config::catalog::CatalogOffering { + provider: "command_code".to_string(), + wire_model_id: id.to_string(), + source: codewhale_config::catalog::CatalogSource::Live { + base_url_fingerprint: fingerprint.clone(), + fetched_at: 7, + }, + ..Default::default() + }; + let roster: Vec<_> = (0..CUSTOM_ROSTER_ROWS_IN_DEFAULT_VIEW + 5) + .map(|index| offering(&format!("relay-{index}"))) + .collect(); + crate::provider_catalog_live::record_success_if_current( + &ticket, + codewhale_config::catalog::ProviderCatalogDelta { + provider: "command_code".to_string(), + base_url_fingerprint: fingerprint, + fetched_at: 7, + offerings: roster, + }, + ) + .expect("the relay listing is recorded"); + + let picker = ModelPickerView::new(&app, &config); + assert_eq!(picker.view, ModelListView::Configured); + let visible: Vec<_> = picker + .visible_model_rows() + .iter() + .filter(|row| { + row.provider == Some(ProviderKind::Custom) + && row_provider_identity(row) == Some("command_code") + && row.id.starts_with("relay-") + }) + .map(|row| row.id.clone()) + .collect(); + assert_eq!( + visible.len(), + CUSTOM_ROSTER_ROWS_IN_DEFAULT_VIEW, + "the relay's own roster shows in the default view, bounded per route: {visible:?}" + ); + assert!( + visible.contains(&"relay-0".to_string()), + "the first listed row is offered: {visible:?}" + ); + } + #[test] fn resumed_session_lists_every_configured_custom_route_by_identity() { let _env = crate::test_support::lock_test_env(); diff --git a/crates/tui/src/tui/provider_picker.rs b/crates/tui/src/tui/provider_picker.rs index 669cb22fc4..99d8d936e2 100644 --- a/crates/tui/src/tui/provider_picker.rs +++ b/crates/tui/src/tui/provider_picker.rs @@ -768,12 +768,12 @@ impl ProviderDashboardRow { runtime_status, is_active, ); - let available_model_count = catalog_model_count_for_provider(provider); - let catalog_status = if available_model_count == 0 { - ProviderCatalogStatus::DefaultOnly - } else { - ProviderCatalogStatus::Bundled - }; + // The provider-level snapshot (Models.dev plus bundled seeds) is one + // source; the exact route's own roster further down is another. Keep + // them separate so the status below can weigh both — a custom relay + // ships no bundled seeds, so this count alone says nothing about + // whether the route has a listing. + let bundled_model_count = catalog_model_count_for_provider(provider); let mut messages = Vec::new(); // Use the same route-effective resolver as the active runtime. In // particular, Kimi Code's bare K3 model has a conservative 262K @@ -890,9 +890,6 @@ impl ProviderDashboardRow { &credential_resolution, )); } - if catalog_status == ProviderCatalogStatus::DefaultOnly { - messages.push("catalog snapshot missing; using provider default".to_string()); - } let route_identity = route_identity_for_model(config, &identity, &default_route.logical_model); @@ -938,6 +935,19 @@ impl ProviderDashboardRow { &base_url, ) .len(); + // "Snapshot missing" means the row has nothing but its default model. + // A provider-level snapshot OR this route's own roster is enough to + // show more, so judge the two sources together: reading only the + // provider-level count told every custom relay its catalog was missing + // while its live listing was already rendered underneath. + let catalog_status = if bundled_model_count == 0 && available_model_count == 0 { + ProviderCatalogStatus::DefaultOnly + } else { + ProviderCatalogStatus::Bundled + }; + if catalog_status == ProviderCatalogStatus::DefaultOnly { + messages.push("catalog snapshot missing; using provider default".to_string()); + } // #5772: the status projection needs an ambient candidate path to // report `ambient_path_changed`, so resolving it for a provider the // user never consented to would derive (and then render) another CLI's