fix(provider): switching provider lands on its current model; /model lists the route's roster

Founder report: `/provider stepfun` silently selected step-3.7-flash, far
older than step-5-preview, and `/model` did not show step-5-preview even
when searching for it.

Root causes:
- StepFun's built-in default was step-3.7-flash in the descriptor and the
  seed spec. `catalog_models_dev.py defaults audit` never flagged it
  because it treats `-preview` rows as non-replacements. Step 5 Preview is
  StepFun's current flagship and is fully wired (reasoning effort map,
  1M context, pricing), so the descriptor, seed spec and rendered seed
  `default` flag move to step-5-preview. A saved [providers.stepfun].model
  is still honoured. The audit's other recorded holds (openai, google,
  xai, meta) stay as recorded; meta was briefly moved and reverted to
  respect its DEFAULT_AUDIT_REASONS entry.
- The default /model view (#6533) showed only the current model, pins,
  recents and one default per provider; the current route's other models
  were behind search. The existing custom-relay roster rule now covers the
  current route of any provider: up to 20 rows of its own roster (bundled
  plus live), newest first, in a new trailing `Roster` section so the
  one-default-per-provider invariant holds.
- In the picker search, Shift+P and Shift+F pinned or added the
  highlighted row to Fleet instead of typing, so `Step-5-Preview` became
  `Step-5-review` and matched nothing (#6500 made them act during search).
  While searching they are now query text; Ctrl+P / Ctrl+F act on the
  highlighted row at any time and the footer advertises them while
  searching. Search also matches ids with separators removed (`step5`).

Known gap, not fixed here: live rows on a non-default official host (e.g.
StepFun Step Plan `/step_plan/v1`) are not completed from bundled facts,
because `CatalogOffering::complete_from` requires the live fingerprint to
match the bundled row's published base; they show "reasoning unknown" and
no context. Widening that needs a per-fact rule (pricing must not cross
PAYG/plan hosts).

Proof (dev build of this tree, founder-shaped CODEWHALE_HOME copy with the
Step Plan route and its cached live roster, saved stepfun model removed):
`/provider stepfun` -> status line `StepFun / StepFlash · step-5-preview`;
`/model` lists step-5-preview, step-3.7-flash, step-3.5-flash-2603,
step-3.5-flash; searches `step5` and `Step-5-Preview` both find
step-5-preview; Ctrl+P during search pins it.

Checks: codewhale-config 805 passed, 1 failed (the StepFun default
assertion in catalog/tests.rs, updated; rerun 1 passed); route::export
golden regenerated with WRITE_GOLDEN=1 (1 passed); codewhale-agent 70
passed; codewhale-tui tui::model_picker 33 passed; commands::groups::core::
provider 37 passed; scripts/catalog_models_dev_test.py 18 passed.

Touched paths: CHANGELOG.md, crates/tui/CHANGELOG.md, crates/agent/src/lib.rs,
crates/config/assets/models_dev.bundled.json,
crates/config/assets/provider_descriptors.json,
crates/config/src/catalog/tests.rs,
crates/config/src/route/providers-export.golden.json,
crates/tui/src/tui/model_picker.rs, docs/PROVIDERS.md,
scripts/catalog/models_dev_seed.toml. No provider_lake/provider_catalog_live
edits.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-27 01:01:36 -07:00
1 parent 3400ebb5c9
commit 7d2308ac81
10 files changed
+89 -55

No files matched your search

+1
View File
@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- **Switching provider lands on its current model:** `/provider stepfun` now selects Step 5 Preview instead of the older Step 3.7 Flash; a model you saved for a provider is still kept. `/model` now lists the current provider's own models (bundled and live, newest first, up to 20) instead of only the selected one. Search also matches ids typed without separators (`step5`). While you search, Shift+P and Shift+F type their letters rather than pinning or adding to Fleet, so `Step-5-Preview` can be typed in full; Ctrl+P and Ctrl+F pin or add the highlighted row at any time.
- **Remote control works again:** `codewhale rc` (`/rc`) can attach to the hosted control plane for web Chat again. The control plane had only accepted the retired 0.9.13 runtime identity, so every 0.10.x Engine was refused with `runner_runtime_identity_unavailable`; it now accepts the exact released runtime identity (commit, image and digest) and fails closed when they are not configured.
- **Modern MCP client:** MCP servers now connect through the official MCP SDK (2.4) behind Codewhale's Rust broker by default (`mcp_backend = "host"`; `"rust"` stays available for one release). Servers that speak the 2026-07-28 revision are discovered with `server/discover`; other stdio and HTTP servers fall back to the legacy `initialize` handshake, and configured SSE servers keep theirs. Rust keeps process, network, credential, catalog, approval and cancellation authority. (CW-6713)
- **MCP compatibility fixes:** older servers that answer the discovery probe with an error or an unrelated result now connect through the legacy handshake instead of being refused after the probe. A server that rejects sign-in reports its real 401 and the `/mcp login` hint rather than the probe's "method not found". A stale OAuth token is refreshed once when the probe itself is rejected. A server whose command cannot start now says why (for example, command not found) instead of a generic broker refusal.
+2 -2
View File
@@ -1131,7 +1131,7 @@ mod tests {
for (provider, id) in [
(ProviderKind::Zai, "GLM-5.2"),
(ProviderKind::Stepfun, "step-3.7-flash"),
(ProviderKind::Stepfun, "step-5-preview"),
(ProviderKind::Minimax, "MiniMax-M2.1"),
(ProviderKind::MinimaxAnthropic, "MiniMax-M3"),
(ProviderKind::Openmodel, "deepseek-v4-flash"),
@@ -1307,7 +1307,7 @@ mod tests {
let stepfun = registry.resolve_ok(None, Some(ProviderKind::Stepfun));
assert_eq!(stepfun.resolved.provider, ProviderKind::Stepfun);
assert_eq!(stepfun.resolved.id, "step-3.7-flash");
assert_eq!(stepfun.resolved.id, "step-5-preview");
for (alias, expected) in [
("minimax", "MiniMax-M3"),
+1 -1
View File
@@ -6102,7 +6102,6 @@
"knowledge": "2026-03-01",
"release_date": "2026-05-29",
"last_updated": "2026-05-29",
"default": true,
"attachment": true,
"reasoning": true,
"reasoning_options": [
@@ -6150,6 +6149,7 @@
"description": "StepFun's next-generation flagship base model for coding and professional knowledge work, with native text, image, and video input and a 1M-token context window",
"release_date": "2026-09-16",
"last_updated": "2026-09-20",
"default": true,
"attachment": true,
"reasoning": true,
"reasoning_options": [
@@ -1133,7 +1133,7 @@
"id": "stepfun",
"label": "StepFun / StepFlash",
"base_url": "https://api.stepfun.ai/v1",
"default_model": "step-3.7-flash",
"default_model": "step-5-preview",
"env_vars": [
"STEPFUN_API_KEY",
"STEP_API_KEY"
+1 -1
View File
@@ -1433,7 +1433,7 @@ fn stepfun_bundled_coding_models_preserve_default_and_plan_pricing_boundary() {
.find(|row| row.default_for_provider)
.unwrap()
.wire_model_id,
"step-3.7-flash"
"step-5-preview"
);
for row in &rows {
assert_eq!(row.reasoning, Some(true));
@@ -1249,7 +1249,7 @@
"label": "StepFun / StepFlash",
"endpoint": "https://api.stepfun.ai/v1",
"wire": "chat-completions",
"defaultModel": "step-3.7-flash",
"defaultModel": "step-5-preview",
"envVars": [
"STEPFUN_API_KEY",
"STEP_API_KEY"
@@ -1271,12 +1271,12 @@
},
{
"id": "step-3.7-flash",
"canonical": "stepfun/step-3.7-flash",
"default": true
"canonical": "stepfun/step-3.7-flash"
},
{
"id": "step-5-preview",
"canonical": "stepfun/step-5-preview"
"canonical": "stepfun/step-5-preview",
"default": true
}
],
"transport": "chat-completions"
+1
View File
@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- **Switching provider lands on its current model:** `/provider stepfun` now selects Step 5 Preview instead of the older Step 3.7 Flash; a model you saved for a provider is still kept. `/model` now lists the current provider's own models (bundled and live, newest first, up to 20) instead of only the selected one. Search also matches ids typed without separators (`step5`). While you search, Shift+P and Shift+F type their letters rather than pinning or adding to Fleet, so `Step-5-Preview` can be typed in full; Ctrl+P and Ctrl+F pin or add the highlighted row at any time.
- **Remote control works again:** `codewhale rc` (`/rc`) can attach to the hosted control plane for web Chat again. The control plane had only accepted the retired 0.9.13 runtime identity, so every 0.10.x Engine was refused with `runner_runtime_identity_unavailable`; it now accepts the exact released runtime identity (commit, image and digest) and fails closed when they are not configured.
- **Modern MCP client:** MCP servers now connect through the official MCP SDK (2.4) behind Codewhale's Rust broker by default (`mcp_backend = "host"`; `"rust"` stays available for one release). Servers that speak the 2026-07-28 revision are discovered with `server/discover`; other stdio and HTTP servers fall back to the legacy `initialize` handshake, and configured SSE servers keep theirs. Rust keeps process, network, credential, catalog, approval and cancellation authority. (CW-6713)
- **MCP compatibility fixes:** older servers that answer the discovery probe with an error or an unrelated result now connect through the legacy handshake instead of being refused after the probe. A server that rejects sign-in reports its real 401 and the `/mcp login` hint rather than the probe's "method not found". A stale OAuth token is refreshed once when the probe itself is rejected. A server whose command cannot start now says why (for example, command not found) instead of a generic broker refusal.
+74 -42
View File
@@ -358,19 +358,20 @@ enum DefaultSection {
Pinned,
Recent,
ProviderDefault,
Roster,
}
/// 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.
/// Most rows the current route's roster, or one custom relay's 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;
/// Hiding all of a route's roster 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 ROUTE_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
@@ -2453,28 +2454,44 @@ 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.
// The current route's roster and a custom relay's roster are what the
// user came to choose from, and the default view used to hide every row
// but the provider default -- the panel listed a dozen models and `/model`
// showed one. Surface each such route's own rows, bounded per route so a
// large gateway cannot take the view over: newest first for a built-in
// provider, the relay's own listing order for a custom relay.
let current_identity = current.map(|(identity, _)| identity);
let mut roster: Vec<usize> = rows
.iter()
.enumerate()
.filter(|(_, row)| {
row.default_rank.is_none()
&& row.not_listed.is_none()
&& !model_is_non_language(row)
&& row_provider_identity(row).is_some_and(|identity| {
current_identity == Some(identity)
|| (row.provider == Some(ProviderKind::Custom)
&& matches!(row.metadata.source, Some(CatalogSource::Live { .. })))
})
})
.map(|(index, _)| index)
.collect();
roster.sort_by_cached_key(|&index| {
let row = &rows[index];
(row.provider != Some(ProviderKind::Custom))
.then(|| std::cmp::Reverse(model_version_key(&row.id)))
});
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 {
for index in roster {
let Some(identity) = row_provider_identity(&rows[index]).map(str::to_string) else {
continue;
};
let shown = shown_per_route.entry(identity).or_default();
if *shown >= CUSTOM_ROSTER_ROWS_IN_DEFAULT_VIEW {
if *shown >= ROUTE_ROSTER_ROWS_IN_DEFAULT_VIEW {
continue;
}
*shown += 1;
row.default_rank = Some((DefaultSection::ProviderDefault, usize::MAX));
rows[index].default_rank = Some((DefaultSection::Roster, usize::MAX));
}
}
@@ -3035,10 +3052,17 @@ fn model_row_matches_query(
return true;
}
let normalized_query = normalize_picker_search_text(&query);
let compact = |text: &str| {
text.chars()
.filter(char::is_ascii_alphanumeric)
.collect::<String>()
};
let compact_query = compact(&query);
let matches = |candidate: &str| {
let candidate = candidate.to_ascii_lowercase();
candidate.contains(&query)
|| normalize_picker_search_text(&candidate).contains(&normalized_query)
|| (!compact_query.is_empty() && compact(&candidate).contains(&compact_query))
};
let provider_matches = row.provider.is_some_and(|provider| {
row.provider_identity.as_deref().is_some_and(matches)
@@ -4320,16 +4344,16 @@ impl ModalView for ModelPickerView {
{
self.explain_unselectable_selection()
}
// ⇧P / ⇧F act on the highlighted row, searching or not: a search
// is how a model is found, and the verbs used to become query
// text the moment one was typed (#6500). The filter ignores case,
// so only a custom id being typed (no catalog row highlighted)
// still receives the capital letter. Some terminals report the
// ⇧P / ⇧F act on the highlighted row while no search is typed;
// while searching they are query text, so `Step-5-Preview` types
// its `P` instead of pinning a row. Ctrl+P / Ctrl+F act on the
// highlighted row either way, so a model found by search can still
// be pinned or added to Fleet (#6500). Some terminals report the
// chord as lowercase + SHIFT, hence `eq_ignore_ascii_case`.
KeyCode::Char(ch)
if key.modifiers == KeyModifiers::SHIFT
&& ch.eq_ignore_ascii_case(&'p')
&& (self.query.is_empty() || self.highlighted_route().is_some()) =>
if ch.eq_ignore_ascii_case(&'p')
&& ((key.modifiers == KeyModifiers::SHIFT && self.query.is_empty())
|| key.modifiers == KeyModifiers::CONTROL) =>
{
let Some((provider, provider_id, model)) = self.highlighted_route() else {
return ViewAction::None;
@@ -4341,10 +4365,10 @@ impl ModalView for ModelPickerView {
})
}
KeyCode::Char(ch)
if key.modifiers == KeyModifiers::SHIFT
&& ch.eq_ignore_ascii_case(&'f')
if ch.eq_ignore_ascii_case(&'f')
&& self.purpose == ModelPickerPurpose::Session
&& (self.query.is_empty() || self.highlighted_route().is_some()) =>
&& ((key.modifiers == KeyModifiers::SHIFT && self.query.is_empty())
|| key.modifiers == KeyModifiers::CONTROL) =>
{
let Some((provider, provider_id, model)) = self.highlighted_route() else {
return ViewAction::None;
@@ -4635,17 +4659,23 @@ impl ModelPickerView {
}
// Keep compact route modals focused on the core browse/apply actions;
// wider shells have room to disclose the pin action too.
// ⇧P / ⇧F act only on a highlighted catalog route; without one
// (typing a custom id) they are query text, so they are not offered.
// Pin and Fleet act only on a highlighted catalog route. While a
// search is typed the shifted letters are query text, so the Ctrl
// chords are advertised instead.
if inner.width >= 72 && inner.height >= 16 && self.highlighted_route().is_some() {
let (fleet_key, pin_key) = if searching {
("Ctrl+F", "Ctrl+P")
} else {
("⇧F", "⇧P")
};
if self.purpose == ModelPickerPurpose::Session {
footer_hints.push(ActionHint::new(
"⇧F",
fleet_key,
tr(self.locale, MessageId::PickerActionFleet),
));
}
footer_hints.push(ActionHint::new(
"⇧P",
pin_key,
tr(self.locale, MessageId::PickerActionPin),
));
}
@@ -6450,7 +6480,7 @@ model = "deepseek/deepseek-v4-flash"
},
..Default::default()
};
let roster: Vec<_> = (0..CUSTOM_ROSTER_ROWS_IN_DEFAULT_VIEW + 5)
let roster: Vec<_> = (0..ROUTE_ROSTER_ROWS_IN_DEFAULT_VIEW + 5)
.map(|index| offering(&format!("relay-{index}")))
.collect();
crate::provider_catalog_live::record_success_if_current(
@@ -6478,7 +6508,7 @@ model = "deepseek/deepseek-v4-flash"
.collect();
assert_eq!(
visible.len(),
CUSTOM_ROSTER_ROWS_IN_DEFAULT_VIEW,
ROUTE_ROSTER_ROWS_IN_DEFAULT_VIEW,
"the relay's own roster shows in the default view, bounded per route: {visible:?}"
);
assert!(
@@ -6799,10 +6829,12 @@ api_key = "fixture-xai-key"
"at most one default per provider: {rows:#?}"
);
}
// The active provider's roster stays one search away.
assert!(
!rows.iter().any(|(_, id, _)| id == "deepseek-v4-pro"),
"{rows:#?}"
rows.iter()
.any(|(identity, id, section)| identity == "deepseek"
&& id == "deepseek-v4-pro"
&& *section == Some(DefaultSection::Roster)),
"the active provider's roster is listed: {rows:#?}"
);
picker.update_query("GLM-5.2".to_string());
+3 -3
View File
@@ -901,8 +901,8 @@ overlay and lets DSH resolve its own keys.
StepFun's four coding models are available through both its standard API and
[Step Plan](https://platform.stepfun.ai/docs/en/step-plan/integrations/reasoning-api).
Choose the billing route in `/provider`, then the model in `/model`; existing
Step 3.7 selections remain unchanged. [Step 5 Preview](https://platform.stepfun.ai/docs/en/guides/models/step-5-preview)
Choose the billing route in `/provider`, then the model in `/model`; Step 5
Preview is the default, and an existing Step 3.7 selection remains unchanged. [Step 5 Preview](https://platform.stepfun.ai/docs/en/guides/models/step-5-preview)
has a 1M context window. Step 5 and Step 3.7 expose low/medium/high reasoning;
Step 3.5 Flash 2603 exposes low/high; base Step 3.5 uses provider-default reasoning.
[Published API prices](https://platform.stepfun.ai/docs/en/guides/pricing/details)
@@ -1217,7 +1217,7 @@ See [Catalog refresh](CATALOG_REFRESH.md) for freshness and estimate boundaries.
| `arcee` | `trinity-large-thinking`, `trinity-large-preview`; provider-hinted custom model IDs pass through | yes | yes for `trinity-large-thinking`; no for `trinity-large-preview` |
| `moonshot` | `kimi-k2.7-code`, `kimi-k2.6` | yes | yes |
| `zai` | `GLM-5.3`, `GLM-5.3-Flash`, `GLM-5.2`, `GLM-5.1`, `GLM-5-Turbo`; provider-hinted custom model IDs pass through | yes | yes |
| `stepfun` | `step-3.7-flash` (default), `step-5-preview`, `step-3.5-flash`, `step-3.5-flash-2603` | yes | yes |
| `stepfun` | `step-5-preview` (default), `step-3.7-flash`, `step-3.5-flash`, `step-3.5-flash-2603` | yes | yes |
| `minimax` | `MiniMax-M3`, `MiniMax-M2.1`, `MiniMax-M2.1-highspeed`, `MiniMax-M2`; `MiniMax-M2.7`, `MiniMax-M2.7-highspeed`, `MiniMax-M2.5` and `MiniMax-M2.5-highspeed` stay accepted until their 2026-11-18 retirement | yes | yes |
| `minimax-anthropic` | `MiniMax-M3`, `MiniMax-M2.7` | yes | yes |
| `sglang` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes |
+1 -1
View File
@@ -358,7 +358,7 @@ name = "StepFun / StepFlash"
api = "https://api.stepfun.ai/v1"
npm = "@ai-sdk/openai-compatible"
env = ["STEPFUN_API_KEY", "STEP_API_KEY"]
default = "step-3.7-flash"
default = "step-5-preview"
models = [
"step-3.5-flash",
"step-3.5-flash-2603",