mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-09 09:59:45 -10:00
fix(setup): say when a key is rejected, and offer only models the provider serves
Two first-run defects from release QA on 8b20d48d9 (real DeepSeek key):
Invalid key loop. With an invalid DEEPSEEK_API_KEY, sending a message
dropped the user into "Getting started → Choose your model provider" with
no explanation; the picker showed DeepSeek as "key saved · not checked"
and Enter re-applied the same rejected key, so send → setup → send looped.
The rejection was written to the transcript but hidden behind the setup
screen, the readiness snapshot could not record a rejection for a key with
no fingerprint (env / secret-store keys), and Enter applied any keyed row.
Now: the setup screen states the key was rejected (existing
OnboardApiKeyRejectedEnv wording, error colour); provider readiness records
rejected credentials by provider, endpoint, auth type and fingerprint
(cleared by the next success); the rejected row reads "last check failed
(authentication)" and Enter opens key entry instead of reusing the key; Esc
returns to the composer. The key value is never displayed or logged.
Stale model list. After "The provider accepted the key" the first-run
picker listed every catalog DeepSeek model (19 on the QA machine, all
"price unknown"); picking one the API no longer serves (deepseek-v3.1)
failed the first message with 400. verify_provider_api_key already fetched
/models but discarded the body. It now returns the roster from a complete
page, and setup publishes it for the exact route through the existing
live-catalog path (no second catalog), filtered on a route's first roster
to ids the catalog knows for that provider so embeddings/speech ids stay
out. DeepSeek setup then offers deepseek-flash and deepseek-v4-pro.
Catalog-unpriced rows fall back to pricing::model_rate_label. Without a live
roster, an alias the route would rewrite to another listed id is hidden.
Tests: tui::provider_picker::tests::
rejected_env_key_is_marked_and_enter_asks_for_a_new_key and
tui::ui::provider_key_validation_tests::
provider_key_probe_roster_becomes_the_model_pick_roster (new), plus
env_only_auth_failure_reopens_provider_onboarding, provider readiness,
verify_provider, provider_key_, fetch_catalog_delta, the provider picker
and onboarding suites: test result: ok. 253 passed; 0 failed.
Follow-ups (data decisions, not in this change): DEEPSEEK_ALIAS_REPLACEMENT
in catalog_corrections.json points deepseek-chat/-reasoner at
deepseek-v4-flash; the bundled seed still carries rows Models.dev marks
deprecated; setup writes [providers.deepseek].model without updating
default_text_model.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
1 parent
c4fc9f2c82
commit
0c79ef28b1
11 files changed
+574
-195
No files matched your search
+196
-167
@@ -2486,7 +2486,15 @@ pub(crate) fn provider_api_key_verification_is_observed(api_provider: ProviderKi
|
||||
/// Verify a provider API key by hitting the `/models` endpoint
|
||||
/// (#3875). Builds a minimal HTTP client with the canonical auth
|
||||
/// headers for `provider`, issues a single GET, and returns
|
||||
/// `Ok(())` on a 2xx response or `Err(reason)` on any failure.
|
||||
/// `Ok(roster)` on a 2xx response or `Err(reason)` on any failure.
|
||||
///
|
||||
/// `roster` is the listing that 2xx already carried, projected by the same
|
||||
/// rules as a catalog refresh and scoped to `provider`'s own id: the caller
|
||||
/// rescopes it to the exact route identity and publishes it, so guided setup
|
||||
/// offers the models this key can call today rather than bundled or
|
||||
/// Models.dev rows the provider may have retired. It is `None` when the body
|
||||
/// is not one complete, valid roster (a paginated first page, malformed or
|
||||
/// empty JSON); a valid 2xx still verifies the key, and the existing rows stay.
|
||||
///
|
||||
/// This is intentionally a one-shot call — no retry, no rate-limit
|
||||
/// wait — so a bad key is surfaced immediately.
|
||||
@@ -2494,11 +2502,11 @@ pub async fn verify_provider_api_key(
|
||||
provider: ProviderKind,
|
||||
api_key: &str,
|
||||
base_url: &str,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<Option<ProviderCatalogDelta>, String> {
|
||||
if api_provider_skips_models_probe(provider) {
|
||||
// Providers without a /models endpoint can't be verified this
|
||||
// way; accept the key optimistically (same as health_check).
|
||||
return Ok(());
|
||||
return Ok(None);
|
||||
}
|
||||
let headers = build_default_headers(
|
||||
api_key,
|
||||
@@ -2529,30 +2537,27 @@ pub async fn verify_provider_api_key(
|
||||
.map_err(|err| format!("request failed: {}", err.without_url()))?;
|
||||
let status = response.status();
|
||||
if status.is_success() {
|
||||
// TelecomJS verification already returns the key-scoped model roster.
|
||||
// Publish it before returning so the guided model picker can render the
|
||||
// live choices in this session instead of requiring a restart. A valid
|
||||
// 2xx response remains sufficient to verify the key even if the body is
|
||||
// malformed; in that case failure-preserving catalog semantics keep the
|
||||
// existing/static rows.
|
||||
// A valid 2xx verifies the key even when the body is not a usable
|
||||
// roster; failure-preserving catalog semantics then keep the
|
||||
// existing rows.
|
||||
let body = bounded_provider_catalog_text(response, PROVIDER_CATALOG_MAX_RESPONSE_BYTES)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if matches!(
|
||||
provider,
|
||||
ProviderKind::Telecomjs | ProviderKind::Edenai | ProviderKind::Zenmux
|
||||
) && serde_json::from_str::<ModelsPage<'_>>(&body).is_ok_and(|page| !page.has_more)
|
||||
&& let Ok(offerings) = named_gateway_catalog_offerings_from_body(
|
||||
&body,
|
||||
provider,
|
||||
provider.as_str(),
|
||||
&base_url_fingerprint(base_url),
|
||||
now_unix(),
|
||||
)
|
||||
{
|
||||
crate::provider_lake::merge_live_offerings(offerings);
|
||||
}
|
||||
Ok(())
|
||||
let complete =
|
||||
serde_json::from_str::<ModelsPage<'_>>(&body).is_ok_and(|page| !page.has_more);
|
||||
let secrets = [api_key.trim().to_string()];
|
||||
Ok(complete
|
||||
.then(|| {
|
||||
catalog_delta_from_models_body(
|
||||
provider,
|
||||
provider.as_str().to_string(),
|
||||
base_url,
|
||||
&body,
|
||||
&secrets,
|
||||
)
|
||||
.ok()
|
||||
})
|
||||
.flatten())
|
||||
} else {
|
||||
let body = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await;
|
||||
let body = if api_key.trim().is_empty() {
|
||||
@@ -3302,9 +3307,9 @@ impl CodewhaleClient {
|
||||
/// about the host (#6289). Catalog ownership still uses the exact
|
||||
/// configured identity, so a renamed Baseten table keeps its own
|
||||
/// partition.
|
||||
#[cfg(test)]
|
||||
fn catalog_endpoint_is_baseten(&self) -> bool {
|
||||
self.api_provider == ProviderKind::Custom
|
||||
&& codewhale_config::catalog::endpoint_is_baseten(&self.base_url)
|
||||
catalog_endpoint_is_baseten(self.api_provider, &self.base_url)
|
||||
}
|
||||
|
||||
/// Fetch the provider's live `/models` listing as a secret-free
|
||||
@@ -3323,150 +3328,17 @@ impl CodewhaleClient {
|
||||
.await
|
||||
.map_err(ModelsFetchError::into_catalog)?;
|
||||
|
||||
let provider = self.catalog_provider_id();
|
||||
let fingerprint = base_url_fingerprint(&self.base_url);
|
||||
let fetched_at = now_unix();
|
||||
|
||||
// OpenRouter returns extended capability metadata in its /models
|
||||
// response (#3385). Capture limits, pricing, reasoning, and modalities
|
||||
// from the live API instead of leaving them unknown.
|
||||
let offerings: Vec<CatalogOffering> = if self.api_provider == ProviderKind::Openrouter {
|
||||
let or_models = parse_openrouter_models_response(&body)?;
|
||||
if or_models.is_empty() {
|
||||
return Err(CatalogRefreshError::EmptyList);
|
||||
}
|
||||
// A row with an unreadable or implausible price is skipped and
|
||||
// counted, not allowed to fail every other row (#6690).
|
||||
let offerings: Vec<_> = or_models
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
openrouter_to_catalog_offering(item, &provider, &fingerprint, fetched_at).ok()
|
||||
})
|
||||
.collect();
|
||||
let skipped = or_models.len() - offerings.len();
|
||||
if skipped > 0 {
|
||||
tracing::warn!(
|
||||
skipped,
|
||||
listed = or_models.len(),
|
||||
"skipped OpenRouter model rows with invalid pricing"
|
||||
);
|
||||
}
|
||||
if offerings.is_empty() {
|
||||
return Err(CatalogRefreshError::InvalidResponse);
|
||||
}
|
||||
offerings
|
||||
} else if self.catalog_endpoint_is_baseten() {
|
||||
let baseten_models = parse_baseten_models_response(&body)?;
|
||||
if baseten_models.is_empty() {
|
||||
return Err(CatalogRefreshError::EmptyList);
|
||||
}
|
||||
baseten_models
|
||||
.iter()
|
||||
.map(|item| baseten_to_catalog_offering(item, &provider, &fingerprint, fetched_at))
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
} else if self.api_provider == ProviderKind::Telecomjs {
|
||||
named_gateway_catalog_offerings_from_body(
|
||||
&body,
|
||||
codewhale_config::ProviderKind::Telecomjs,
|
||||
&provider,
|
||||
&fingerprint,
|
||||
fetched_at,
|
||||
)?
|
||||
} else if self.api_provider == ProviderKind::Edenai {
|
||||
named_gateway_catalog_offerings_from_body(
|
||||
&body,
|
||||
codewhale_config::ProviderKind::Edenai,
|
||||
&provider,
|
||||
&fingerprint,
|
||||
fetched_at,
|
||||
)?
|
||||
} else if self.api_provider == ProviderKind::Zenmux {
|
||||
named_gateway_catalog_offerings_from_body(
|
||||
&body,
|
||||
codewhale_config::ProviderKind::Zenmux,
|
||||
&provider,
|
||||
&fingerprint,
|
||||
fetched_at,
|
||||
)?
|
||||
} else if provider == "codewhale" {
|
||||
// The Codewhale API's own listing states the wire protocol per
|
||||
// model, so it is the catalog authority for this route.
|
||||
codewhale_catalog_offerings_from_body(&body, &provider, &fingerprint, fetched_at)?
|
||||
} else if provider == "concentrate" {
|
||||
// Concentrate's unauthenticated `GET /v1/models` is the same
|
||||
// OpenAI list shape (`{"object":"list","data":[{"id":..}]}`);
|
||||
// rows stay unclaimed unless a same-provider bundled row exists.
|
||||
named_gateway_catalog_offerings_from_body(
|
||||
&body,
|
||||
codewhale_config::ProviderKind::Concentrate,
|
||||
&provider,
|
||||
&fingerprint,
|
||||
fetched_at,
|
||||
)?
|
||||
} else {
|
||||
let models = apply_provider_model_cutline(
|
||||
self.api_provider,
|
||||
parse_models_response_for_provider(&body, self.api_provider)
|
||||
.map_err(|_| CatalogRefreshError::InvalidResponse)?,
|
||||
);
|
||||
if models.is_empty() {
|
||||
return Err(CatalogRefreshError::EmptyList);
|
||||
}
|
||||
models
|
||||
.into_iter()
|
||||
.map(|model| CatalogOffering {
|
||||
cost_source: None,
|
||||
modalities_source: None,
|
||||
provider: provider.clone(),
|
||||
endpoint_key: if self.api_provider == ProviderKind::OpencodeGo {
|
||||
codewhale_config::opencode_go_endpoint_key(&model.id)
|
||||
.expect("filtered Go roster")
|
||||
} else {
|
||||
"chat"
|
||||
}
|
||||
.to_string(),
|
||||
wire_model_id: model.id,
|
||||
canonical_model: None,
|
||||
default_for_provider: false,
|
||||
family: None,
|
||||
limit: None,
|
||||
cost: None,
|
||||
modalities: None,
|
||||
attachment: None,
|
||||
reasoning: None,
|
||||
tool_call: None,
|
||||
structured_output: None,
|
||||
reasoning_options: Vec::new(),
|
||||
source: CatalogSource::Live {
|
||||
base_url_fingerprint: fingerprint.clone(),
|
||||
fetched_at,
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
if offerings.iter().any(|row| {
|
||||
!crate::provider_lake::valid_catalog_model_id(&row.wire_model_id)
|
||||
|| self
|
||||
.model_bound_secret_values
|
||||
.iter()
|
||||
.any(|secret| !secret.is_empty() && row.wire_model_id.contains(secret))
|
||||
}) {
|
||||
return Err(CatalogRefreshError::InvalidResponse);
|
||||
}
|
||||
if offerings.len() > PROVIDER_CATALOG_MAX_ROWS {
|
||||
return Err(CatalogRefreshError::InvalidResponse);
|
||||
}
|
||||
|
||||
let delta = catalog_delta_from_models_body(
|
||||
self.api_provider,
|
||||
self.catalog_provider_id(),
|
||||
&self.base_url,
|
||||
&body,
|
||||
&self.model_bound_secret_values,
|
||||
)?;
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(CatalogRefreshError::Network);
|
||||
}
|
||||
Ok(ProviderCatalogDelta {
|
||||
provider,
|
||||
base_url_fingerprint: fingerprint,
|
||||
fetched_at,
|
||||
offerings,
|
||||
})
|
||||
Ok(delta)
|
||||
}
|
||||
|
||||
/// Refresh `cache` for this client's provider + base URL, recording either a
|
||||
@@ -4576,6 +4448,163 @@ fn codewhale_catalog_offerings_from_body(
|
||||
Ok(offerings)
|
||||
}
|
||||
|
||||
/// Whether a route speaks Baseten's `/models` dialect: recognized by
|
||||
/// endpoint, never by table name (#6289).
|
||||
fn catalog_endpoint_is_baseten(api_provider: ProviderKind, base_url: &str) -> bool {
|
||||
api_provider == ProviderKind::Custom && codewhale_config::catalog::endpoint_is_baseten(base_url)
|
||||
}
|
||||
|
||||
/// Project one `/models` document onto a secret-free, endpoint-scoped roster
|
||||
/// delta (#3385). Shared by [`CodewhaleClient::fetch_catalog_delta`] and the
|
||||
/// guided-setup key probe ([`verify_provider_api_key`]), so the listing a key
|
||||
/// check already downloaded is read by exactly the rules a refresh uses.
|
||||
fn catalog_delta_from_models_body(
|
||||
api_provider: ProviderKind,
|
||||
provider: String,
|
||||
base_url: &str,
|
||||
body: &str,
|
||||
model_bound_secret_values: &[String],
|
||||
) -> Result<ProviderCatalogDelta, CatalogRefreshError> {
|
||||
let fingerprint = base_url_fingerprint(base_url);
|
||||
let fetched_at = now_unix();
|
||||
|
||||
// OpenRouter returns extended capability metadata in its /models
|
||||
// response (#3385). Capture limits, pricing, reasoning, and modalities
|
||||
// from the live API instead of leaving them unknown.
|
||||
let offerings: Vec<CatalogOffering> = if api_provider == ProviderKind::Openrouter {
|
||||
let or_models = parse_openrouter_models_response(body)?;
|
||||
if or_models.is_empty() {
|
||||
return Err(CatalogRefreshError::EmptyList);
|
||||
}
|
||||
// A row with an unreadable or implausible price is skipped and
|
||||
// counted, not allowed to fail every other row (#6690).
|
||||
let offerings: Vec<_> = or_models
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
openrouter_to_catalog_offering(item, &provider, &fingerprint, fetched_at).ok()
|
||||
})
|
||||
.collect();
|
||||
let skipped = or_models.len() - offerings.len();
|
||||
if skipped > 0 {
|
||||
tracing::warn!(
|
||||
skipped,
|
||||
listed = or_models.len(),
|
||||
"skipped OpenRouter model rows with invalid pricing"
|
||||
);
|
||||
}
|
||||
if offerings.is_empty() {
|
||||
return Err(CatalogRefreshError::InvalidResponse);
|
||||
}
|
||||
offerings
|
||||
} else if catalog_endpoint_is_baseten(api_provider, base_url) {
|
||||
let baseten_models = parse_baseten_models_response(body)?;
|
||||
if baseten_models.is_empty() {
|
||||
return Err(CatalogRefreshError::EmptyList);
|
||||
}
|
||||
baseten_models
|
||||
.iter()
|
||||
.map(|item| baseten_to_catalog_offering(item, &provider, &fingerprint, fetched_at))
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
} else if api_provider == ProviderKind::Telecomjs {
|
||||
named_gateway_catalog_offerings_from_body(
|
||||
body,
|
||||
codewhale_config::ProviderKind::Telecomjs,
|
||||
&provider,
|
||||
&fingerprint,
|
||||
fetched_at,
|
||||
)?
|
||||
} else if api_provider == ProviderKind::Edenai {
|
||||
named_gateway_catalog_offerings_from_body(
|
||||
body,
|
||||
codewhale_config::ProviderKind::Edenai,
|
||||
&provider,
|
||||
&fingerprint,
|
||||
fetched_at,
|
||||
)?
|
||||
} else if api_provider == ProviderKind::Zenmux {
|
||||
named_gateway_catalog_offerings_from_body(
|
||||
body,
|
||||
codewhale_config::ProviderKind::Zenmux,
|
||||
&provider,
|
||||
&fingerprint,
|
||||
fetched_at,
|
||||
)?
|
||||
} else if provider == "codewhale" {
|
||||
// The Codewhale API's own listing states the wire protocol per
|
||||
// model, so it is the catalog authority for this route.
|
||||
codewhale_catalog_offerings_from_body(body, &provider, &fingerprint, fetched_at)?
|
||||
} else if provider == "concentrate" {
|
||||
// Concentrate's unauthenticated `GET /v1/models` is the same
|
||||
// OpenAI list shape (`{"object":"list","data":[{"id":..}]}`);
|
||||
// rows stay unclaimed unless a same-provider bundled row exists.
|
||||
named_gateway_catalog_offerings_from_body(
|
||||
body,
|
||||
codewhale_config::ProviderKind::Concentrate,
|
||||
&provider,
|
||||
&fingerprint,
|
||||
fetched_at,
|
||||
)?
|
||||
} else {
|
||||
let models = apply_provider_model_cutline(
|
||||
api_provider,
|
||||
parse_models_response_for_provider(body, api_provider)
|
||||
.map_err(|_| CatalogRefreshError::InvalidResponse)?,
|
||||
);
|
||||
if models.is_empty() {
|
||||
return Err(CatalogRefreshError::EmptyList);
|
||||
}
|
||||
models
|
||||
.into_iter()
|
||||
.map(|model| CatalogOffering {
|
||||
cost_source: None,
|
||||
modalities_source: None,
|
||||
provider: provider.clone(),
|
||||
endpoint_key: if api_provider == ProviderKind::OpencodeGo {
|
||||
codewhale_config::opencode_go_endpoint_key(&model.id)
|
||||
.expect("filtered Go roster")
|
||||
} else {
|
||||
"chat"
|
||||
}
|
||||
.to_string(),
|
||||
wire_model_id: model.id,
|
||||
canonical_model: None,
|
||||
default_for_provider: false,
|
||||
family: None,
|
||||
limit: None,
|
||||
cost: None,
|
||||
modalities: None,
|
||||
attachment: None,
|
||||
reasoning: None,
|
||||
tool_call: None,
|
||||
structured_output: None,
|
||||
reasoning_options: Vec::new(),
|
||||
source: CatalogSource::Live {
|
||||
base_url_fingerprint: fingerprint.clone(),
|
||||
fetched_at,
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
if offerings.iter().any(|row| {
|
||||
!crate::provider_lake::valid_catalog_model_id(&row.wire_model_id)
|
||||
|| model_bound_secret_values
|
||||
.iter()
|
||||
.any(|secret| !secret.is_empty() && row.wire_model_id.contains(secret))
|
||||
}) {
|
||||
return Err(CatalogRefreshError::InvalidResponse);
|
||||
}
|
||||
if offerings.len() > PROVIDER_CATALOG_MAX_ROWS {
|
||||
return Err(CatalogRefreshError::InvalidResponse);
|
||||
}
|
||||
Ok(ProviderCatalogDelta {
|
||||
provider,
|
||||
base_url_fingerprint: fingerprint,
|
||||
fetched_at,
|
||||
offerings,
|
||||
})
|
||||
}
|
||||
|
||||
fn named_gateway_catalog_offerings_from_body(
|
||||
body: &str,
|
||||
kind: codewhale_config::ProviderKind,
|
||||
|
||||
@@ -368,9 +368,10 @@ pub fn clear_live_snapshot() {
|
||||
/// Unlike [`set_live_snapshot`] for `LiveSource::PerProvider` (which replaces
|
||||
/// each represented provider's partition), this merges new rows by
|
||||
/// `(provider, wire_model_id)` identity within that provider's partition,
|
||||
/// preserving every other provider and the Models.dev partition. This is used
|
||||
/// by provider catalog refreshes (e.g. TelecomJS `/v1/models`) that need to
|
||||
/// coexist with the cross-provider Models.dev live layer.
|
||||
/// preserving every other provider and the Models.dev partition. Production
|
||||
/// rosters publish through `provider_catalog_live` (the guided-setup key probe
|
||||
/// included), so this remains a fixture seam for lake tests.
|
||||
#[cfg(test)]
|
||||
pub fn merge_live_offerings(new_offerings: Vec<CatalogOffering>) {
|
||||
if new_offerings.is_empty() {
|
||||
return;
|
||||
|
||||
@@ -462,10 +462,38 @@ impl ResolvedProviderReadiness {
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct ProviderReadinessSnapshot {
|
||||
checks: Vec<(ProviderRouteIdentity, LastProviderCheck)>,
|
||||
/// Credentials the provider refused (authentication failures), keyed by
|
||||
/// route without the model: a rejected key is rejected for every model.
|
||||
/// Unlike `checks`, an entry may carry no credential generation — an
|
||||
/// ambient `*_API_KEY`, a secret-store key or a command source cannot be
|
||||
/// fingerprinted read-only, and without this the picker kept offering a
|
||||
/// refused env key as "key saved · not checked".
|
||||
///
|
||||
/// Known limitation: an opaque (generation-less) rejection cannot see a
|
||||
/// key rotated outside this process; it lasts until the next success on
|
||||
/// that route, or until a saved key makes the generation provable.
|
||||
credential_rejections: Vec<(ProviderRouteIdentity, LastProviderCheck)>,
|
||||
}
|
||||
|
||||
fn same_credential(candidate: &ProviderRouteIdentity, identity: &ProviderRouteIdentity) -> bool {
|
||||
candidate.identity == identity.identity
|
||||
&& candidate.endpoint == identity.endpoint
|
||||
&& candidate.auth_class == identity.auth_class
|
||||
&& candidate.credential_generation == identity.credential_generation
|
||||
}
|
||||
|
||||
impl ProviderReadinessSnapshot {
|
||||
fn last(&self, identity: &ProviderRouteIdentity) -> Option<&LastProviderCheck> {
|
||||
// A refused credential outranks any older per-model success with it;
|
||||
// a later success on the route clears the rejection.
|
||||
if let Some(check) = self
|
||||
.credential_rejections
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|(candidate, check)| same_credential(candidate, identity).then_some(check))
|
||||
{
|
||||
return Some(check);
|
||||
}
|
||||
identity.credential_generation.as_ref()?;
|
||||
if let Some(check) = self
|
||||
.checks
|
||||
@@ -483,13 +511,10 @@ impl ProviderReadinessSnapshot {
|
||||
if identity.model != "auto" {
|
||||
return None;
|
||||
}
|
||||
self.checks.iter().rev().find_map(|(candidate, check)| {
|
||||
(candidate.identity == identity.identity
|
||||
&& candidate.endpoint == identity.endpoint
|
||||
&& candidate.auth_class == identity.auth_class
|
||||
&& candidate.credential_generation == identity.credential_generation)
|
||||
.then_some(check)
|
||||
})
|
||||
self.checks
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|(candidate, check)| same_credential(candidate, identity).then_some(check))
|
||||
}
|
||||
|
||||
pub(crate) fn record_success(
|
||||
@@ -502,6 +527,9 @@ impl ProviderReadinessSnapshot {
|
||||
let mut route = route_identity_for_model(config, identity, model);
|
||||
route.endpoint = receipt.endpoint_identity().to_string();
|
||||
route.credential_generation = Some(receipt.credential_generation().clone());
|
||||
self.credential_rejections.retain(|(candidate, _)| {
|
||||
candidate.identity != route.identity || candidate.endpoint != route.endpoint
|
||||
});
|
||||
self.replace(route, LastProviderCheck::Passed);
|
||||
}
|
||||
|
||||
@@ -555,13 +583,28 @@ impl ProviderReadinessSnapshot {
|
||||
if !provider_owned_failure(envelope) {
|
||||
return;
|
||||
}
|
||||
self.replace(
|
||||
route,
|
||||
LastProviderCheck::Failed {
|
||||
category: envelope.category,
|
||||
message: sanitize_message(&envelope.message),
|
||||
},
|
||||
);
|
||||
let check = LastProviderCheck::Failed {
|
||||
category: envelope.category,
|
||||
message: sanitize_message(&envelope.message),
|
||||
};
|
||||
if envelope.category == ErrorCategory::Authentication {
|
||||
// Record the rejection under the generation the picker will
|
||||
// compute. When that projection proves a different credential is
|
||||
// configured now, the refused one is already gone.
|
||||
let current = config.readonly_health_credential_generation(identity);
|
||||
if current
|
||||
.as_ref()
|
||||
.is_none_or(|generation| generation == receipt.credential_generation())
|
||||
{
|
||||
let mut rejected = route.clone();
|
||||
rejected.model.clear();
|
||||
rejected.credential_generation = current;
|
||||
self.credential_rejections
|
||||
.retain(|(candidate, _)| !same_credential(candidate, &rejected));
|
||||
self.credential_rejections.push((rejected, check.clone()));
|
||||
}
|
||||
}
|
||||
self.replace(route, check);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -2102,6 +2102,10 @@ pub struct App {
|
||||
/// provider is missing its key. Esc then exits to the offline composer
|
||||
/// instead of walking back through first-run steps.
|
||||
pub onboarding_missing_key_recovery: bool,
|
||||
/// Why provider setup reopened after the provider refused the active
|
||||
/// key. The setup screen covers the transcript, so it shows this until
|
||||
/// the user leaves or completes setup.
|
||||
pub(crate) onboarding_key_rejected: Option<String>,
|
||||
/// True when the user explicitly chose "Explore offline" during onboarding
|
||||
/// (#3927). No provider was selected, no route was activated, and no secret
|
||||
/// was saved: the session browses with queued input until a route is
|
||||
|
||||
@@ -996,6 +996,7 @@ impl App {
|
||||
onboarding_provider: provider,
|
||||
onboarding_workspace_trust_gate,
|
||||
onboarding_missing_key_recovery,
|
||||
onboarding_key_rejected: None,
|
||||
onboarding_explore_offline: false,
|
||||
// Language is asked in /setup, never at launch: counting it here
|
||||
// made the one launch screen read "Getting started · 2/3" with no
|
||||
|
||||
@@ -170,6 +170,15 @@ fn provider_lines(app: &App, width: usize) -> Vec<Line<'static>> {
|
||||
let mut lines = Vec::new();
|
||||
heading(&mut lines, app, MessageId::OnboardProviderTitle, width);
|
||||
lines.push(Line::from(""));
|
||||
if let Some(notice) = app.onboarding_key_rejected.as_deref() {
|
||||
for segment in wrap_words(notice, width) {
|
||||
lines.push(Line::from(Span::styled(
|
||||
segment,
|
||||
Style::default().fg(palette::STATUS_ERROR),
|
||||
)));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
wrap_body(&mut lines, app, MessageId::OnboardProviderBlurb, width);
|
||||
lines
|
||||
}
|
||||
|
||||
@@ -1638,7 +1638,16 @@ fn model_cost_label(provider: ProviderKind, model: &str) -> String {
|
||||
}
|
||||
let pricing =
|
||||
catalog_offering_for_model(provider, model).map(|offering| offering.to_offering().pricing);
|
||||
model_cost_label_for_pricing(provider, pricing.as_ref())
|
||||
let label = model_cost_label_for_pricing(provider, pricing.as_ref());
|
||||
if label != "price unknown" {
|
||||
return label;
|
||||
}
|
||||
// A catalog row can withhold a rate the reviewed table still owns:
|
||||
// DeepSeek bills by time of day (`pricing_withheld` in
|
||||
// catalog_corrections.json), and live roster rows carry no price at all.
|
||||
// Ask pricing.rs before calling the price unknown, as `/model` does.
|
||||
crate::pricing::model_rate_label(provider, model, crate::pricing::CostCurrency::Usd)
|
||||
.map_or(label, |rate| format!("{rate} per 1M"))
|
||||
}
|
||||
|
||||
/// Slice D two-pane picker: `(model, per-model cost, is_default_route)` rows
|
||||
@@ -2295,6 +2304,19 @@ impl ProviderPickerView {
|
||||
self.rows[self.selected_idx].route_ok
|
||||
}
|
||||
|
||||
/// The provider refused this row's credential in this session. Enter
|
||||
/// then asks for a new key instead of reusing the refused one, which
|
||||
/// only loops back into the same rejection.
|
||||
fn selected_credential_rejected(&self) -> bool {
|
||||
matches!(
|
||||
self.rows[self.selected_idx].readiness,
|
||||
ResolvedProviderReadiness::SavedLastCheckFailed {
|
||||
category: crate::error_taxonomy::ErrorCategory::Authentication,
|
||||
..
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fn enter_key_entry(&mut self) {
|
||||
self.stage = Stage::KeyEntry;
|
||||
self.api_key_input.clear();
|
||||
@@ -2515,9 +2537,28 @@ impl ProviderPickerView {
|
||||
// Last-resort so the guided flow never dead-ends without a choice.
|
||||
models.push(provider.as_str().to_string());
|
||||
}
|
||||
// Without a live roster the list is catalog rows, which can still name
|
||||
// a compatibility alias this route rewrites to another id (DeepSeek's
|
||||
// retired `deepseek-chat` -> `deepseek-v4-flash`). Offering both shows
|
||||
// one model twice under a name the provider no longer serves; keep
|
||||
// the id the route actually sends whenever that id is also listed.
|
||||
let base_url = self.rows[self.selected_idx].base_url.clone();
|
||||
let wire =
|
||||
|model: &str| crate::config::wire_model_for_provider_route(provider, &base_url, model);
|
||||
let listed: Vec<String> = models.clone();
|
||||
models.retain(|model| {
|
||||
let target = wire(model);
|
||||
target == model.trim() || !listed.iter().any(|other| *other == target)
|
||||
});
|
||||
let preferred_wire = wire(&preferred);
|
||||
let selected = models
|
||||
.iter()
|
||||
.position(|model| model.eq_ignore_ascii_case(preferred.trim()))
|
||||
.or_else(|| {
|
||||
models
|
||||
.iter()
|
||||
.position(|model| model.eq_ignore_ascii_case(preferred_wire.trim()))
|
||||
})
|
||||
.unwrap_or(0);
|
||||
self.model_options = models;
|
||||
self.model_selected_idx = selected.min(self.model_options.len().saturating_sub(1));
|
||||
@@ -2747,7 +2788,7 @@ impl ProviderPickerView {
|
||||
self.tr(MessageId::PickerActionCustom)
|
||||
} else if !self.selected_route_is_valid() {
|
||||
self.tr(MessageId::PickerActionUnavailable)
|
||||
} else if self.selected_has_key() {
|
||||
} else if self.selected_has_key() && !self.selected_credential_rejected() {
|
||||
self.tr(MessageId::PickerActionApply)
|
||||
} else {
|
||||
self.tr(MessageId::PickerActionSetKey)
|
||||
@@ -4314,7 +4355,7 @@ impl ProviderPickerView {
|
||||
ViewAction::None
|
||||
} else if !self.selected_route_is_valid() {
|
||||
ViewAction::None
|
||||
} else if self.selected_has_key() {
|
||||
} else if self.selected_has_key() && !self.selected_credential_rejected() {
|
||||
ViewAction::EmitAndClose(ViewEvent::ProviderPickerApplied {
|
||||
identity: self.selected_identity().expect("checked admitted row"),
|
||||
})
|
||||
@@ -6555,6 +6596,68 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Release QA: a DeepSeek key from the environment that the provider
|
||||
/// refused stayed "key saved · not checked" (an ambient key has no
|
||||
/// read-only generation), and Enter re-applied the same key, looping
|
||||
/// send → setup → send. The rejection holds for every model on the route.
|
||||
#[test]
|
||||
fn rejected_env_key_is_marked_and_enter_asks_for_a_new_key() {
|
||||
let _lock = crate::test_support::lock_test_env();
|
||||
let _cli = EnvVarGuard::remove(codewhale_config::CLI_API_KEY_ENV);
|
||||
let _source = EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
|
||||
let _key = EnvVarGuard::set("DEEPSEEK_API_KEY", "sk-env-rejected");
|
||||
let config = Config::default();
|
||||
let identity = config.test_identity_for_kind(ProviderKind::Deepseek);
|
||||
assert!(
|
||||
config
|
||||
.readonly_health_credential_generation(&identity)
|
||||
.is_none(),
|
||||
"fixture must exercise the opaque ambient-key path"
|
||||
);
|
||||
let mut health = ProviderReadinessSnapshot::default();
|
||||
health.record_failure(
|
||||
&config,
|
||||
&crate::route_receipt::TurnRouteReceipt::for_test_fixture(
|
||||
&config,
|
||||
ProviderKind::Deepseek,
|
||||
"model-the-turn-ran",
|
||||
),
|
||||
"model-the-turn-ran",
|
||||
&crate::error_taxonomy::ErrorEnvelope::fatal_auth(
|
||||
"Authentication failed: invalid API key",
|
||||
),
|
||||
);
|
||||
|
||||
let mut picker = ProviderPickerView::new_for_onboarding(
|
||||
ProviderKind::Deepseek,
|
||||
Some(ProviderKind::Deepseek.as_str().into()),
|
||||
&config,
|
||||
None,
|
||||
)
|
||||
.with_provider_health(&health);
|
||||
assert_eq!(picker.selected_provider(), ProviderKind::Deepseek);
|
||||
let readiness = picker.rows[picker.selected_idx].readiness.clone();
|
||||
assert!(
|
||||
matches!(
|
||||
readiness,
|
||||
ResolvedProviderReadiness::SavedLastCheckFailed {
|
||||
category: crate::error_taxonomy::ErrorCategory::Authentication,
|
||||
..
|
||||
}
|
||||
),
|
||||
"{readiness:?}"
|
||||
);
|
||||
assert!(!format!("{readiness:?}").contains("sk-env-rejected"));
|
||||
|
||||
let action = picker.handle_key(key(KeyCode::Enter));
|
||||
assert!(
|
||||
matches!(action, ViewAction::None),
|
||||
"Enter must not re-apply the refused key"
|
||||
);
|
||||
assert_eq!(picker.stage, Stage::KeyEntry);
|
||||
assert!(picker.api_key_input.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_codex_row_is_experimental_and_tagged_in_hint() {
|
||||
let config = Config::default();
|
||||
|
||||
+105
-2
@@ -1184,7 +1184,15 @@ fn ignore_stale_stream_event_while_idle(event: &EngineEvent) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
type ProviderKeyVerification<'a> = Pin<Box<dyn Future<Output = Result<(), String>> + Send + 'a>>;
|
||||
/// `Ok` carries the `/models` roster the probe already downloaded, when it
|
||||
/// was one complete listing (see [`crate::client::verify_provider_api_key`]).
|
||||
type ProviderKeyVerification<'a> = Pin<
|
||||
Box<
|
||||
dyn Future<Output = Result<Option<codewhale_config::catalog::ProviderCatalogDelta>, String>>
|
||||
+ Send
|
||||
+ 'a,
|
||||
>,
|
||||
>;
|
||||
|
||||
pub(crate) fn request_foreground_shell_background(app: &mut App) {
|
||||
if !app.is_loading {
|
||||
@@ -1531,6 +1539,7 @@ mod provider_key_validation_tests {
|
||||
|
||||
struct MockProviderKeyVerifier {
|
||||
result: Result<(), String>,
|
||||
roster: Option<codewhale_config::catalog::ProviderCatalogDelta>,
|
||||
calls: std::sync::Mutex<Vec<(ProviderKind, String, String)>>,
|
||||
}
|
||||
|
||||
@@ -1538,10 +1547,16 @@ mod provider_key_validation_tests {
|
||||
fn new(result: Result<(), String>) -> Self {
|
||||
Self {
|
||||
result,
|
||||
roster: None,
|
||||
calls: std::sync::Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_roster(mut self, roster: codewhale_config::catalog::ProviderCatalogDelta) -> Self {
|
||||
self.roster = Some(roster);
|
||||
self
|
||||
}
|
||||
|
||||
fn calls(&self) -> Vec<(ProviderKind, String, String)> {
|
||||
self.calls.lock().expect("calls lock").clone()
|
||||
}
|
||||
@@ -1559,7 +1574,9 @@ mod provider_key_validation_tests {
|
||||
api_key.to_string(),
|
||||
base_url.to_string(),
|
||||
));
|
||||
Box::pin(std::future::ready(self.result.clone()))
|
||||
Box::pin(std::future::ready(
|
||||
self.result.clone().map(|()| self.roster.clone()),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1635,6 +1652,92 @@ mod provider_key_validation_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Release QA: a DeepSeek key probe returned two served ids, yet guided
|
||||
/// setup offered catalog rows the endpoint rejects at the first turn. The
|
||||
/// roster the probe already downloaded must become the route's model list.
|
||||
#[tokio::test]
|
||||
async fn provider_key_probe_roster_becomes_the_model_pick_roster() {
|
||||
use codewhale_config::catalog::{
|
||||
CatalogOffering, CatalogSource, ProviderCatalogDelta, base_url_fingerprint, now_unix,
|
||||
};
|
||||
struct Reset;
|
||||
impl Drop for Reset {
|
||||
fn drop(&mut self) {
|
||||
crate::provider_catalog_live::reset_cache_for_test();
|
||||
crate::provider_lake::clear_live_snapshot();
|
||||
}
|
||||
}
|
||||
let _config_env = ConfigPathEnvGuard::new();
|
||||
let _live = crate::provider_lake::lock_live_snapshot();
|
||||
let _reset = Reset;
|
||||
crate::provider_catalog_live::reset_cache_for_test();
|
||||
let mut app = create_test_app();
|
||||
let mut engine = mock_engine_handle();
|
||||
let mut config = Config::default();
|
||||
let identity = picker_provider_identity(&config, ProviderKind::Deepseek, None)
|
||||
.expect("DeepSeek identity");
|
||||
let mut scoped = config.clone();
|
||||
scoped
|
||||
.scope_to_provider_identity(&identity)
|
||||
.expect("scope DeepSeek");
|
||||
let base_url = scoped.active_route_base_url();
|
||||
let fingerprint = base_url_fingerprint(&base_url);
|
||||
let fetched_at = now_unix();
|
||||
let served = ["deepseek-flash", "deepseek-v4-pro"];
|
||||
// Scoped to the provider kind, exactly as the live probe returns it,
|
||||
// plus an id the catalog does not offer as a chat model.
|
||||
let roster = ProviderCatalogDelta {
|
||||
provider: "deepseek".into(),
|
||||
base_url_fingerprint: fingerprint.clone(),
|
||||
fetched_at,
|
||||
offerings: served
|
||||
.iter()
|
||||
.chain(&["deepseek-embedding-fixture"])
|
||||
.map(|id| CatalogOffering {
|
||||
provider: "deepseek".into(),
|
||||
wire_model_id: (*id).into(),
|
||||
endpoint_key: "chat".into(),
|
||||
source: CatalogSource::Live {
|
||||
base_url_fingerprint: fingerprint.clone(),
|
||||
fetched_at,
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
assert_ne!(
|
||||
crate::provider_lake::catalog_models_for_route(
|
||||
ProviderKind::Deepseek,
|
||||
identity.key.as_str(),
|
||||
&base_url,
|
||||
),
|
||||
served,
|
||||
"precondition: the catalog fallback lists more than the endpoint serves"
|
||||
);
|
||||
let verifier = MockProviderKeyVerifier::new(Ok(())).with_roster(roster);
|
||||
|
||||
apply_provider_picker_api_key_with_verifier(
|
||||
&mut app,
|
||||
&mut engine.handle,
|
||||
&mut config,
|
||||
identity.clone(),
|
||||
"sk-verified".to_string(),
|
||||
None,
|
||||
&verifier,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ProviderPicker));
|
||||
assert_eq!(
|
||||
crate::provider_lake::catalog_models_for_route(
|
||||
ProviderKind::Deepseek,
|
||||
identity.key.as_str(),
|
||||
&base_url,
|
||||
),
|
||||
served
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_key_submit_opens_model_pick_without_persisting_on_validation_success() {
|
||||
let config_env = ConfigPathEnvGuard::new();
|
||||
|
||||
@@ -273,6 +273,10 @@ pub(crate) fn apply_engine_error_to_app(
|
||||
{
|
||||
app.offline_mode = true;
|
||||
app.onboarding_needs_api_key = true;
|
||||
// The key was rejected, not missing: Esc returns to the composer and
|
||||
// the picker focuses the configured route, as missing-key recovery
|
||||
// does, instead of walking back through first-run screens.
|
||||
app.onboarding_missing_key_recovery = true;
|
||||
app.onboarding = OnboardingState::Provider;
|
||||
let provider = app.api_provider;
|
||||
let config_path = match crate::config::resolve_load_config_path(app.config_path.clone()) {
|
||||
@@ -280,11 +284,20 @@ pub(crate) fn apply_engine_error_to_app(
|
||||
Ok(None) => "~/.codewhale/config.toml".to_string(),
|
||||
Err(error) => error.to_string(),
|
||||
};
|
||||
let notice = tr(app.ui_locale, MessageId::OnboardApiKeyRejectedEnv)
|
||||
.replace("{provider}", provider.as_str())
|
||||
.replace("{env}", &provider.provider().env_vars().join(" / "))
|
||||
.replace("{path}", &config_path);
|
||||
// The setup screen covers the transcript, so it must say why it
|
||||
// opened. The log records the reason without the provider's message,
|
||||
// which the transcript already carries.
|
||||
crate::logging::warn(format!(
|
||||
"{} rejected the environment API key; opening provider setup",
|
||||
provider.as_str()
|
||||
));
|
||||
app.onboarding_key_rejected = Some(notice.clone());
|
||||
app.push_status_toast(
|
||||
tr(app.ui_locale, MessageId::OnboardApiKeyRejectedEnv)
|
||||
.replace("{provider}", provider.as_str())
|
||||
.replace("{env}", &provider.provider().env_vars().join(" / "))
|
||||
.replace("{path}", &config_path),
|
||||
notice,
|
||||
StatusToastLevel::Error,
|
||||
Some(App::STICKY_ERROR_TTL_MS),
|
||||
);
|
||||
@@ -3609,7 +3622,8 @@ pub(crate) async fn apply_provider_picker_test_connection_with_verifier(
|
||||
return;
|
||||
}
|
||||
match outcome {
|
||||
Ok(()) => {
|
||||
Ok(roster) => {
|
||||
publish_verified_roster(&identity, &base_url, roster);
|
||||
app.provider_health.record_models_probe_success(
|
||||
&scoped_config,
|
||||
&identity,
|
||||
@@ -3711,7 +3725,10 @@ pub(crate) async fn apply_provider_picker_api_key_with_verifier(
|
||||
return;
|
||||
}
|
||||
match outcome {
|
||||
Ok(()) => {
|
||||
Ok(roster) => {
|
||||
// Before the model pick reads the route roster: list what this
|
||||
// key can call today, not catalog rows the provider has retired.
|
||||
publish_verified_roster(&identity, &base_url, roster);
|
||||
// Keep the readiness row aligned with the live check the wizard
|
||||
// just completed. This probe only proves the endpoint and
|
||||
// credentials are reachable: the model is chosen after the probe,
|
||||
|
||||
@@ -83,6 +83,7 @@ pub(crate) fn onboarding_key_route(
|
||||
}
|
||||
|
||||
pub(crate) fn back_from_provider_onboarding(app: &mut App) {
|
||||
app.onboarding_key_rejected = None;
|
||||
if app.onboarding_missing_key_recovery {
|
||||
// A returning user declined missing-key recovery: leave onboarding
|
||||
// for the offline composer without mutating the saved route.
|
||||
@@ -132,6 +133,7 @@ pub(crate) fn complete_provider_picker_onboarding(app: &mut App, provider: Provi
|
||||
};
|
||||
app.onboarding_provider = provider;
|
||||
app.onboarding_needs_api_key = false;
|
||||
app.onboarding_key_rejected = None;
|
||||
// The route now has its key, so a later local-Ollama probe must not treat
|
||||
// this session as still recovering from a missing one.
|
||||
app.onboarding_missing_key_recovery = false;
|
||||
|
||||
@@ -28,6 +28,73 @@ impl ProviderKeyVerifier for LiveProviderKeyVerifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish the `/models` roster a successful key probe already downloaded as
|
||||
/// this exact route's live catalog (the #3385 cache and its provider-lake
|
||||
/// partition). A route roster is authoritative for the ids it lists and for
|
||||
/// its omissions, so the model pick that follows — and `/provider` and
|
||||
/// `/model` — offer what this key can call today instead of bundled or
|
||||
/// Models.dev rows the provider has retired. The probe scopes rows to the
|
||||
/// provider kind; ownership here is the exact route identity, so a named or
|
||||
/// regional table keeps its own partition.
|
||||
///
|
||||
/// The endpoint decides availability; the catalog still decides which listed
|
||||
/// ids are chat models. A first roster for a route keeps only the ids the
|
||||
/// catalog already offers there (case-insensitively, in the provider's own
|
||||
/// spelling), so an OpenAI-style `/models` that also lists embedding, speech
|
||||
/// and image models does not flood setup. When the catalog knows none of the
|
||||
/// ids, or the route already has a roster, the probe's roster is kept whole.
|
||||
///
|
||||
/// Does not: list a served chat model the catalog does not know yet (until
|
||||
/// the catalog or `codewhale models --update` lists it — the same as before
|
||||
/// this probe was read), refresh on its own schedule, or remove the roster if
|
||||
/// the user abandons setup. The rows are secret-free facts about this
|
||||
/// endpoint; account-scoped endpoints are re-fenced by
|
||||
/// `begin_refresh_for_identity`.
|
||||
pub(crate) fn publish_verified_roster(
|
||||
identity: &crate::config::ProviderIdentity,
|
||||
base_url: &str,
|
||||
roster: Option<codewhale_config::catalog::ProviderCatalogDelta>,
|
||||
) {
|
||||
let Some(mut roster) = roster else {
|
||||
return;
|
||||
};
|
||||
let key = identity.key.as_str();
|
||||
let has_route_roster =
|
||||
crate::provider_catalog_live::cached_entry_for_route(identity.provider, key, base_url)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some_and(|entry| entry.fetched_at > 0);
|
||||
if !has_route_roster {
|
||||
// Without a route roster this is the catalog view: bundled, Models.dev
|
||||
// and signed rows for this provider.
|
||||
let offered =
|
||||
crate::provider_lake::catalog_models_for_route(identity.provider, key, base_url);
|
||||
let chat: Vec<_> = roster
|
||||
.offerings
|
||||
.iter()
|
||||
.filter(|row| {
|
||||
offered
|
||||
.iter()
|
||||
.any(|id| id.eq_ignore_ascii_case(&row.wire_model_id))
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
if !chat.is_empty() {
|
||||
roster.offerings = chat;
|
||||
}
|
||||
}
|
||||
let owner = identity.key.to_string();
|
||||
for row in &mut roster.offerings {
|
||||
row.provider.clone_from(&owner);
|
||||
}
|
||||
roster.provider = owner;
|
||||
let ticket =
|
||||
crate::provider_catalog_live::begin_refresh_for_identity(identity.provider, key, base_url);
|
||||
// `None` means a newer refresh for this route superseded the probe; its
|
||||
// rows win, which is the failure-preserving outcome we want.
|
||||
let _ = crate::provider_catalog_live::record_success_if_current(&ticket, roster);
|
||||
}
|
||||
|
||||
pub(crate) struct RuntimePresetFileSnapshot {
|
||||
pub(crate) path: PathBuf,
|
||||
pub(crate) contents: Option<Vec<u8>>,
|
||||
|
||||
Reference in new issue
Block a user