Merge pull request #7013 from SparkofSpike/fix/custom-relay-model-envelope

fix(catalog): discover a custom relay model list
This commit is contained in:
Hunter Bown authored and GitHub committed 2026-10-18 05:25:49 +08:00
commit 1c99609427
12 files changed
+409 -27

No files matched your search

+10
View File
@@ -268,6 +268,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
when the provider reports none (Refs CW-7450).
- The `/statusline` picker's item hints are localized in all 15 UI languages
instead of always showing English.
- A custom OpenAI-compatible relay's model list is now discovered. Four
separate defects kept it out of `/model`: a roster answering both `data` and
`models` was rejected as a duplicate field; only the active route was probed;
`/v1/models` was authenticated with `x-api-key` whenever the route declared
`wire = "anthropic"`, which a relay that rejects that header answered with
`401 missing_api_key`; and the picker's default view then hid the relay's own
rows. The startup probe now covers every configured relay, a built-in
provider name pointed at a private endpoint (`[providers.openai]` against a
relay) counts as a relay, and the provider panel no longer reports its
catalog missing while listing it.
## [0.10.2] - 2026-10-13
+10
View File
@@ -268,6 +268,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
when the provider reports none (Refs CW-7450).
- The `/statusline` picker's item hints are localized in all 15 UI languages
instead of always showing English.
- A custom OpenAI-compatible relay's model list is now discovered. Four
separate defects kept it out of `/model`: a roster answering both `data` and
`models` was rejected as a duplicate field; only the active route was probed;
`/v1/models` was authenticated with `x-api-key` whenever the route declared
`wire = "anthropic"`, which a relay that rejects that header answered with
`401 missing_api_key`; and the picker's default view then hid the relay's own
rows. The startup probe now covers every configured relay, a built-in
provider name pointed at a private endpoint (`[providers.openai]` against a
relay) counts as a relay, and the provider panel no longer reports its
catalog missing while listing it.
## [0.10.2] - 2026-10-13
+94 -4
View File
@@ -1022,13 +1022,23 @@ impl From<CatalogRefreshError> 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<String>,
}
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 {
@@ -1104,7 +1114,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 {
@@ -1852,6 +1862,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())
@@ -1868,6 +1879,7 @@ impl CodewhaleClient {
auth_disabled,
force_http1,
config,
AuthHeaderSurface::Listing,
)?
.redirect(reqwest::redirect::Policy::none())
.build()?;
@@ -1883,6 +1895,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())
@@ -2353,6 +2366,7 @@ impl CodewhaleClient {
false,
false,
&Config::default(),
AuthHeaderSurface::Inference,
)?
.build()
.map_err(Into::into)
@@ -2367,6 +2381,7 @@ impl CodewhaleClient {
auth_disabled: bool,
force_http1: bool,
config: &Config,
surface: AuthHeaderSurface,
) -> Result<reqwest::ClientBuilder> {
let headers = build_default_headers(
api_key,
@@ -2375,6 +2390,7 @@ impl CodewhaleClient {
base_url,
wire_format,
auth_disabled,
surface,
)?;
let mut builder = crate::tls::reqwest_client_builder()
.default_headers(headers)
@@ -2416,6 +2432,7 @@ impl CodewhaleClient {
crate::config::DEFAULT_DEEPSEEK_BASE_URL,
WireFormat::ChatCompletions,
false,
AuthHeaderSurface::Inference,
)
}
@@ -2433,6 +2450,7 @@ impl CodewhaleClient {
base_url,
provider_default_wire_format(api_provider),
false,
AuthHeaderSurface::Inference,
)
}
@@ -2450,10 +2468,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<String, String>,
@@ -2461,6 +2498,7 @@ fn build_default_headers(
base_url: &str,
wire_format: WireFormat,
auth_disabled: bool,
surface: AuthHeaderSurface,
) -> Result<HeaderMap> {
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
@@ -2479,10 +2517,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
@@ -2750,6 +2795,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()
@@ -3764,7 +3810,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;
}
@@ -3843,6 +3892,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
+132
View File
@@ -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::<Vec<_>>(),
["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::<Vec<_>>(),
["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"
);
}
+1
View File
@@ -288,6 +288,7 @@ impl CodewhaleClient {
false,
client.force_http1,
config,
AuthHeaderSurface::Inference,
)?
.build()?;
client.base_url = base_url;
+4
View File
@@ -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"] {
+1
View File
@@ -370,6 +370,7 @@
"https://api.codewhale.net/v1",
wire,
false,
AuthHeaderSurface::Inference,
)
.expect("headers");
assert_eq!(
+1
View File
@@ -726,6 +726,7 @@
true,
false,
&config,
AuthHeaderSurface::Inference,
)
.unwrap()
.no_proxy()
+4
View File
@@ -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
+27 -9
View File
@@ -624,20 +624,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.<name>]` 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
@@ -649,6 +666,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
+106 -5
View File
@@ -356,6 +356,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.
@@ -2355,6 +2364,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<String, usize> = 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
@@ -2597,14 +2629,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,
@@ -6091,6 +6123,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();
+19 -9
View File
@@ -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