mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 08:04:25 -10:00
Merge pull request #7013 from SparkofSpike/fix/custom-relay-model-envelope
fix(catalog): discover a custom relay model list
This commit is contained in:
12 files changed
+409
-27
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -288,6 +288,7 @@ impl CodewhaleClient {
|
||||
false,
|
||||
client.force_http1,
|
||||
config,
|
||||
AuthHeaderSurface::Inference,
|
||||
)?
|
||||
.build()?;
|
||||
client.base_url = base_url;
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
@@ -370,6 +370,7 @@
|
||||
"https://api.codewhale.net/v1",
|
||||
wire,
|
||||
false,
|
||||
AuthHeaderSurface::Inference,
|
||||
)
|
||||
.expect("headers");
|
||||
assert_eq!(
|
||||
|
||||
@@ -726,6 +726,7 @@
|
||||
true,
|
||||
false,
|
||||
&config,
|
||||
AuthHeaderSurface::Inference,
|
||||
)
|
||||
.unwrap()
|
||||
.no_proxy()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user