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:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-04 21:53:30 -07:00
1 parent c4fc9f2c82
commit 0c79ef28b1
11 files changed
+574 -195

No files matched your search

+196 -167
View File
@@ -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,
+4 -3
View File
@@ -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;
+57 -14
View File
@@ -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)]
+4
View File
@@ -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
+1
View File
@@ -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
+9
View File
@@ -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
}
+106 -3
View File
@@ -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
View File
@@ -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();
+23 -6
View File
@@ -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,
+2
View File
@@ -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;
+67
View File
@@ -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>>,