From 00410057f14be96eabf5c595185a62ce54d744b9 Mon Sep 17 00:00:00 2001 From: Shizuku <2163018547@qq.com> Date: Fri, 16 Oct 2026 12:21:40 +0800 Subject: [PATCH 1/6] fix(catalog): read a model list that carries both data and models A relay that returns the standard OpenAI `data` roster and also echoes a `models` array failed the whole page parse: `models` was declared as a serde alias of `data`, so a body carrying both keys was rejected as a duplicate field, and the probe reported InvalidResponse while the listing was sitting right there in the response. Enumerate `data` and `models` as separate optional fields and resolve the rows from `data` first: it is the roster an OpenAI-compatible host actually serves. `models` stays as the fallback for hosts that only use the wider spelling. --- crates/tui/src/client.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 64e53f2dea..f48b27f065 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 { From e150b932441df07db228396383957cd539dd50b6 Mon Sep 17 00:00:00 2001 From: Shizuku <2163018547@qq.com> Date: Fri, 16 Oct 2026 18:16:54 +0800 Subject: [PATCH 2/6] test(catalog): pin the envelope that carries both data and models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing in catalog_tests.rs fed a roster body carrying both keys, so the duplicate-field reject that 00410057f removes could return unnoticed. Both tests were run against the unfixed envelope first: they fail there with `InvalidResponse` — the same receipt the reported relay produced — and pass with the fix. The second also pins that a present-but-empty `data` stays an empty listing rather than falling back, while a host serving only `models` still resolves through it. That fallback is load-bearing: the ChatGPT roster uses `models` as its only key. --- crates/tui/src/client/catalog_tests.rs | 63 ++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/crates/tui/src/client/catalog_tests.rs b/crates/tui/src/client/catalog_tests.rs index 5f8e25dda6..0a3699a9c4 100644 --- a/crates/tui/src/client/catalog_tests.rs +++ b/crates/tui/src/client/catalog_tests.rs @@ -1118,3 +1118,66 @@ 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 + ); +} From 0dcee728661896d33739aa3cbf16894dbcb5199e Mon Sep 17 00:00:00 2001 From: Shizuku <2163018547@qq.com> Date: Sat, 17 Oct 2026 01:38:03 +0800 Subject: [PATCH 3/6] feat(catalog): probe every configured relay, not only the active one The picker lists every configured route, but only the active route was ever probed -- and the probe predicate could not see the endpoint at all. A builtin name pointed at a private relay (`[providers.openai]` against an Aliyun MaaS endpoint) read as official OpenAI, so it was never probed, and its picker row could only ever show what a previous explicit `models --update` had left behind. - `provider_owns_live_catalog` takes the route's `base_url` and reuses `provider_preserves_custom_base_url_model`, so a vendor-named table on a private endpoint owns its roster the same way a `[providers.]` table does. An official endpoint keeps the snapshot row it always had, which the predicate test pins on both sides. - `spawn_configured_provider_catalog_refresh` sweeps the remaining configured routes at startup. Each goes 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. A route whose roster is already fresh is skipped: this fills the relays a session has never listed, it does not re-fetch on every launch. --- crates/tui/src/client.rs | 46 ++++++++++++++++++++++++- crates/tui/src/lib.rs | 4 +++ crates/tui/src/provider_catalog_live.rs | 36 ++++++++++++++----- crates/tui/src/tui/model_picker.rs | 10 +++--- 4 files changed, 81 insertions(+), 15 deletions(-) diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index f48b27f065..41e27ea521 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -3644,7 +3644,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; } @@ -3723,6 +3726,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/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..0d9d8c8a01 100644 --- a/crates/tui/src/tui/model_picker.rs +++ b/crates/tui/src/tui/model_picker.rs @@ -2599,14 +2599,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, From e640483aaa030c49fc3f1f34c784b432210c825b Mon Sep 17 00:00:00 2001 From: Shizuku <2163018547@qq.com> Date: Sat, 17 Oct 2026 04:16:15 +0800 Subject: [PATCH 4/6] fix(auth): a custom relay's /models keeps the OpenAI auth convention `x-api-key` is Anthropic's own service convention, not a property of the Messages wire format. A custom table that declares `wire = "anthropic"` is still reached through the OpenAI-compatible surface, and a relay can accept `/v1/messages` on either header while rejecting `x-api-key` on `/v1/models` with `401 missing_api_key`. Its listing therefore reported `unauthorized` while the same endpoint answered `Authorization: Bearer` normally. `build_default_headers` now takes the surface it is building for. Inference keeps the wire-derived choice, so the Messages `x-api-key` default stays for Anthropic and the dual-wire vendors; Listing is the provider's own `/models` endpoint, where a custom host uses Bearer. The models client and the guided-setup key probe (`verify_provider_api_key`) build a Listing surface; the chat/Messages/Responses client, its HTTP/1.1 fallback twin, the TypeSafe decision client and the test header helpers build Inference. Measured against the reported relay: `GET /v1/models` answers 200 with `Authorization: Bearer` and 401 `missing_api_key` with `x-api-key`, while `POST /v1/messages` accepts either. --- crates/tui/src/client.rs | 36 ++++++++++++++ crates/tui/src/client/catalog_tests.rs | 69 ++++++++++++++++++++++++++ crates/tui/src/client/system_one.rs | 1 + crates/tui/src/client/test_cases_01.rs | 4 ++ crates/tui/src/client/test_cases_04.rs | 1 + crates/tui/src/client/test_cases_06.rs | 1 + 6 files changed, 112 insertions(+) diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 41e27ea521..187380be47 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -1860,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()) @@ -1876,6 +1877,7 @@ impl CodewhaleClient { auth_disabled, force_http1, config, + AuthHeaderSurface::Listing, )? .redirect(reqwest::redirect::Policy::none()) .build()?; @@ -1891,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()) @@ -2326,6 +2329,7 @@ impl CodewhaleClient { false, false, &Config::default(), + AuthHeaderSurface::Inference, )? .build() .map_err(Into::into) @@ -2340,6 +2344,7 @@ impl CodewhaleClient { auth_disabled: bool, force_http1: bool, config: &Config, + surface: AuthHeaderSurface, ) -> Result { let headers = build_default_headers( api_key, @@ -2348,6 +2353,7 @@ impl CodewhaleClient { base_url, wire_format, auth_disabled, + surface, )?; let mut builder = crate::tls::reqwest_client_builder() .default_headers(headers) @@ -2389,6 +2395,7 @@ impl CodewhaleClient { crate::config::DEFAULT_DEEPSEEK_BASE_URL, WireFormat::ChatCompletions, false, + AuthHeaderSurface::Inference, ) } @@ -2406,6 +2413,7 @@ impl CodewhaleClient { base_url, provider_default_wire_format(api_provider), false, + AuthHeaderSurface::Inference, ) } @@ -2423,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, @@ -2434,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")); @@ -2452,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 @@ -2723,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() diff --git a/crates/tui/src/client/catalog_tests.rs b/crates/tui/src/client/catalog_tests.rs index 0a3699a9c4..4cae36bc82 100644 --- a/crates/tui/src/client/catalog_tests.rs +++ b/crates/tui/src/client/catalog_tests.rs @@ -1181,3 +1181,72 @@ async fn roster_envelope_with_empty_data_is_an_empty_listing_not_a_models_fallba 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() From dfe5a845741d2a620aa2a8e7940643b7feb20bfe Mon Sep 17 00:00:00 2001 From: Shizuku <2163018547@qq.com> Date: Sat, 17 Oct 2026 06:03:02 +0800 Subject: [PATCH 5/6] fix(tui): stop telling a custom relay its catalog is missing The provider panel derived the row's catalog status from the provider-level snapshot count alone. A custom provider ships no bundled seeds, so that count is always zero for one, and every custom relay rendered "catalog snapshot missing; using provider default" directly above the live listing it had just fetched -- the panel contradicted itself on screen. The provider-level snapshot and the exact route's own roster are two different sources, and the row already computes both. The status now weighs them together, so the copy appears only when neither has anything beyond the configured default. The written count is unchanged: it is the route-level one the row already reported. --- crates/tui/src/tui/provider_picker.rs | 28 ++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) 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 From 6ede6dd34b0ac11d3f86aee1a480171ce05062e1 Mon Sep 17 00:00:00 2001 From: Shizuku <2163018547@qq.com> Date: Sat, 17 Oct 2026 19:39:29 +0800 Subject: [PATCH 6/6] feat(tui): show a custom relay's own roster in the default view `/model` opens on `Configured`, which keeps only the rows placed in a current/pinned/recent/provider-default section. A custom relay's roster lives only in the listing that route itself answered, and none of those rows carried a section -- so `/model` showed the configured model alone while the provider panel had just listed the whole roster two keystrokes away. The rows were there; the default view was not. A custom route's live rows now reach the default view, bounded per route so a gateway fronting a thousand models cannot take the view over. Builtin providers keep the existing rule: their rows come from a bundled snapshot the Catalog view already browses. The regression asserts the bound and the presence, and fails with an empty row list when the rule is disabled. --- crates/tui/src/tui/model_picker.rs | 101 +++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/crates/tui/src/tui/model_picker.rs b/crates/tui/src/tui/model_picker.rs index 0d9d8c8a01..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 @@ -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();