fix(exec): read the account model listing before the first request

Account listings (the Codewhale account, Baseten and plugin account
catalogs) are kept only in the memory of the process that read them, by
design: they are credential-scoped and are never reloaded from disk
(provider_catalog_live::maybe_load_persisted_cache_for_config forgets them).
The interactive TUI refreshes its listing at launch and the desktop app
asks its Engine to, but exec, review and the workflow tool never did, so a
fresh CLI process resolved an account model with no route limits and sent
the model-only default. exec, review and the workflow tool now run one
awaited account-listing refresh before their first request when the route
is account-scoped, off the async runtime (spawn_blocking + block_on, as the
Runtime API refresh route does). If the listing cannot be read the run
continues with built-in limits and prints one stderr line, naming the
refresh failure with the model picker's labels (catalog_refresh_error_label,
now pub(crate)) rather than the receipt's "cache_write_failed", which a
rejected listing also produced. The account-scope rule now has one
definition (route_is_account_scoped). Not covered: auto
and inventory model selection, which runs before the refresh; Runtime API
and app-server clients that never ask for a refresh; saved non-account
rosters, which exec still does not load. docs/PROVIDERS.md (both
languages) says so; the CHANGELOG line also covers fe2f1d8d03, which
carried the parser half without one.

Verification: checked once on the final batch tree (parent db89057d84):
cargo fmt --check clean; tui lib nextest across route_budget,
provider_catalog_live, provider_lake, mounted_history_tests,
session_manager, client, core::engine, compaction, tools::subagent,
runtime_api, purge, commands::contract, tui::model_picker and whats_new
2757 passed, 0 failed; tui integration exec_turn_usage 11 passed, 0
failed; feature_registry 2 passed, 0 failed; clippy -p codewhale-tui
--lib --tests -D warnings with the CI allow-list clean; check-blocking-
calls-budget 540 sites, within budget. Not run: hosted CI and the
loopback product captures (those follow on the release build).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-21 17:07:04 -07:00
1 parent f35645acbc
commit d92c96a25e
9 files changed
+83 -8

No files matched your search

+7
View File
@@ -106,6 +106,13 @@ guidance.
counted, instead of rewriting the file and then reporting that nothing
changed. Refs #6955. Thanks @7jrxt42BxFZo4iAnN4CX.
- The input, output and context limits your Codewhale account states for a
model now apply to its requests. The desktop app and interactive sessions
take them from the account's model listing, and `codewhale exec`,
`codewhale review` and the workflow tool read that listing once before
their first request; if it cannot be read, the run continues with built-in
limits and says so on stderr.
- When a route states an output limit larger than half its context window,
as a server that shares one window between input and output does,
Codewhale compacts the conversation to keep a quarter of the window (at
+7
View File
@@ -106,6 +106,13 @@ guidance.
counted, instead of rewriting the file and then reporting that nothing
changed. Refs #6955. Thanks @7jrxt42BxFZo4iAnN4CX.
- The input, output and context limits your Codewhale account states for a
model now apply to its requests. The desktop app and interactive sessions
take them from the account's model listing, and `codewhale exec`,
`codewhale review` and the workflow tool read that listing once before
their first request; if it cannot be read, the run continues with built-in
limits and says so on stderr.
- When a route states an output limit larger than half its context window,
as a server that shares one window between input and output does,
Codewhale compacts the conversation to keep a quarter of the window (at
+1
View File
@@ -378,6 +378,7 @@ pub(crate) async fn run_exec_agent(
let route = resolve_cli_exec_route(config, model, prompt, force_configured_route).await?;
let execution_config = config_for_cli_route(config, &route)?;
crate::provider_lake::refresh_account_catalog_before_first_request(&execution_config).await;
let auto_model = route.auto_model;
let effective_identity = execution_config
.active_provider_identity()
+2
View File
@@ -10370,6 +10370,7 @@ async fn run_review(config: &Config, args: ReviewArgs) -> Result<()> {
.expect("review has at least one prompt");
let route = resolve_cli_exec_route(config, &model, route_input, force_configured_route).await?;
let execution_config = config_for_cli_route(config, &route)?;
crate::provider_lake::refresh_account_catalog_before_first_request(&execution_config).await;
let route_provider = execution_config
.active_provider_identity()
.map_err(anyhow::Error::msg)?
@@ -14317,6 +14318,7 @@ async fn run_workflow_tool_command_inner(
)
.await?;
let execution_config = config_for_cli_route(&config, &route)?;
crate::provider_lake::refresh_account_catalog_before_first_request(&execution_config).await;
let route_identity = execution_config
.active_provider_identity()
.map_err(anyhow::Error::msg)
+14 -4
View File
@@ -683,6 +683,19 @@ fn is_account_scoped_scope(provider: &str, fingerprint: &str) -> bool {
== base_url_fingerprint(ProviderKind::Codewhale.provider().default_base_url())
}
pub(crate) fn route_is_account_scoped(
config: &Config,
identity: &crate::config::ProviderIdentity,
) -> bool {
is_account_scoped_scope(
&storage_provider(
identity.provider,
&canonical_provider_scope(identity.key.as_str()),
),
&base_url_fingerprint(&config.base_url_for_route(identity)),
)
}
fn cache_lock_path(path: &Path) -> PathBuf {
let mut name = path
.file_name()
@@ -1248,10 +1261,7 @@ pub fn maybe_load_persisted_cache_for_config(config: &Config) -> usize {
let provider = identity.provider;
let provider_identity = canonical_provider_scope(identity.key.as_str());
let fingerprint = base_url_fingerprint(&config.base_url_for_route(&identity));
if is_account_scoped_scope(
&storage_provider(provider, &provider_identity),
&fingerprint,
) {
if route_is_account_scoped(config, &identity) {
forget_account_scoped_provider(provider, &provider_identity);
return 0;
}
+44
View File
@@ -1868,6 +1868,50 @@ pub(crate) async fn update_provider_catalog(
receipt
}
pub(crate) async fn refresh_account_catalog_before_first_request(config: &Config) {
let Ok(identity) = config.active_provider_identity() else {
return;
};
if !crate::provider_catalog_live::route_is_account_scoped(config, &identity)
|| live_catalog_unavailable(config, &identity)
{
return;
}
let handle = tokio::runtime::Handle::current();
let refresh_config = config.clone();
let refresh_identity = identity.clone();
let receipt = tokio::task::spawn_blocking(move || {
handle.block_on(update_provider_catalog(&refresh_config, &refresh_identity))
})
.await
.ok();
let status = crate::provider_catalog_live::status_for_route(
identity.provider,
identity.key.as_str(),
&config.active_route_base_url(),
);
if !matches!(status, CatalogStatus::Fresh) {
let reason = match &status {
CatalogStatus::Failed { reason } => {
crate::tui::model_picker::catalog_refresh_error_label(*reason)
}
_ => receipt.map_or("refresh_failed", |receipt| {
receipt.error.unwrap_or(receipt.outcome)
}),
};
tracing::warn!(
target: "provider_catalog",
provider = %identity.key,
reason,
"account model listing unavailable before the first request"
);
eprintln!(
"codewhale: could not read the {} account model listing ({reason}); requesting with built-in model limits.",
identity.key
);
}
}
pub(crate) async fn run_models(
config: &Config,
update: bool,
+1 -1
View File
@@ -3952,7 +3952,7 @@ fn provider_catalog_source_label(receipt: Option<&(CatalogStatus, bool)>) -> Str
}
}
fn catalog_refresh_error_label(error: CatalogRefreshError) -> &'static str {
pub(crate) fn catalog_refresh_error_label(error: CatalogRefreshError) -> &'static str {
match error {
CatalogRefreshError::Unauthorized => "unauthorized",
CatalogRefreshError::Forbidden => "forbidden",
+6 -2
View File
@@ -177,8 +177,12 @@ that invocation.
Plain `models` lists the active provider's saved catalog without provider requests.
For ChatGPT, it validates the selected registration locally before reading
that account's saved roster. Successful refreshes are saved under Codewhale's
catalog directory and used by the model/provider pickers. Cache files are
scoped to provider identity and endpoint; a failed refresh preserves prior
catalog directory and used by the model/provider pickers, except account
listings (the Codewhale account, Baseten and plugin account catalogs), which
stay in the memory of the process that read them; `exec`, `review` and the
workflow tool therefore read the account listing once before their first
request, and continue with built-in limits if it cannot be read. Cache files
are scoped to provider identity and endpoint; a failed refresh preserves prior
rows. Text output reports source, last successful fetch time (Unix seconds),
and freshness. `--update --json` adds per-source receipts and aggregate counts;
partial failures return a nonzero exit code after writing those receipts.
+1 -1
View File
@@ -85,7 +85,7 @@ codewhale models --provider openai --json
`models --update`(也可写作 `--refresh`)会更新共享的 Models.dev 元数据,并用每个已配置提供商各自的凭据调用其现有的 `/models` 端点。`--provider ID` 把刷新限定在该提供商,包括具名的自定义端点。它不发起任何推理请求,也不会更改已保存的提供商或模型。命令行传入的 API 密钥只限于当前提供商;本次调用中其他路由会被报告为已跳过。
不带 `--update` 的 `models` 会列出当前提供商已保存的目录,不发起提供商请求。ChatGPT 路由会先在本地验证所选注册,再读取该账户的缓存目录。刷新成功后,结果保存在 Codewhale 的目录文件夹下,供模型/提供商选择器使用。缓存文件按提供商身份和端点划分;刷新失败时会保留原有行。文本输出报告来源、最近一次成功获取的时间(Unix 秒)和新鲜度。`--update --json` 会额外给出每个来源的回执和汇总计数;部分失败时,会在写完这些回执后返回非零退出码。普通的 `models --json` 仍保持模型数组格式。随附的或已配置的回退项并不能证明账户可以使用列出的每个模型。
不带 `--update` 的 `models` 会列出当前提供商已保存的目录,不发起提供商请求。ChatGPT 路由会先在本地验证所选注册,再读取该账户的缓存目录。刷新成功后,结果保存在 Codewhale 的目录文件夹下,供模型/提供商选择器使用;账户目录(Codewhale 账户、Baseten 和插件账户目录)除外,它们只保留在读取它们的进程内存中,因此 `exec`、`review` 和工作流工具会在第一次请求前读取一次账户模型列表,读取失败时使用内置限制继续运行。缓存文件按提供商身份和端点划分;刷新失败时会保留原有行。文本输出报告来源、最近一次成功获取的时间(Unix 秒)和新鲜度。`--update --json` 会额外给出每个来源的回执和汇总计数;部分失败时,会在写完这些回执后返回非零退出码。普通的 `models --json` 仍保持模型数组格式。随附的或已配置的回退项并不能证明账户可以使用列出的每个模型。
### 使用 ChatGPT 登录