From 8324b60d8686d64167bcf1e2408cf4663113c7f8 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Mon, 12 Oct 2026 18:20:32 -0700 Subject: [PATCH 01/26] =?UTF-8?q?WIP:=200.10.2=20catalog/pricing=20slice?= =?UTF-8?q?=20(#6396)=20+=20takeover=20fixes=20=E2=80=94=20DO=20NOT=20MERG?= =?UTF-8?q?E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handoff snapshot for cloud takeover. Endpoint-scoped catalog facts, exact tiered pricing, regenerated models.dev seed (2026-10-12), plus fixes for the 21 full-suite failures it introduced (reasoning ladders, tier cache rates, usage breakdowns, aggregator suffix match, status baseline). Evidence at snapshot: fmt/check-versions/locale/provider/dead-code clean; tui pricing 97 passed, models_dev 21 passed, feature_registry 2 passed, cargo check -p codewhale-cli clean; focused 36/36 pass. Known red: codewhale-config 797 passed, 2 failed (openrouter_custom_endpoint_preserves_qwen37_alias, resolver_direct_owned_row_match_survives_casing_mismatch — constrain the new suffix match). Full TUI suite not yet rerun after fixes. Slice 2 notes, PR landings, and crate-size gate still outstanding. Stop at ready-to-tag; no tag/publish authorized here. Refs #6396 --- CHANGELOG.md | 13 +- Cargo.lock | 48 + crates/cli/src/lib.rs | 92 +- crates/config/Cargo.toml | 1 + crates/config/assets/catalog_corrections.json | 77 +- crates/config/assets/models_dev.bundled.json | 1803 ++++++++++++++--- crates/config/src/catalog.rs | 3 +- crates/config/src/catalog/configured.rs | 16 +- crates/config/src/catalog/tests.rs | 27 +- crates/config/src/model_reference.rs | 12 +- crates/config/src/models_dev.rs | 6 +- crates/config/src/pricing.rs | 483 ++++- crates/config/src/pricing/tests.rs | 51 +- .../src/route/providers-export.golden.json | 197 +- crates/config/src/route/resolver.rs | 29 + crates/localization/locales/ca.json | 1 + crates/localization/locales/de.json | 1 + crates/localization/locales/en.json | 1 + crates/localization/locales/es-419.json | 1 + crates/localization/locales/fr.json | 1 + crates/localization/locales/hi.json | 1 + crates/localization/locales/id.json | 1 + crates/localization/locales/ja.json | 1 + crates/localization/locales/ko.json | 1 + crates/localization/locales/pt-BR.json | 1 + crates/localization/locales/ru.json | 1 + crates/localization/locales/uk.json | 1 + crates/localization/locales/vi.json | 1 + crates/localization/locales/zh-Hans.json | 1 + crates/localization/locales/zh-Hant.json | 1 + crates/localization/src/lib.rs | 2 + crates/models/src/lib.rs | 6 + crates/runtime/src/llm_response_cache.rs | 5 + crates/tui/CHANGELOG.md | 13 +- crates/tui/src/client.rs | 103 + crates/tui/src/client/anthropic.rs | 43 +- crates/tui/src/client/responses.rs | 11 + crates/tui/src/client/wire.rs | 19 + .../contract/config_policy_baseline.rs | 8 + .../commands/contract/debug_diagnostics.rs | 5 + .../fixtures/config_policy/status.json | 2 +- .../commands/debug_diagnostics_host_tests.rs | 2 + crates/tui/src/config/tests.rs | 1 + .../src/core/engine/tests/test_cases_07.rs | 8 +- crates/tui/src/core/session.rs | 15 + crates/tui/src/core/turn.rs | 17 + crates/tui/src/cost_status.rs | 5 + crates/tui/src/hooks/executor.rs | 10 + crates/tui/src/models_dev_live.rs | 25 +- crates/tui/src/pricing.rs | 580 +++--- .../pricing/default_coverage_unpriced.json | 4 +- crates/tui/src/provider_catalog_live.rs | 291 ++- crates/tui/src/provider_lake.rs | 11 +- crates/tui/src/reasoning_preference.rs | 65 +- crates/tui/src/rlm/bridge.rs | 14 +- crates/tui/src/rlm/mod.rs | 41 +- crates/tui/src/rlm/turn.rs | 13 +- crates/tui/src/route_budget.rs | 6 +- crates/tui/src/route_preferences.rs | 19 + crates/tui/src/runtime_api.rs | 47 +- crates/tui/src/runtime_api/context.rs | 41 +- crates/tui/src/runtime_api/tests.rs | 6 + crates/tui/src/scorecard.rs | 5 +- crates/tui/src/tui/app.rs | 4 +- crates/tui/src/tui/app/init.rs | 8 +- crates/tui/src/tui/model_picker.rs | 43 +- crates/tui/src/tui/ui/tests.rs | 5 + docs/CATALOG_REFRESH.md | 26 +- docs/PROVIDERS.md | 39 +- docs/features.toml | 2 +- scripts/catalog/models_dev_seed.lock.json | 1689 ++++++++++++--- scripts/catalog/models_dev_seed.toml | 79 +- scripts/catalog_models_dev.py | 10 +- scripts/catalog_models_dev_test.py | 3 +- web/data/agent-docs.generated.json | 4 +- web/data/features.generated.json | 4 +- 76 files changed, 5040 insertions(+), 1192 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5fcc25205..f09894e828 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.10.2] - 2026-10-10 +- Model lists, resolution, picker controls and Runtime facts use the same + endpoint-scoped catalog facts. Cost estimates preserve complete price tiers, + calculate exact decimal amounts per request, and freeze rates at dispatch. + Missing rates or required usage breakdowns stay unpriced; public prices do + not establish account access. The offline seed records its source snapshot + and keeps absent default wire IDs as dated compatibility fallbacks. Refs #6396. + - Desktop and HTTP app-server turns feed an existing shared pet companion when its followed session has no live producer. Automatic selection is checked atomically by the owner; a live TUI keeps its source. The Runtime producer @@ -444,12 +451,6 @@ launcher guidance. - Idle CPU has not been measured on FreeBSD or Linux for this release ([#6728](https://github.com/codewhale-hq/Codewhale/issues/6728)). -- Models.dev prices with context tiers are not applied. A turn at or above a - model's first context tier reads as unpriced ("pricing tier unavailable") - and is counted as missing from the cost total, instead of being priced at - its base rate. The bundled catalog has no tiered rows, so this appears only - after a live models.dev refresh - ([#6396](https://github.com/codewhale-hq/Codewhale/issues/6396)). - Shell commands run by delegated agents appear in `/jobs` and the work dock with their owner, not in the agent's own detail view; named terminal sessions live in the terminal dock diff --git a/Cargo.lock b/Cargo.lock index b0d1c7a58b..0917ccd7d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -181,6 +181,12 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "ascii" version = "1.1.0" @@ -571,6 +577,30 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases 0.2.2", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "bstr" version = "1.13.0" @@ -937,6 +967,7 @@ dependencies = [ "fd-lock", "libc", "ring", + "rust_decimal", "semver", "serde", "serde_json", @@ -4858,6 +4889,23 @@ dependencies = [ "triomphe", ] +[[package]] +name = "rust_decimal" +version = "1.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7653272e75dcac41dc199fbea6f5797633994fafd339943c06c9af16bf29cd3a" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.7", + "rand 0.9.5", + "serde", + "serde_json", + "wasm-bindgen", +] + [[package]] name = "rustc-hash" version = "2.1.3" diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index b6d60f1766..e04be82a69 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -15,7 +15,6 @@ use std::process::Command; use anyhow::{Context, Result, anyhow, bail}; use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum}; use clap_complete::{Shell, generate}; -use codewhale_agent::ModelRegistry; use codewhale_app_server::RuntimeControlFrontend; use codewhale_config::credentials::{ clear_provider_api_key_from_config, provider_slot, set_provider_api_key, @@ -5793,7 +5792,6 @@ fn run_model_command( Ok(()) } ModelCommand::Resolve { model, provider } => { - let registry = ModelRegistry::default(); // Only `model resolve --provider X` is a hypothetical. The // top-level `--provider` is the route this process is actually on, // and it is already folded into `resolved_runtime` — treating it as @@ -5837,7 +5835,48 @@ fn run_model_command( "requested: {}", if source.is_explicit() { model } else { "" } ); - println!("resolved: {model}"); + let kind = ProviderKind::parse(provider).unwrap_or(ProviderKind::Custom); + let identity = if kind == ProviderKind::Custom { + store.config.named_custom_provider_id().unwrap_or(provider) + } else { + provider + }; + let base = if kind == resolved_runtime.provider { + resolved_runtime.base_url.clone() + } else { + store + .config + .providers + .for_provider(kind) + .base_url + .clone() + .unwrap_or_else(|| kind.provider().default_base_url().into()) + }; + let candidate = codewhale_tui::route_preferences::resolve_model( + store.path(), + identity, + &base, + Some(model), + ); + println!( + "resolved: {}", + candidate + .as_ref() + .map_or(model, |route| route.wire_model_id().as_str()) + ); + match candidate.as_ref() { + Ok(route) => { + if let Some(canonical) = route.canonical_model() { + println!("canonical_model: {}", canonical.as_str()); + } + println!("limits: {}", serde_json::to_string(&route.limits())?); + println!( + "capabilities: {}", + serde_json::to_string(&route.capabilities())? + ); + } + Err(error) => println!("route_error: {error}"), + } println!("provider: {provider}"); println!("used_fallback: {}", !source.is_explicit()); println!( @@ -5859,12 +5898,47 @@ fn run_model_command( // inside a named route. The subcommand provider wins; otherwise // the configured runtime provider remains authoritative. Model // text never authorizes switching providers or credential slots. - let provider_hint = subcommand_provider.or(Some(resolved_runtime.provider)); - let resolved = registry.resolve(queried, provider_hint)?; - println!("requested: {}", resolved.requested.unwrap_or_default()); - println!("resolved: {}", resolved.resolved.id); - println!("provider: {}", resolved.resolved.provider.as_str()); - println!("used_fallback: {}", resolved.used_fallback); + let provider = subcommand_provider.unwrap_or(resolved_runtime.provider); + let identity = if provider == ProviderKind::Custom && subcommand_provider.is_none() { + store + .config + .named_custom_provider_id() + .unwrap_or(provider.as_str()) + } else { + provider.as_str() + }; + let base = if provider == resolved_runtime.provider { + resolved_runtime.base_url.clone() + } else { + store + .config + .providers + .for_provider(provider) + .base_url + .clone() + .unwrap_or_else(|| provider.provider().default_base_url().into()) + }; + let resolved = codewhale_tui::route_preferences::resolve_model( + store.path(), + identity, + &base, + queried, + )?; + println!("requested: {}", queried.unwrap_or_default()); + println!("resolved: {}", resolved.wire_model_id().as_str()); + println!("provider: {identity}"); + println!( + "used_fallback: {}", + queried.is_none() || queried == Some("auto") + ); + if let Some(canonical) = resolved.canonical_model() { + println!("canonical_model: {}", canonical.as_str()); + } + println!("limits: {}", serde_json::to_string(&resolved.limits())?); + println!( + "capabilities: {}", + serde_json::to_string(&resolved.capabilities())? + ); println!( "provider_source: {}", if subcommand_provider.is_some() { diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 5daa6a4225..c4fdc54600 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -16,6 +16,7 @@ anyhow.workspace = true base64 = "0.23.1" chrono.workspace = true ring = "0.17.14" +rust_decimal = { version = "1.43.0", default-features = false, features = ["std"] } semver.workspace = true codewhale-execpolicy = { path = "../execpolicy", version = "0.10.2" } codewhale-paths = { path = "../paths", version = "0.10.2" } diff --git a/crates/config/assets/catalog_corrections.json b/crates/config/assets/catalog_corrections.json index 82bb58bd43..d9a4ac8bfc 100644 --- a/crates/config/assets/catalog_corrections.json +++ b/crates/config/assets/catalog_corrections.json @@ -1,15 +1,11 @@ { "_about": "Codewhale's corrections to Models.dev rows (#6396). Applied to the bundled seed and to live Models.dev rows alike, above both and below signed cloud facts and provider rosters, so a correction holds on every install, online or offline. Corrections only fix rows that exist: they never add or hide one. Each entry says why; a correction whose value now equals upstream is stale and should be deleted. See docs/CATALOG_REFRESH.md.", - "revision": "2026-09-26", + "revision": "2026-10-12", "providers": [ { "provider": "deepseek", "pricing_withheld": "DeepSeek bills by time of day (off-peak rates and a weekend rule). The time-aware table in crates/tui/src/pricing.rs prices these routes; one flat catalog rate would disagree with it." }, - { - "provider": "xai", - "pricing_withheld": "xAI doubles its rates once a prompt passes 200K tokens. A flat catalog rate would understate every long session." - }, { "provider": "xiaomi-mimo", "pricing_withheld": "Published PAYG rates apply only to sk- pay-as-you-go keys. Token Plan keys draw on quota, and the catalog cannot tell the two billing surfaces apart." @@ -65,79 +61,18 @@ "id": "GLM-5.3", "pricing_withheld": "Codewhale's default Z.ai route is the GLM Coding Plan (DEFAULT_ZAI_BASE_URL, api.z.ai/api/coding/paas/v4), which bills credit multipliers, not USD per token; see the seed's pending_release_metadata." }, - { - "provider": "openrouter", - "id": "deepseek/deepseek-v4-pro", - "pricing_withheld": "Codewhale prices hosted DeepSeek from the reviewed provider-docs table in crates/tui/src/pricing.rs; a catalog rate would outrank it (for Fireworks V4 Pro the table has 1.74 and Models.dev 1.20 per 1M input)." - }, - { - "provider": "openrouter", - "id": "deepseek/deepseek-v4-flash", - "pricing_withheld": "Codewhale prices hosted DeepSeek from the reviewed provider-docs table in crates/tui/src/pricing.rs; a catalog rate would outrank it (for Fireworks V4 Pro the table has 1.74 and Models.dev 1.20 per 1M input)." - }, - { - "provider": "together", - "id": "deepseek-ai/DeepSeek-V4-Pro", - "pricing_withheld": "Codewhale prices hosted DeepSeek from the reviewed provider-docs table in crates/tui/src/pricing.rs; a catalog rate would outrank it (for Fireworks V4 Pro the table has 1.74 and Models.dev 1.20 per 1M input)." - }, - { - "provider": "together", - "id": "deepseek-ai/DeepSeek-V4-Flash", - "pricing_withheld": "Codewhale prices hosted DeepSeek from the reviewed provider-docs table in crates/tui/src/pricing.rs; a catalog rate would outrank it (for Fireworks V4 Pro the table has 1.74 and Models.dev 1.20 per 1M input)." - }, - { - "provider": "fireworks", - "id": "accounts/fireworks/models/deepseek-v4-pro", - "pricing_withheld": "Codewhale prices hosted DeepSeek from the reviewed provider-docs table in crates/tui/src/pricing.rs; a catalog rate would outrank it (for Fireworks V4 Pro the table has 1.74 and Models.dev 1.20 per 1M input)." - }, - { - "provider": "novita", - "id": "deepseek/deepseek-v4-pro", - "pricing_withheld": "Codewhale prices hosted DeepSeek from the reviewed provider-docs table in crates/tui/src/pricing.rs; a catalog rate would outrank it (for Fireworks V4 Pro the table has 1.74 and Models.dev 1.20 per 1M input)." - }, - { - "provider": "novita", - "id": "deepseek/deepseek-v4-flash", - "pricing_withheld": "Codewhale prices hosted DeepSeek from the reviewed provider-docs table in crates/tui/src/pricing.rs; a catalog rate would outrank it (for Fireworks V4 Pro the table has 1.74 and Models.dev 1.20 per 1M input)." - }, - { - "provider": "siliconflow", - "id": "deepseek-ai/DeepSeek-V4-Pro", - "pricing_withheld": "Codewhale prices hosted DeepSeek from the reviewed provider-docs table in crates/tui/src/pricing.rs; a catalog rate would outrank it (for Fireworks V4 Pro the table has 1.74 and Models.dev 1.20 per 1M input)." - }, - { - "provider": "siliconflow", - "id": "deepseek-ai/DeepSeek-V4-Flash", - "pricing_withheld": "Codewhale prices hosted DeepSeek from the reviewed provider-docs table in crates/tui/src/pricing.rs; a catalog rate would outrank it (for Fireworks V4 Pro the table has 1.74 and Models.dev 1.20 per 1M input)." - }, { "provider": "novita", "id": "deepseek/deepseek-v4-pro", "max_output": 384000, "reason": "DeepSeek publishes a 384K output limit; Models.dev lists 393216 for Novita (384 x 1024). Which one the API accepts is unverified, and the lower bound is accepted either way." }, - { - "provider": "novita", - "id": "deepseek/deepseek-v4-flash", - "max_output": 384000, - "reason": "DeepSeek publishes a 384K output limit; Models.dev lists 393216 for Novita (384 x 1024). Which one the API accepts is unverified, and the lower bound is accepted either way." - }, { "provider": "xai", "id": "grok-4.3", "reasoning_options": [], "reason": "xAI documents no reasoning_effort control for grok-4.3, so Codewhale sends none (6be755f31 / #6501); Models.dev lists an effort ladder." }, - { - "provider": "minimax", - "id": "MiniMax-M3", - "pricing_withheld": "MiniMax doubles M3 rates once a prompt passes 512K tokens. A flat catalog rate would understate long sessions." - }, - { - "provider": "minimax-anthropic", - "id": "MiniMax-M3", - "pricing_withheld": "MiniMax doubles M3 rates once a prompt passes 512K tokens. A flat catalog rate would understate long sessions." - }, { "provider": "minimax", "id": "MiniMax-M3", @@ -409,6 +344,16 @@ "cache_read_per_m": 0.25 }, "reason": "Arcee publishes no cache rate (docs.arcee.ai/get-started/pricing), so cache hits bill at the input rate, as in the reviewed table in crates/tui/src/pricing.rs; Models.dev lists a 0.06 cache-read rate." + }, + { + "provider": "fireworks", + "id": "accounts/fireworks/models/deepseek-v4-pro", + "pricing": { + "input_per_m": 1.74, + "output_per_m": 3.48, + "cache_read_per_m": 0.145 + }, + "reason": "Fireworks hosts DeepSeek V4 Pro at its own published rates, as in the reviewed table in crates/tui/src/pricing.rs; Models.dev lists a flat 1.2/1.2 rate with a 0.6 cache-read rate for this deployment." } ], "reviewed": { diff --git a/crates/config/assets/models_dev.bundled.json b/crates/config/assets/models_dev.bundled.json index 373cdc7e27..f46b2f7949 100644 --- a/crates/config/assets/models_dev.bundled.json +++ b/crates/config/assets/models_dev.bundled.json @@ -2,20 +2,24 @@ "_meta": { "about": "Offline fallback Models.dev-shaped catalog snapshot for Codewhale (#3385, demoted by #4188, generated since #6396).", "schema": "Matches crates/config/src/models_dev.rs ModelsDevCatalog ({ models, providers }).", + "compatibility_fallback": "Legacy Together and Fireworks defaults and previously reviewed curated IDs retain their dated metadata when absent upstream; they do not prove current availability. See each curated reason. Refreshed provider/catalog rows and explicit configuration remain authoritative.", "role": "NOT a competing source of truth. Preferred metadata is the live Models.dev catalog published into ProviderLake (#4187). This asset is used only when live/cache rows are unavailable (offline startup, failed refresh, or empty cache).", "source": "Rows are projected unedited from Models.dev onto the fields models_dev.rs reads; provider headers, id mapping, defaults and canonical joins come from scripts/catalog/models_dev_seed.toml.", "corrections": "Deliberate holds (withheld prices, clamped limits) are not in this file. They live in crates/config/assets/catalog_corrections.json and apply to this seed and to live refresh alike.", "default_rows": "Each provider's `default: true` wire id equals that provider's built-in DEFAULT_*_MODEL so RouteResolver::new() and the descriptor stay in agreement when offline.", "pending_release_metadata": "GLM-5.3 is live on the Z.ai Coding Plan (docs.z.ai/devpack/overview and docs.z.ai/devpack/latest-model, recorded 2026-08-13) and is the default direct Z.ai model (DEFAULT_ZAI_MODEL); explicit GLM-5.2 selections keep their own id. First-party wire id is GLM-5.3; OpenRouter mirror is z-ai/glm-5.3. Capability/limit/dialect values still inherit from GLM-5.2 until Z.ai publishes distinct 5.3 numbers. Pricing stays absent: Coding Plan publishes credit multipliers, not a USD PAYG row we can stand behind. Z.ai may auto-route GLM-5.2/GLM-5.1 requests to GLM-5.3 on their side; Codewhale still sends the selected picker id. Do not send a [1m] suffix. Scope stays first-party Z.ai plus the OpenRouter mirror; add third-party gateway rows only against that gateway's own published roster.", "generated_by": "python3 scripts/catalog_models_dev.py seed render from scripts/catalog/models_dev_seed.toml and scripts/catalog/models_dev_seed.lock.json. Do not edit this file by hand; see docs/CATALOG_REFRESH.md.", - "upstream": "https://models.dev/catalog.json fetched 2026-09-26T09:07:10Z sha256 1e559956cfa16dad77e1fd5f1f38ae533276b397aca15c2b0ebb71d6e50e64a3", - "coverage": "21 providers, 100 provider model rows, 4 canonical model entries." + "upstream": "https://models.dev/catalog.json fetched 2026-10-12T15:41:34Z sha256 fc7b3ca17b868cd3854b57df7aae683df6e62067acce54e2bdbac49b8665df53", + "coverage": "21 providers, 95 provider model rows, 4 canonical model entries." }, "models": { - "deepseek-v4-pro": { - "id": "deepseek-v4-pro", - "name": "DeepSeek V4 Pro", + "deepseek/deepseek-v4-pro-0813": { + "id": "deepseek/deepseek-v4-pro-0813", + "name": "DeepSeek V4 Pro 0813", + "description": "DeepSeek V4 Pro snapshot with million-token context and support for thinking and non-thinking modes", "family": "deepseek-thinking", + "release_date": "2026-08-12", + "last_updated": "2026-08-22", "attachment": false, "reasoning": true, "tool_call": true, @@ -33,13 +37,106 @@ "limit": { "context": 1000000, "output": 384000 - } + }, + "license": "MIT", + "weights": [ + { + "label": "Hugging Face", + "url": "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" + } + ], + "benchmarks": [ + { + "name": "Humanity's Last Exam", + "score": 42.7, + "metric": "score", + "variant": "without tools", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" + }, + { + "name": "Humanity's Last Exam", + "score": 60, + "metric": "score", + "variant": "with tools", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" + }, + { + "name": "Terminal-Bench", + "score": 87.9, + "metric": "score", + "harness": "DeepSeek Harness minimal mode", + "variant": "max effort", + "version": "2.1", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" + }, + { + "name": "NL2Repo", + "score": 61.5, + "metric": "score", + "harness": "DeepSeek Harness minimal mode", + "variant": "max effort", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" + }, + { + "name": "CyberGym", + "score": 83.3, + "metric": "score", + "harness": "DeepSeek Harness minimal mode", + "variant": "max effort", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" + }, + { + "name": "DeepSWE", + "score": 62.7, + "metric": "score", + "harness": "DeepSeek Harness minimal mode", + "variant": "max effort", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" + }, + { + "name": "Toolathlon-Verified", + "score": 74.1, + "metric": "score", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" + }, + { + "name": "Agents' Last Exam", + "score": 25.7, + "metric": "score", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" + }, + { + "name": "AutomationBench", + "score": 31.8, + "metric": "score", + "dataset": "public", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" + }, + { + "name": "DSBench-FullStack", + "score": 71.1, + "metric": "score", + "dataset": "internal", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" + }, + { + "name": "DSBench-Hard", + "score": 67.2, + "metric": "score", + "dataset": "internal", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" + } + ] }, - "deepseek-v4-flash": { - "id": "deepseek-v4-flash", - "name": "DeepSeek V4 Flash", + "deepseek/deepseek-v4.1-flash": { + "id": "deepseek/deepseek-v4.1-flash", + "name": "DeepSeek V4.1 Flash", + "description": "DeepSeek V4.1 Flash model for reasoning and agentic coding", "family": "deepseek-flash", - "attachment": false, + "knowledge": "2025-05", + "release_date": "2026-09-10", + "last_updated": "2026-09-10", + "attachment": true, "reasoning": true, "tool_call": true, "structured_output": true, @@ -47,7 +144,8 @@ "open_weights": true, "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" @@ -56,12 +154,307 @@ "limit": { "context": 1000000, "output": 384000 - } + }, + "license": "MIT", + "weights": [ + { + "label": "Hugging Face", + "url": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + } + ], + "benchmarks": [ + { + "name": "GPQA Diamond", + "score": 90.9, + "metric": "pass@1", + "variant": "reasoning_effort=100", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "MathArena Apex", + "score": 65.6, + "metric": "pass@1", + "variant": "reasoning_effort=100", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "Humanity's Last Exam", + "score": 36.8, + "metric": "pass@1", + "variant": "reasoning_effort=100", + "dataset": "full set", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "Humanity's Last Exam", + "score": 39.1, + "metric": "pass@1", + "variant": "reasoning_effort=100", + "dataset": "text-only subset", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "Codeforces", + "score": 3471, + "metric": "rating", + "variant": "reasoning_effort=100", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "Terminal-Bench", + "score": 90.6, + "metric": "pass@1", + "harness": "DeepSeek Harness minimal mode", + "variant": "reasoning_effort=100", + "version": "2.1", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "Terminal-Bench", + "score": 30, + "metric": "pass@1", + "harness": "DeepSeek Harness minimal mode", + "variant": "reasoning_effort=100", + "version": "3.0", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "Terminal-Bench", + "score": 31.2, + "metric": "pass@1", + "harness": "DeepSeek Harness minimal mode", + "variant": "reasoning_effort=100", + "version": "4.0", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "DeepSWE", + "score": 74.2, + "metric": "resolved", + "harness": "mini-swe-agent", + "variant": "reasoning_effort=100", + "version": "1.1", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "ProgramBench", + "score": 20.3, + "metric": "almost@1", + "harness": "DeepSeek Harness minimal mode", + "variant": "reasoning_effort=100", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "NL2Repo", + "score": 64, + "metric": "score", + "harness": "DeepSeek Harness minimal mode", + "variant": "reasoning_effort=100", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "CyberGym", + "score": 88.1, + "metric": "pass@1", + "variant": "reasoning_effort=100", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "ExploitGym", + "score": 15.3, + "metric": "pass@1", + "variant": "reasoning_effort=100", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "AutomationBench", + "score": 54.8, + "metric": "pass@1", + "variant": "reasoning_effort=100", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "Agents' Last Exam", + "score": 31.8, + "metric": "pass@1", + "variant": "reasoning_effort=100", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "SEC-Bench Pro", + "score": 62.8, + "metric": "pass@1", + "harness": "Claude Code", + "variant": "reasoning_effort=100", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "Humanity's Last Exam", + "score": 63.9, + "metric": "pass@1", + "variant": "reasoning_effort=100; with tools", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "Chartography", + "score": 78.9, + "metric": "pass@1", + "harness": "Claude Code", + "variant": "reasoning_effort=100; with tools", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "BabyVision", + "score": 89.6, + "metric": "pass@1", + "harness": "Claude Code", + "variant": "reasoning_effort=100; with tools", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "ZeroBench", + "score": 49, + "metric": "pass@5", + "harness": "Claude Code", + "variant": "reasoning_effort=100; with tools", + "dataset": "main", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "DeepSWE", + "score": 69.8, + "metric": "resolved", + "harness": "Claude Code", + "variant": "reasoning_effort=100", + "version": "1.1", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "Terminal-Bench", + "score": 88, + "metric": "pass@1", + "harness": "Claude Code", + "variant": "reasoning_effort=100", + "version": "2.1", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "DeepSWE", + "score": 65.6, + "metric": "resolved", + "harness": "Codex", + "variant": "reasoning_effort=100", + "version": "1.1", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "Terminal-Bench", + "score": 84.1, + "metric": "pass@1", + "harness": "Codex", + "variant": "reasoning_effort=100", + "version": "2.1", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "DeepSWE", + "score": 65.5, + "metric": "resolved", + "harness": "OpenCode", + "variant": "reasoning_effort=100", + "version": "1.1", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "Terminal-Bench", + "score": 85, + "metric": "pass@1", + "harness": "OpenCode", + "variant": "reasoning_effort=100", + "version": "2.1", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "DeepSWE", + "score": 66.2, + "metric": "resolved", + "harness": "Pi", + "variant": "reasoning_effort=100", + "version": "1.1", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "Terminal-Bench", + "score": 86.1, + "metric": "pass@1", + "harness": "Pi", + "variant": "reasoning_effort=100", + "version": "2.1", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "Terminal-Bench", + "score": 90.3, + "metric": "pass@1", + "harness": "mini-swe-agent", + "variant": "reasoning_effort=100", + "version": "2.1", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "DeepSWE", + "score": 72.6, + "metric": "resolved", + "harness": "DeepSeek Harness minimal mode", + "variant": "reasoning_effort=100", + "version": "1.1", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "DeepSWE", + "score": 70.5, + "metric": "resolved", + "harness": "DeepSeek Harness standard mode", + "variant": "reasoning_effort=100", + "version": "1.1", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "Terminal-Bench", + "score": 85.8, + "metric": "pass@1", + "harness": "DeepSeek Harness standard mode", + "variant": "reasoning_effort=100", + "version": "2.1", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "DeepSWE", + "score": 67.6, + "metric": "resolved", + "harness": "DeepSeek Harness PTC mode", + "variant": "reasoning_effort=100", + "version": "1.1", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "Terminal-Bench", + "score": 85.8, + "metric": "pass@1", + "harness": "DeepSeek Harness PTC mode", + "variant": "reasoning_effort=100", + "version": "2.1", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + } + ] }, "xiaomi/mimo-v2.6-pro": { "id": "xiaomi/mimo-v2.6-pro", "name": "MiMo-V2.6-Pro", + "description": "Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution", "family": "mimo", + "release_date": "2026-09-22", + "last_updated": "2026-09-22", "attachment": true, "reasoning": true, "tool_call": true, @@ -81,12 +474,147 @@ "limit": { "context": 1048576, "output": 131072 - } + }, + "weights": [ + { + "label": "Hugging Face", + "url": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Pro-RL" + } + ], + "benchmarks": [ + { + "name": "DeepSWE", + "score": 71.9, + "metric": "score", + "variant": "MiMo-V2.6 Pro comparison column", + "version": "1.1", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "ProgramBench", + "score": 26.5, + "metric": "score", + "variant": "MiMo-V2.6 Pro comparison column", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "MiMo Code Bench", + "score": 63.2, + "metric": "score", + "variant": "MiMo-V2.6 Pro comparison column", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "AutomationBench", + "score": 53.1, + "metric": "score", + "variant": "MiMo-V2.6 Pro comparison column", + "version": "1.0.6", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "Toolathlon-Verified", + "score": 76.9, + "metric": "score", + "variant": "MiMo-V2.6 Pro comparison column", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "GDPval-AA", + "score": 1673, + "metric": "Elo", + "variant": "MiMo-V2.6 Pro comparison column", + "version": "2.1", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "Agents' Last Exam", + "score": 31.6, + "metric": "score", + "variant": "MiMo-V2.6 Pro comparison column", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "Terminal-Bench", + "score": 34.9, + "metric": "score", + "variant": "MiMo-V2.6 Pro comparison column", + "version": "4.0", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "Terminal-Bench", + "score": 89.9, + "metric": "score", + "variant": "MiMo-V2.6 Pro comparison column", + "version": "2.1", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "OSWorld-Verified", + "score": 82, + "metric": "score", + "variant": "MiMo-V2.6 Pro comparison column", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "JobBench", + "score": 62, + "metric": "score", + "variant": "MiMo-V2.6 Pro comparison column", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "CyberGym", + "score": 94, + "metric": "score", + "variant": "MiMo-V2.6 Pro comparison column", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "MiMo Cyber Bench", + "score": 80.2, + "metric": "score", + "variant": "MiMo-V2.6 Pro comparison column", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "ExploitGym", + "score": 17.8, + "metric": "score", + "variant": "MiMo-V2.6 Pro comparison column", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "ExploitBench", + "score": 47.9, + "metric": "score", + "variant": "MiMo-V2.6 Pro comparison column", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "SEC-Bench Pro", + "score": 66.3, + "metric": "score", + "variant": "MiMo-V2.6 Pro comparison column", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "MiMo VisualCoding", + "score": 72.3, + "metric": "score", + "variant": "MiMo-V2.6 Pro comparison column", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + } + ] }, "xiaomi/mimo-v2.6-flash": { "id": "xiaomi/mimo-v2.6-flash", "name": "MiMo-V2.6-Flash", + "description": "MiMo Flash model for multimodal coding agents and long-context automation", "family": "mimo", + "release_date": "2026-09-22", + "last_updated": "2026-09-22", "attachment": true, "reasoning": true, "tool_call": true, @@ -106,7 +634,115 @@ "limit": { "context": 1048576, "output": 131072 - } + }, + "weights": [ + { + "label": "Hugging Face", + "url": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + } + ], + "benchmarks": [ + { + "name": "DeepSWE", + "score": 67.9, + "metric": "score", + "version": "1.1", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "ProgramBench", + "score": 26, + "metric": "score", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "MiMo Code Bench", + "score": 61.2, + "metric": "score", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "AutomationBench", + "score": 52.3, + "metric": "score", + "version": "1.0.6", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "Toolathlon-Verified", + "score": 73.6, + "metric": "score", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "Agents' Last Exam", + "score": 27.6, + "metric": "score", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "Terminal-Bench", + "score": 28.8, + "metric": "score", + "version": "4.0", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "Terminal-Bench", + "score": 87.6, + "metric": "score", + "version": "2.1", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "OSWorld-Verified", + "score": 80.8, + "metric": "score", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "JobBench", + "score": 61.2, + "metric": "score", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "CyberGym", + "score": 95.1, + "metric": "score", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "MiMo Cyber Bench", + "score": 77.2, + "metric": "score", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "ExploitGym", + "score": 6, + "metric": "score", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "ExploitBench", + "score": 25.3, + "metric": "score", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "SEC-Bench Pro", + "score": 47.5, + "metric": "score", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "MiMo VisualCoding", + "score": 71.5, + "metric": "score", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + } + ] } }, "providers": { @@ -121,9 +757,13 @@ "models": { "deepseek-v4-pro": { "id": "deepseek-v4-pro", - "base_model": "deepseek-v4-pro", + "base_model": "deepseek/deepseek-v4-pro-0813", + "canonical_model_id": "deepseek/deepseek-v4-pro-0813", "name": "DeepSeek V4 Pro", + "description": "DeepSeek V4 Pro snapshot with million-token context and support for thinking and non-thinking modes", "family": "deepseek-thinking", + "release_date": "2026-08-12", + "last_updated": "2026-08-22", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -159,16 +799,22 @@ "output": 393216 }, "cost": { - "input": 0.435, - "output": 0.87, - "cache_read": 0.003625 + "input": 0.66, + "output": 1.98, + "cache_read": 0.022, + "reasoning": 1.98 } }, "deepseek-v4-flash": { "id": "deepseek-v4-flash", - "base_model": "deepseek-v4-flash", + "base_model": "deepseek/deepseek-v4.1-flash", + "canonical_model_id": "deepseek/deepseek-v4.1-flash", "name": "DeepSeek V4 Flash", + "description": "DeepSeek V4.1 Flash model for reasoning and agentic coding", "family": "deepseek-flash", + "knowledge": "2025-05", + "release_date": "2026-09-10", + "last_updated": "2026-09-10", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -207,14 +853,21 @@ "cost": { "input": 0.15, "output": 0.6, - "cache_read": 0.003 - } + "cache_read": 0.003, + "reasoning": 0.6 + }, + "status": "deprecated" }, "deepseek-v4-flash-vision-exp": { "id": "deepseek-v4-flash-vision-exp", - "base_model": "deepseek-v4-flash", + "base_model": "deepseek/deepseek-v4.1-flash", + "canonical_model_id": "deepseek/deepseek-v4.1-flash", "name": "DeepSeek V4 Flash Vision Exp", + "description": "DeepSeek V4.1 Flash model for reasoning and agentic coding", "family": "deepseek-flash", + "knowledge": "2025-05", + "release_date": "2026-09-10", + "last_updated": "2026-09-10", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -253,14 +906,21 @@ "cost": { "input": 0.15, "output": 0.6, - "cache_read": 0.003 - } + "cache_read": 0.003, + "reasoning": 0.6 + }, + "status": "deprecated" }, "deepseek-flash": { "id": "deepseek-flash", - "base_model": "deepseek-flash", + "base_model": "deepseek/deepseek-v4.1-flash", + "canonical_model_id": "deepseek/deepseek-v4.1-flash", "name": "DeepSeek V4.1 Flash", + "description": "DeepSeek V4.1 Flash model for reasoning and agentic coding", "family": "deepseek-flash", + "knowledge": "2025-05", + "release_date": "2026-09-10", + "last_updated": "2026-09-10", "default": true, "attachment": true, "reasoning": true, @@ -300,7 +960,8 @@ "cost": { "input": 0.15, "output": 0.6, - "cache_read": 0.003 + "cache_read": 0.003, + "reasoning": 0.6 } } } @@ -318,8 +979,12 @@ "models": { "GLM-5.2": { "id": "GLM-5.2", + "canonical_model_id": "zhipuai/glm-5.2", "name": "GLM-5.2", + "description": "Open flagship GLM for long-horizon coding agents and million-token context work", "family": "glm", + "release_date": "2026-06-13", + "last_updated": "2026-06-13", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -359,8 +1024,12 @@ }, "GLM-5.3": { "id": "GLM-5.3", + "canonical_model_id": "zhipuai/glm-5.3", "name": "GLM-5.3", + "description": "Flagship GLM model for long-horizon coding, agents, and complex project delivery", "family": "glm", + "release_date": "2026-08-14", + "last_updated": "2026-08-14", "default": true, "attachment": false, "reasoning": true, @@ -402,8 +1071,12 @@ }, "GLM-5.3-Flash": { "id": "GLM-5.3-Flash", + "canonical_model_id": "zhipuai/glm-5.3-flash", "name": "GLM-5.3-Flash", + "description": "Native multimodal GLM model for efficient coding and long-horizon agent tasks", "family": "glm-flash", + "release_date": "2026-08-26", + "last_updated": "2026-08-26", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -447,8 +1120,12 @@ }, "glm-5.1": { "id": "glm-5.1", + "canonical_model_id": "zhipuai/glm-5.1", "name": "GLM-5.1", + "description": "Strong GLM coding model for agentic engineering, terminals, and repository generation", "family": "glm", + "release_date": "2026-04-07", + "last_updated": "2026-04-07", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -485,7 +1162,10 @@ "GLM-5-Turbo": { "id": "GLM-5-Turbo", "name": "GLM-5-Turbo", + "description": "Faster GLM-5 lane for coding agents that need lower latency", "family": "glm", + "release_date": "2026-03-16", + "last_updated": "2026-03-16", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -533,14 +1213,15 @@ "models": { "kimi-k3": { "id": "kimi-k3", + "canonical_model_id": "moonshotai/kimi-k3", "name": "Kimi K3", + "description": "Multimodal Kimi model with 1M context and toggleable max-effort thinking for long-horizon agent work", "family": "kimi-k3", + "release_date": "2026-07-16", + "last_updated": "2026-07-16", "attachment": true, "reasoning": true, "reasoning_options": [ - { - "type": "toggle" - }, { "type": "effort", "values": [ @@ -574,13 +1255,19 @@ "cost": { "input": 3, "output": 15, - "cache_read": 0.3 + "cache_read": 0.3, + "cache_write": 3 } }, "kimi-k2.7-code": { "id": "kimi-k2.7-code", + "canonical_model_id": "moonshotai/kimi-k2.7-code", "name": "Kimi K2.7 Code", + "description": "Coding-focused Kimi model, stronger on long-horizon repo work with less overthinking", "family": "kimi-k2", + "knowledge": "2025-01", + "release_date": "2026-06-12", + "last_updated": "2026-06-12", "default": true, "attachment": true, "reasoning": true, @@ -614,8 +1301,13 @@ }, "kimi-k2.7-code-highspeed": { "id": "kimi-k2.7-code-highspeed", + "canonical_model_id": "moonshotai/kimi-k2.7-code", "name": "Kimi K2.7 Code HighSpeed", + "description": "Coding-focused Kimi model, stronger on long-horizon repo work with less overthinking", "family": "kimi-k2", + "knowledge": "2025-01", + "release_date": "2026-06-12", + "last_updated": "2026-06-12", "attachment": true, "reasoning": true, "reasoning_options": [], @@ -649,7 +1341,11 @@ "kimi-k2.6": { "id": "kimi-k2.6", "name": "Kimi K2.6", + "description": "Multimodal Kimi workhorse for agent loops, coding tasks, and visual context", "family": "kimi-k2", + "knowledge": "2025-01", + "release_date": "2026-04-21", + "last_updated": "2026-04-21", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -698,7 +1394,10 @@ "MiniMax-M3": { "id": "MiniMax-M3", "name": "MiniMax-M3", + "description": "MiniMax multimodal model for long-context coding, perception, and agent planning", "family": "minimax", + "release_date": "2026-06-01", + "last_updated": "2026-06-25", "default": true, "attachment": true, "reasoning": true, @@ -727,13 +1426,32 @@ "cost": { "input": 0.3, "output": 1.2, - "cache_read": 0.06 + "cache_read": 0.06, + "tiers": [ + { + "input": 0.6, + "output": 2.4, + "cache_read": 0.12, + "tier": { + "type": "context", + "size": 512000 + } + } + ], + "context_over_200k": { + "input": 0.6, + "output": 2.4, + "cache_read": 0.12 + } } }, "MiniMax-M2.7": { "id": "MiniMax-M2.7", "name": "MiniMax-M2.7", + "description": "Open MiniMax flagship for coding agents, office automation, and complex environments", "family": "minimax", + "release_date": "2026-03-18", + "last_updated": "2026-03-18", "attachment": false, "reasoning": true, "reasoning_options": [], @@ -762,7 +1480,10 @@ "MiniMax-M2.7-highspeed": { "id": "MiniMax-M2.7-highspeed", "name": "MiniMax-M2.7-highspeed", + "description": "Low-latency M2.7 variant for interactive coding plans and agent loops", "family": "minimax", + "release_date": "2026-03-18", + "last_updated": "2026-03-18", "attachment": false, "reasoning": true, "reasoning_options": [], @@ -802,7 +1523,10 @@ "MiniMax-M3": { "id": "MiniMax-M3", "name": "MiniMax-M3", + "description": "MiniMax multimodal model for long-context coding, perception, and agent planning", "family": "minimax", + "release_date": "2026-06-01", + "last_updated": "2026-06-25", "default": true, "attachment": true, "reasoning": true, @@ -831,13 +1555,32 @@ "cost": { "input": 0.3, "output": 1.2, - "cache_read": 0.06 + "cache_read": 0.06, + "tiers": [ + { + "input": 0.6, + "output": 2.4, + "cache_read": 0.12, + "tier": { + "type": "context", + "size": 512000 + } + } + ], + "context_over_200k": { + "input": 0.6, + "output": 2.4, + "cache_read": 0.12 + } } }, "MiniMax-M2.7": { "id": "MiniMax-M2.7", "name": "MiniMax-M2.7", + "description": "Open MiniMax flagship for coding agents, office automation, and complex environments", "family": "minimax", + "release_date": "2026-03-18", + "last_updated": "2026-03-18", "attachment": false, "reasoning": true, "reasoning_options": [], @@ -866,7 +1609,10 @@ "MiniMax-M2.7-highspeed": { "id": "MiniMax-M2.7-highspeed", "name": "MiniMax-M2.7-highspeed", + "description": "Low-latency M2.7 variant for interactive coding plans and agent loops", "family": "minimax", + "release_date": "2026-03-18", + "last_updated": "2026-03-18", "attachment": false, "reasoning": true, "reasoning_options": [], @@ -906,8 +1652,12 @@ "models": { "qwen3.8-max": { "id": "qwen3.8-max", + "canonical_model_id": "alibaba/qwen3.8-max", "name": "Qwen3.8 Max", + "description": "2.4-trillion-parameter MoE flagship for coding, professional work, multimodal understanding, and long-horizon agentic workflows", "family": "qwen", + "release_date": "2026-08-03", + "last_updated": "2026-08-03", "default": true, "attachment": true, "reasoning": true, @@ -960,8 +1710,12 @@ }, "qwen3.8-max-preview": { "id": "qwen3.8-max-preview", + "canonical_model_id": "alibaba/qwen3.8-max-preview", "name": "Qwen3.8 Max Preview", + "description": "Preview Qwen flagship for million-token multimodal reasoning and long-horizon agentic workflows", "family": "qwen", + "release_date": "2026-07-19", + "last_updated": "2026-07-19", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -1005,12 +1759,18 @@ "output": 0, "cache_read": 0, "cache_write": 0 - } + }, + "status": "deprecated" }, "qwen3.7-plus": { "id": "qwen3.7-plus", + "canonical_model_id": "alibaba/qwen3.7-plus", "name": "Qwen3.7 Plus", + "description": "Multimodal Qwen workhorse for long-context agents, visual inputs, and coding", "family": "qwen", + "knowledge": "2025-04", + "release_date": "2026-06-02", + "last_updated": "2026-06-02", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -1052,8 +1812,12 @@ }, "qwen3.7-max": { "id": "qwen3.7-max", + "canonical_model_id": "alibaba/qwen3.7-max", "name": "Qwen3.7 Max", + "description": "Qwen frontier model tuned for agent frameworks, coding assistants, and long tasks", "family": "qwen", + "release_date": "2026-05-21", + "last_updated": "2026-05-21", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -1093,8 +1857,12 @@ }, "qwen3.6-flash": { "id": "qwen3.6-flash", + "canonical_model_id": "alibaba/qwen3.6-flash", "name": "Qwen3.6 Flash", + "description": "Qwen vision-language model for visual reasoning, documents, and agent tasks", "family": "qwen3.6", + "release_date": "2026-04-27", + "last_updated": "2026-04-27", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -1136,8 +1904,13 @@ }, "deepseek-v4-pro": { "id": "deepseek-v4-pro", + "canonical_model_id": "deepseek/deepseek-v4-pro", "name": "DeepSeek V4 Pro", + "description": "Open MoE flagship with million-token context for coding and long agent runs", "family": "deepseek-thinking", + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -1180,8 +1953,13 @@ }, "deepseek-v4-flash-0731": { "id": "deepseek-v4-flash-0731", + "canonical_model_id": "deepseek/deepseek-v4-flash-0731", "name": "DeepSeek V4 Flash 0731", + "description": "Official DeepSeek V4 Flash release with enhanced agentic capabilities and integrated DSpark speculative decoding", "family": "deepseek-flash", + "knowledge": "2025-05", + "release_date": "2026-07-31", + "last_updated": "2026-07-31", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -1224,8 +2002,12 @@ }, "glm-5.2": { "id": "glm-5.2", + "canonical_model_id": "zhipuai/glm-5.2", "name": "GLM-5.2", + "description": "Open flagship GLM for long-horizon coding agents and million-token context work", "family": "glm", + "release_date": "2026-06-13", + "last_updated": "2026-06-13", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -1277,8 +2059,12 @@ "models": { "qwen3.8-max": { "id": "qwen3.8-max", + "canonical_model_id": "alibaba/qwen3.8-max", "name": "Qwen3.8 Max", + "description": "2.4-trillion-parameter MoE flagship for coding, professional work, multimodal understanding, and long-horizon agentic workflows", "family": "qwen", + "release_date": "2026-08-03", + "last_updated": "2026-08-03", "default": true, "attachment": true, "reasoning": true, @@ -1331,8 +2117,12 @@ }, "qwen3.8-max-preview": { "id": "qwen3.8-max-preview", + "canonical_model_id": "alibaba/qwen3.8-max-preview", "name": "Qwen3.8 Max Preview", + "description": "Preview Qwen flagship for million-token multimodal reasoning and long-horizon agentic workflows", "family": "qwen", + "release_date": "2026-07-19", + "last_updated": "2026-07-19", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -1376,12 +2166,18 @@ "output": 0, "cache_read": 0, "cache_write": 0 - } + }, + "status": "deprecated" }, "qwen3.7-plus": { "id": "qwen3.7-plus", + "canonical_model_id": "alibaba/qwen3.7-plus", "name": "Qwen3.7 Plus", + "description": "Multimodal Qwen workhorse for long-context agents, visual inputs, and coding", "family": "qwen", + "knowledge": "2025-04", + "release_date": "2026-06-02", + "last_updated": "2026-06-02", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -1423,8 +2219,12 @@ }, "qwen3.7-max": { "id": "qwen3.7-max", + "canonical_model_id": "alibaba/qwen3.7-max", "name": "Qwen3.7 Max", + "description": "Qwen frontier model tuned for agent frameworks, coding assistants, and long tasks", "family": "qwen", + "release_date": "2026-05-21", + "last_updated": "2026-05-21", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -1464,8 +2264,12 @@ }, "qwen3.6-flash": { "id": "qwen3.6-flash", + "canonical_model_id": "alibaba/qwen3.6-flash", "name": "Qwen3.6 Flash", + "description": "Qwen vision-language model for visual reasoning, documents, and agent tasks", "family": "qwen3.6", + "release_date": "2026-04-27", + "last_updated": "2026-04-27", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -1507,8 +2311,13 @@ }, "deepseek-v4-pro": { "id": "deepseek-v4-pro", + "canonical_model_id": "deepseek/deepseek-v4-pro", "name": "DeepSeek V4 Pro", + "description": "Open MoE flagship with million-token context for coding and long agent runs", "family": "deepseek-thinking", + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -1551,8 +2360,13 @@ }, "deepseek-v4-flash-0731": { "id": "deepseek-v4-flash-0731", + "canonical_model_id": "deepseek/deepseek-v4-flash-0731", "name": "DeepSeek V4 Flash 0731", + "description": "Official DeepSeek V4 Flash release with enhanced agentic capabilities and integrated DSpark speculative decoding", "family": "deepseek-flash", + "knowledge": "2025-05", + "release_date": "2026-07-31", + "last_updated": "2026-07-31", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -1595,8 +2409,12 @@ }, "glm-5.2": { "id": "glm-5.2", + "canonical_model_id": "zhipuai/glm-5.2", "name": "GLM-5.2", + "description": "Open flagship GLM for long-horizon coding agents and million-token context work", "family": "glm", + "release_date": "2026-06-13", + "last_updated": "2026-06-13", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -1648,8 +2466,12 @@ "models": { "qwen3.8-max": { "id": "qwen3.8-max", + "canonical_model_id": "alibaba/qwen3.8-max", "name": "Qwen3.8 Max", + "description": "2.4-trillion-parameter MoE flagship for coding, professional work, multimodal understanding, and long-horizon agentic workflows", "family": "qwen", + "release_date": "2026-08-03", + "last_updated": "2026-08-03", "default": true, "attachment": true, "reasoning": true, @@ -1702,8 +2524,12 @@ }, "qwen3.8-max-preview": { "id": "qwen3.8-max-preview", + "canonical_model_id": "alibaba/qwen3.8-max-preview", "name": "Qwen3.8 Max Preview", + "description": "Preview Qwen flagship for million-token multimodal reasoning and long-horizon agentic workflows", "family": "qwen", + "release_date": "2026-07-19", + "last_updated": "2026-07-19", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -1747,12 +2573,18 @@ "output": 0, "cache_read": 0, "cache_write": 0 - } + }, + "status": "deprecated" }, "qwen3.7-plus": { "id": "qwen3.7-plus", + "canonical_model_id": "alibaba/qwen3.7-plus", "name": "Qwen3.7 Plus", + "description": "Multimodal Qwen workhorse for long-context agents, visual inputs, and coding", "family": "qwen", + "knowledge": "2025-04", + "release_date": "2026-06-02", + "last_updated": "2026-06-02", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -1784,78 +2616,15 @@ "cache_write": 0 } }, - "qwen3.7-max": { - "id": "qwen3.7-max", - "name": "Qwen3.7 Max", - "family": "qwen", - "attachment": false, - "reasoning": true, - "reasoning_options": [ - { - "type": "toggle" - } - ], - "tool_call": true, - "temperature": true, - "open_weights": false, - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 1000000, - "output": 65536 - }, - "cost": { - "input": 2.5, - "output": 7.5, - "cache_read": 0.5, - "cache_write": 3.125 - } - }, - "qwen3.6-flash": { - "id": "qwen3.6-flash", - "name": "Qwen3.6 Flash", - "family": "qwen3.6", - "attachment": true, - "reasoning": true, - "reasoning_options": [ - { - "type": "toggle" - } - ], - "tool_call": true, - "structured_output": true, - "temperature": true, - "open_weights": false, - "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 1000000, - "output": 65536 - }, - "cost": { - "input": 0.1875, - "output": 1.125, - "cache_write": 0.234375 - } - }, "deepseek-v4-pro": { "id": "deepseek-v4-pro", + "canonical_model_id": "deepseek/deepseek-v4-pro", "name": "DeepSeek V4 Pro", + "description": "Open MoE flagship with million-token context for coding and long agent runs", "family": "deepseek-thinking", + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -1898,8 +2667,13 @@ }, "deepseek-v4-flash-0731": { "id": "deepseek-v4-flash-0731", + "canonical_model_id": "deepseek/deepseek-v4-flash-0731", "name": "DeepSeek V4 Flash 0731", + "description": "Official DeepSeek V4 Flash release with enhanced agentic capabilities and integrated DSpark speculative decoding", "family": "deepseek-flash", + "knowledge": "2025-05", + "release_date": "2026-07-31", + "last_updated": "2026-07-31", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -1942,8 +2716,12 @@ }, "glm-5.2": { "id": "glm-5.2", + "canonical_model_id": "zhipuai/glm-5.2", "name": "GLM-5.2", + "description": "Open flagship GLM for long-horizon coding agents and million-token context work", "family": "glm", + "release_date": "2026-06-13", + "last_updated": "2026-06-13", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -1995,8 +2773,12 @@ "models": { "qwen3.8-max": { "id": "qwen3.8-max", + "canonical_model_id": "alibaba/qwen3.8-max", "name": "Qwen3.8 Max", + "description": "2.4-trillion-parameter MoE flagship for coding, professional work, multimodal understanding, and long-horizon agentic workflows", "family": "qwen", + "release_date": "2026-08-03", + "last_updated": "2026-08-03", "default": true, "attachment": true, "reasoning": true, @@ -2049,8 +2831,12 @@ }, "qwen3.8-max-preview": { "id": "qwen3.8-max-preview", + "canonical_model_id": "alibaba/qwen3.8-max-preview", "name": "Qwen3.8 Max Preview", + "description": "Preview Qwen flagship for million-token multimodal reasoning and long-horizon agentic workflows", "family": "qwen", + "release_date": "2026-07-19", + "last_updated": "2026-07-19", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -2094,12 +2880,18 @@ "output": 0, "cache_read": 0, "cache_write": 0 - } + }, + "status": "deprecated" }, "qwen3.7-plus": { "id": "qwen3.7-plus", + "canonical_model_id": "alibaba/qwen3.7-plus", "name": "Qwen3.7 Plus", + "description": "Multimodal Qwen workhorse for long-context agents, visual inputs, and coding", "family": "qwen", + "knowledge": "2025-04", + "release_date": "2026-06-02", + "last_updated": "2026-06-02", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -2131,78 +2923,15 @@ "cache_write": 0 } }, - "qwen3.7-max": { - "id": "qwen3.7-max", - "name": "Qwen3.7 Max", - "family": "qwen", - "attachment": false, - "reasoning": true, - "reasoning_options": [ - { - "type": "toggle" - } - ], - "tool_call": true, - "temperature": true, - "open_weights": false, - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 1000000, - "output": 65536 - }, - "cost": { - "input": 2.5, - "output": 7.5, - "cache_read": 0.5, - "cache_write": 3.125 - } - }, - "qwen3.6-flash": { - "id": "qwen3.6-flash", - "name": "Qwen3.6 Flash", - "family": "qwen3.6", - "attachment": true, - "reasoning": true, - "reasoning_options": [ - { - "type": "toggle" - } - ], - "tool_call": true, - "structured_output": true, - "temperature": true, - "open_weights": false, - "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 1000000, - "output": 65536 - }, - "cost": { - "input": 0.1875, - "output": 1.125, - "cache_write": 0.234375 - } - }, "deepseek-v4-pro": { "id": "deepseek-v4-pro", + "canonical_model_id": "deepseek/deepseek-v4-pro", "name": "DeepSeek V4 Pro", + "description": "Open MoE flagship with million-token context for coding and long agent runs", "family": "deepseek-thinking", + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -2245,8 +2974,13 @@ }, "deepseek-v4-flash-0731": { "id": "deepseek-v4-flash-0731", + "canonical_model_id": "deepseek/deepseek-v4-flash-0731", "name": "DeepSeek V4 Flash 0731", + "description": "Official DeepSeek V4 Flash release with enhanced agentic capabilities and integrated DSpark speculative decoding", "family": "deepseek-flash", + "knowledge": "2025-05", + "release_date": "2026-07-31", + "last_updated": "2026-07-31", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -2289,8 +3023,12 @@ }, "glm-5.2": { "id": "glm-5.2", + "canonical_model_id": "zhipuai/glm-5.2", "name": "GLM-5.2", + "description": "Open flagship GLM for long-horizon coding agents and million-token context work", "family": "glm", + "release_date": "2026-06-13", + "last_updated": "2026-06-13", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -2342,8 +3080,12 @@ "models": { "muse-spark-1.1": { "id": "muse-spark-1.1", + "canonical_model_id": "meta/muse-spark-1.1", "name": "Muse Spark 1.1", + "description": "Muse Spark is a natively multimodal reasoning model with support for tool-use, visual chain of thought, and multi-agent orchestration.", "family": "muse", + "release_date": "2026-04-08", + "last_updated": "2026-07-09", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -2385,8 +3127,12 @@ }, "muse-spark-1.2": { "id": "muse-spark-1.2", + "canonical_model_id": "meta/muse-spark-1.2", "name": "Muse Spark 1.2", + "description": "Muse Spark 1.2 is a coding-focused update to Muse Spark 1.1 with improvements in code generation, complex debugging, codebase understanding, and end-to-end developer workflows.", "family": "muse", + "release_date": "2026-08-05", + "last_updated": "2026-08-05", "default": true, "attachment": true, "reasoning": true, @@ -2430,8 +3176,12 @@ }, "muse-spark-1.2-contributor": { "id": "muse-spark-1.2-contributor", + "canonical_model_id": "meta/muse-spark-1.2", "name": "Muse Spark 1.2 Contributor", + "description": "Muse Spark 1.2 is a coding-focused update to Muse Spark 1.1 with improvements in code generation, complex debugging, codebase understanding, and end-to-end developer workflows.", "family": "muse", + "release_date": "2026-08-05", + "last_updated": "2026-08-05", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -2486,7 +3236,11 @@ "gpt-5.5": { "id": "gpt-5.5", "name": "GPT-5.5", + "description": "Default frontier GPT for coding, computer use, research, and knowledge work", "family": "gpt", + "knowledge": "2025-12-01", + "release_date": "2026-04-23", + "last_updated": "2026-04-23", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -2523,13 +3277,49 @@ "cost": { "input": 5, "output": 30, - "cache_read": 0.5 + "cache_read": 0.5, + "tiers": [ + { + "input": 10, + "output": 45, + "cache_read": 1, + "tier": { + "type": "context", + "size": 272000 + } + } + ], + "context_over_200k": { + "input": 10, + "output": 45, + "cache_read": 1 + } + }, + "experimental": { + "modes": { + "fast": { + "cost": { + "input": 12.5, + "output": 75, + "cache_read": 1.25 + }, + "provider": { + "body": { + "service_tier": "priority" + } + } + } + } } }, "gpt-5.5-pro": { "id": "gpt-5.5-pro", "name": "GPT-5.5 Pro", + "description": "Highest-accuracy GPT-5.5 tier for slower, precision-heavy reasoning and coding", "family": "gpt-pro", + "knowledge": "2025-12-01", + "release_date": "2026-04-23", + "last_updated": "2026-04-23", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -2563,13 +3353,32 @@ }, "cost": { "input": 30, - "output": 180 + "output": 180, + "tiers": [ + { + "input": 60, + "output": 270, + "tier": { + "type": "context", + "size": 272000 + } + } + ], + "context_over_200k": { + "input": 60, + "output": 270 + } } }, "gpt-5.6": { "id": "gpt-5.6", + "canonical_model_id": "openai/gpt-5.6-sol", "name": "GPT-5.6", + "description": "Frontier GPT-5.6 model for complex professional work, coding, and agentic workflows", "family": "gpt-sol", + "knowledge": "2026-02-16", + "release_date": "2026-07-09", + "last_updated": "2026-07-09", "default": true, "attachment": true, "reasoning": true, @@ -2609,13 +3418,62 @@ "input": 4, "output": 20, "cache_read": 0.4, - "cache_write": 5 + "cache_write": 5, + "tiers": [ + { + "input": 8, + "output": 30, + "cache_read": 0.8, + "cache_write": 10, + "tier": { + "type": "context", + "size": 272000 + } + } + ], + "context_over_200k": { + "input": 8, + "output": 30, + "cache_read": 0.8, + "cache_write": 10 + } + }, + "experimental": { + "modes": { + "fast": { + "cost": { + "input": 8, + "output": 40, + "cache_read": 0.8, + "cache_write": 10 + }, + "provider": { + "body": { + "service_tier": "priority" + } + } + }, + "pro": { + "provider": { + "body": { + "reasoning": { + "mode": "pro" + } + } + } + } + } } }, "gpt-5.6-sol": { "id": "gpt-5.6-sol", + "canonical_model_id": "openai/gpt-5.6-sol", "name": "GPT-5.6 Sol", + "description": "Frontier GPT-5.6 model for complex professional work, coding, and agentic workflows", "family": "gpt-sol", + "knowledge": "2026-02-16", + "release_date": "2026-07-09", + "last_updated": "2026-07-09", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -2654,13 +3512,62 @@ "input": 4, "output": 20, "cache_read": 0.4, - "cache_write": 5 + "cache_write": 5, + "tiers": [ + { + "input": 8, + "output": 30, + "cache_read": 0.8, + "cache_write": 10, + "tier": { + "type": "context", + "size": 272000 + } + } + ], + "context_over_200k": { + "input": 8, + "output": 30, + "cache_read": 0.8, + "cache_write": 10 + } + }, + "experimental": { + "modes": { + "fast": { + "cost": { + "input": 8, + "output": 40, + "cache_read": 0.8, + "cache_write": 10 + }, + "provider": { + "body": { + "service_tier": "priority" + } + } + }, + "pro": { + "provider": { + "body": { + "reasoning": { + "mode": "pro" + } + } + } + } + } } }, "gpt-5.6-terra": { "id": "gpt-5.6-terra", + "canonical_model_id": "openai/gpt-5.6-terra", "name": "GPT-5.6 Terra", + "description": "Balanced GPT-5.6 model for capable, cost-efficient everyday work", "family": "gpt-terra", + "knowledge": "2026-02-16", + "release_date": "2026-07-09", + "last_updated": "2026-07-09", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -2699,13 +3606,62 @@ "input": 2, "output": 12, "cache_read": 0.2, - "cache_write": 2.5 + "cache_write": 2.5, + "tiers": [ + { + "input": 4, + "output": 18, + "cache_read": 0.4, + "cache_write": 5, + "tier": { + "type": "context", + "size": 272000 + } + } + ], + "context_over_200k": { + "input": 4, + "output": 18, + "cache_read": 0.4, + "cache_write": 5 + } + }, + "experimental": { + "modes": { + "fast": { + "cost": { + "input": 4, + "output": 24, + "cache_read": 0.4, + "cache_write": 5 + }, + "provider": { + "body": { + "service_tier": "priority" + } + } + }, + "pro": { + "provider": { + "body": { + "reasoning": { + "mode": "pro" + } + } + } + } + } } }, "gpt-5.6-luna": { "id": "gpt-5.6-luna", + "canonical_model_id": "openai/gpt-5.6-luna", "name": "GPT-5.6 Luna", + "description": "Cost-efficient GPT-5.6 model for fast, high-volume workloads", "family": "gpt-luna", + "knowledge": "2026-02-16", + "release_date": "2026-07-09", + "last_updated": "2026-07-09", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -2744,13 +3700,61 @@ "input": 0.2, "output": 1.2, "cache_read": 0.02, - "cache_write": 0.25 + "cache_write": 0.25, + "tiers": [ + { + "input": 0.4, + "output": 1.8, + "cache_read": 0.04, + "cache_write": 0.5, + "tier": { + "type": "context", + "size": 272000 + } + } + ], + "context_over_200k": { + "input": 0.4, + "output": 1.8, + "cache_read": 0.04, + "cache_write": 0.5 + } + }, + "experimental": { + "modes": { + "fast": { + "cost": { + "input": 0.4, + "output": 2.4, + "cache_read": 0.04, + "cache_write": 0.5 + }, + "provider": { + "body": { + "service_tier": "priority" + } + } + }, + "pro": { + "provider": { + "body": { + "reasoning": { + "mode": "pro" + } + } + } + } + } } }, "gpt-5.3-codex": { "id": "gpt-5.3-codex", "name": "GPT-5.3 Codex", + "description": "Coding-optimized GPT model for repository edits, reviews, and agentic software work", "family": "gpt-codex", + "knowledge": "2025-08-31", + "release_date": "2026-02-05", + "last_updated": "2026-02-05", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -2788,6 +3792,22 @@ "input": 1.75, "output": 14, "cache_read": 0.175 + }, + "experimental": { + "modes": { + "fast": { + "cost": { + "input": 3.5, + "output": 28, + "cache_read": 0.35 + }, + "provider": { + "body": { + "service_tier": "priority" + } + } + } + } } } } @@ -2804,7 +3824,11 @@ "claude-sonnet-4-6": { "id": "claude-sonnet-4-6", "name": "Claude Sonnet 4.6", + "description": "Claude workhorse for coding agents, careful analysis, and production cost control", "family": "claude-sonnet", + "knowledge": "2025-08-31", + "release_date": "2026-02-17", + "last_updated": "2026-03-13", "default": true, "attachment": true, "reasoning": true, @@ -2851,7 +3875,11 @@ "claude-opus-4-8": { "id": "claude-opus-4-8", "name": "Claude Opus 4.8", + "description": "Top Claude Opus tier for the hardest reasoning, coding, and long-horizon agents", "family": "claude-opus", + "knowledge": "2026-01", + "release_date": "2026-05-28", + "last_updated": "2026-05-28", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -2889,12 +3917,37 @@ "output": 25, "cache_read": 0.5, "cache_write": 6.25 + }, + "experimental": { + "modes": { + "fast": { + "cost": { + "input": 10, + "output": 50, + "cache_read": 1, + "cache_write": 12.5 + }, + "provider": { + "body": { + "speed": "fast" + }, + "headers": { + "anthropic-beta": "fast-mode-2026-02-01" + } + } + } + } } }, "claude-haiku-4-5": { "id": "claude-haiku-4-5", + "canonical_model_id": "anthropic/claude-haiku-4-5", "name": "Claude Haiku 4.5 (latest)", + "description": "Fast Claude lane for lightweight agents, office tasks, and responsive chat", "family": "claude-haiku", + "knowledge": "2025-02-28", + "release_date": "2025-10-15", + "last_updated": "2025-10-15", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -2930,8 +3983,13 @@ }, "claude-opus-5": { "id": "claude-opus-5", + "canonical_model_id": "anthropic/claude-opus-5", "name": "Claude Opus 5", + "description": "Strongest Claude Opus model for coding, agents, and professional work", "family": "claude-opus", + "knowledge": "2026-05", + "release_date": "2026-07-24", + "last_updated": "2026-07-24", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -2969,12 +4027,36 @@ "output": 25, "cache_read": 0.5, "cache_write": 6.25 + }, + "experimental": { + "modes": { + "fast": { + "cost": { + "input": 10, + "output": 50, + "cache_read": 1, + "cache_write": 12.5 + }, + "provider": { + "body": { + "speed": "fast" + }, + "headers": { + "anthropic-beta": "fast-mode-2026-02-01" + } + } + } + } } }, "claude-sonnet-5": { "id": "claude-sonnet-5", "name": "Claude Sonnet 5", + "description": "Everyday Claude agent model for coding, planning, browsing, and general work", "family": "claude-sonnet", + "knowledge": "2026-01-31", + "release_date": "2026-06-29", + "last_updated": "2026-06-30", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -3020,7 +4102,10 @@ "claude-fable-5": { "id": "claude-fable-5", "name": "Claude Fable 5", + "description": "Claude model for creative writing, analysis, and controlled agent workflows", "family": "claude-fable", + "release_date": "2026-06-07", + "last_updated": "2026-06-09", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -3073,9 +4158,14 @@ "models": { "deepseek/deepseek-v4-pro": { "id": "deepseek/deepseek-v4-pro", - "base_model": "deepseek-v4-pro", + "base_model": "deepseek/deepseek-v4-pro", + "canonical_model_id": "deepseek/deepseek-v4-pro", "name": "DeepSeek V4 Pro", + "description": "Open MoE flagship with million-token context for coding and long agent runs", "family": "deepseek-thinking", + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", "default": true, "attachment": false, "reasoning": true, @@ -3111,16 +4201,21 @@ "output": 384000 }, "cost": { - "input": 0.418818, - "output": 0.837636, - "cache_read": 0.034902 + "input": 0.436044, + "output": 0.872088, + "cache_read": 0.036337 } }, "deepseek/deepseek-v4-flash": { "id": "deepseek/deepseek-v4-flash", - "base_model": "deepseek-v4-flash", + "base_model": "deepseek/deepseek-v4-flash", + "canonical_model_id": "deepseek/deepseek-v4-flash", "name": "DeepSeek V4 Flash", + "description": "Fast DeepSeek V4 lane for economical reasoning, coding, and long-context work", "family": "deepseek-flash", + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -3152,18 +4247,22 @@ }, "limit": { "context": 1048576, - "output": 384000 + "output": 943718 }, "cost": { - "input": 0.04704, - "output": 0.09408, - "cache_read": 0.009408 + "input": 0.03, + "output": 1.28, + "cache_read": 0.03 } }, "qwen/qwen3.8-flash": { "id": "qwen/qwen3.8-flash", + "canonical_model_id": "alibaba/qwen3.8-flash", "name": "Qwen3.8 Flash", + "description": "Qwen vision-language model for visual reasoning, documents, and agent tasks", "family": "qwen", + "release_date": "2026-08-26", + "last_updated": "2026-08-26", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -3195,14 +4294,17 @@ "cost": { "input": 0.15, "output": 0.47, - "cache_read": 0.016, - "cache_write": 0.2 + "cache_read": 0.016 } }, "qwen/qwen3.6-flash": { "id": "qwen/qwen3.6-flash", + "canonical_model_id": "alibaba/qwen3.6-flash", "name": "Qwen3.6 Flash", + "description": "Qwen vision-language model for visual reasoning, documents, and agent tasks", "family": "qwen3.6", + "release_date": "2026-04-27", + "last_updated": "2026-04-27", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -3231,13 +4333,34 @@ "cost": { "input": 0.1875, "output": 1.125, - "cache_write": 0.234375 + "cache_write": 0.234375, + "tiers": [ + { + "input": 0.75, + "output": 3, + "cache_write": 0.9375, + "tier": { + "type": "context", + "size": 256000 + } + } + ], + "context_over_200k": { + "input": 0.75, + "output": 3, + "cache_write": 0.9375 + } } }, "qwen/qwen3.6-plus": { "id": "qwen/qwen3.6-plus", + "canonical_model_id": "alibaba/qwen3.6-plus", "name": "Qwen3.6 Plus", + "description": "Earlier Qwen multimodal workhorse for million-token agent and document tasks", "family": "qwen", + "knowledge": "2025-04", + "release_date": "2026-04-02", + "last_updated": "2026-04-02", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -3266,13 +4389,33 @@ "cost": { "input": 0.325, "output": 1.95, - "cache_write": 0.40625 + "cache_write": 0.40625, + "tiers": [ + { + "input": 1.3, + "output": 3.9, + "cache_write": 1.625, + "tier": { + "type": "context", + "size": 256000 + } + } + ], + "context_over_200k": { + "input": 1.3, + "output": 3.9, + "cache_write": 1.625 + } } }, "qwen/qwen3.6-35b-a3b": { "id": "qwen/qwen3.6-35b-a3b", + "canonical_model_id": "alibaba/qwen3.6-35b-a3b", "name": "Qwen3.6 35B-A3B", + "description": "Open multimodal Qwen MoE for local agents that need vision, audio, and code", "family": "qwen", + "release_date": "2026-04-17", + "last_updated": "2026-04-17", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -3296,18 +4439,23 @@ }, "limit": { "context": 262144, - "output": 235929 + "output": 65536 }, "cost": { - "input": 0.15, - "output": 1, - "cache_read": 0.05 + "input": 0.1, + "output": 0.95, + "cache_read": 0.1 } }, "qwen/qwen3.7-plus": { "id": "qwen/qwen3.7-plus", + "canonical_model_id": "alibaba/qwen3.7-plus", "name": "Qwen3.7 Plus", + "description": "Multimodal Qwen workhorse for long-context agents, visual inputs, and coding", "family": "qwen", + "knowledge": "2025-04", + "release_date": "2026-06-02", + "last_updated": "2026-06-02", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -3336,13 +4484,35 @@ "input": 0.32, "output": 1.28, "cache_read": 0.064, - "cache_write": 0.4 + "cache_write": 0.4, + "tiers": [ + { + "input": 0.96, + "output": 3.84, + "cache_read": 0.192, + "cache_write": 1.2, + "tier": { + "type": "context", + "size": 256000 + } + } + ], + "context_over_200k": { + "input": 0.96, + "output": 3.84, + "cache_read": 0.192, + "cache_write": 1.2 + } } }, "minimax/minimax-m3": { "id": "minimax/minimax-m3", + "canonical_model_id": "minimax/MiniMax-M3", "name": "MiniMax-M3", + "description": "MiniMax multimodal model for long-context coding, perception, and agent planning", "family": "minimax", + "release_date": "2026-06-01", + "last_updated": "2026-06-01", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -3376,8 +4546,12 @@ }, "z-ai/glm-5.2": { "id": "z-ai/glm-5.2", + "canonical_model_id": "zhipuai/glm-5.2", "name": "GLM-5.2", + "description": "Open flagship GLM for long-horizon coding agents and million-token context work", "family": "glm", + "release_date": "2026-06-13", + "last_updated": "2026-06-13", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -3409,18 +4583,22 @@ }, "limit": { "context": 1048576, - "output": 131072 + "output": 943718 }, "cost": { - "input": 0.6496, - "output": 2.0416, - "cache_read": 0.12064 + "input": 0.06, + "output": 7, + "cache_read": 0.059 } }, "z-ai/glm-5.3": { "id": "z-ai/glm-5.3", + "canonical_model_id": "zhipuai/glm-5.3", "name": "GLM-5.3", + "description": "Flagship GLM model for hybrid reasoning, coding, and agentic engineering", "family": "glm", + "release_date": "2026-08-14", + "last_updated": "2026-08-14", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -3446,19 +4624,23 @@ ] }, "limit": { - "context": 1310720, - "output": 131072 + "context": 1048576, + "output": 943718 }, "cost": { - "input": 0.3794, - "output": 1.1924, - "cache_read": 0.07046 + "input": 0.079, + "output": 4.8, + "cache_read": 0.059 } }, "z-ai/glm-5.3-flash": { "id": "z-ai/glm-5.3-flash", + "canonical_model_id": "zhipuai/glm-5.3-flash", "name": "GLM-5.3-Flash", + "description": "GLM vision model for visual reasoning, documents, and multimodal agents", "family": "glm-flash", + "release_date": "2026-08-26", + "last_updated": "2026-08-26", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -3486,18 +4668,21 @@ ] }, "limit": { - "context": 1310720, - "output": 131072 + "context": 1048576, + "output": 943718 }, "cost": { - "input": 0.04, - "output": 0.5, - "cache_read": 0.015 + "input": 0.0232, + "output": 1.2, + "cache_read": 0.0232 } }, "dots-studio/dots-3-note-preview:free": { "id": "dots-studio/dots-3-note-preview:free", "name": "Dots3-Note Preview (free)", + "description": "Multimodal reasoning model for visual analysis, planning, and tool use", + "release_date": "2026-08-14", + "last_updated": "2026-08-14", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -3672,9 +4857,14 @@ "models": { "deepseek/deepseek-v4-pro": { "id": "deepseek/deepseek-v4-pro", - "base_model": "deepseek-v4-pro", + "base_model": "deepseek/deepseek-v4-pro", + "canonical_model_id": "deepseek/deepseek-v4-pro", "name": "DeepSeek V4 Pro", + "description": "Open MoE flagship with million-token context for coding and long agent runs", "family": "deepseek-thinking", + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", "default": true, "attachment": false, "reasoning": true, @@ -3686,9 +4876,8 @@ "type": "effort", "values": [ "low", - "medium", "high", - "xhigh" + "max" ] } ], @@ -3716,53 +4905,6 @@ "output": 3.2, "cache_read": 0.135 } - }, - "deepseek/deepseek-v4-flash": { - "id": "deepseek/deepseek-v4-flash", - "base_model": "deepseek-v4-flash", - "name": "DeepSeek V4 Flash", - "family": "deepseek-flash", - "attachment": false, - "reasoning": true, - "reasoning_options": [ - { - "type": "toggle" - }, - { - "type": "effort", - "values": [ - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - } - ], - "interleaved": { - "field": "reasoning_content" - }, - "tool_call": true, - "structured_output": true, - "temperature": true, - "open_weights": true, - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 1048576, - "output": 393216 - }, - "cost": { - "input": 0.14, - "output": 0.28, - "cache_read": 0.028 - } } } }, @@ -3777,9 +4919,14 @@ "models": { "deepseek-ai/DeepSeek-V4-Pro": { "id": "deepseek-ai/DeepSeek-V4-Pro", - "base_model": "deepseek-v4-pro", + "base_model": "deepseek/deepseek-v4-pro", + "canonical_model_id": "deepseek/deepseek-v4-pro", "name": "DeepSeek V4 Pro", + "description": "Open MoE flagship with million-token context for coding and long agent runs", "family": "deepseek-thinking", + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", "default": true, "attachment": false, "reasoning": true, @@ -3817,9 +4964,14 @@ }, "deepseek-ai/DeepSeek-V4-Flash": { "id": "deepseek-ai/DeepSeek-V4-Flash", - "base_model": "deepseek-v4-flash", + "base_model": "deepseek/deepseek-v4-flash", + "canonical_model_id": "deepseek/deepseek-v4-flash", "name": "DeepSeek V4 Flash", + "description": "Fast DeepSeek V4 lane for economical reasoning, coding, and long-context work", "family": "deepseek-flash", + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -3867,8 +5019,12 @@ "models": { "trinity-large-thinking": { "id": "trinity-large-thinking", + "canonical_model_id": "arcee-ai/trinity-large-thinking", "name": "Trinity Large Thinking", + "description": "Reasoning-optimized 398B MoE agent model with extended thinking for long-horizon and multi-turn tool use", "family": "trinity", + "release_date": "2026-04-01", + "last_updated": "2026-05-28", "default": true, "attachment": false, "reasoning": true, @@ -3895,7 +5051,8 @@ "input": 0.25, "output": 0.8, "cache_read": 0.06 - } + }, + "status": "beta" }, "trinity-mini": { "id": "trinity-mini", @@ -3928,8 +5085,13 @@ "models": { "grok-4.7": { "id": "grok-4.7", + "canonical_model_id": "xai/grok-4.7", "name": "Grok 4.7", + "description": "xAI's frontier model for long-running agents, coding, knowledge work, and visual projects", "family": "grok", + "knowledge": "2026-05", + "release_date": "2026-09-21", + "last_updated": "2026-09-21", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -3964,13 +5126,34 @@ "cost": { "input": 2, "output": 6, - "cache_read": 0.5 + "cache_read": 0.5, + "tiers": [ + { + "input": 4, + "output": 12, + "cache_read": 1, + "tier": { + "type": "context", + "size": 200000 + } + } + ], + "context_over_200k": { + "input": 4, + "output": 12, + "cache_read": 1 + } } }, "grok-4.6": { "id": "grok-4.6", + "canonical_model_id": "xai/grok-4.6", "name": "Grok 4.6", + "description": "xAI's frontier model for long-running agents, coding, knowledge work, and visual projects", "family": "grok", + "knowledge": "2026-02-01", + "release_date": "2026-08-12", + "last_updated": "2026-08-12", "default": true, "attachment": true, "reasoning": true, @@ -4006,13 +5189,33 @@ "cost": { "input": 2, "output": 6, - "cache_read": 0.5 + "cache_read": 0.5, + "tiers": [ + { + "input": 4, + "output": 12, + "cache_read": 1, + "tier": { + "type": "context", + "size": 200000 + } + } + ], + "context_over_200k": { + "input": 4, + "output": 12, + "cache_read": 1 + } } }, "grok-4.5": { "id": "grok-4.5", + "canonical_model_id": "xai/grok-4.5", "name": "Grok 4.5", + "description": "xAI's Grok model for chat, coding, agentic tools, and lower hallucination risk", "family": "grok", + "release_date": "2026-07-08", + "last_updated": "2026-07-08", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -4046,13 +5249,32 @@ "cost": { "input": 2, "output": 6, - "cache_read": 0.3 + "cache_read": 0.3, + "tiers": [ + { + "input": 4, + "output": 12, + "cache_read": 0.6, + "tier": { + "type": "context", + "size": 200000 + } + } + ], + "context_over_200k": { + "input": 4, + "output": 12, + "cache_read": 0.6 + } } }, "grok-4.3": { "id": "grok-4.3", "name": "Grok 4.3", + "description": "xAI's Grok for chat, coding, agentic tools, and lower hallucination risk", "family": "grok", + "release_date": "2026-04-17", + "last_updated": "2026-04-17", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -4087,7 +5309,23 @@ "cost": { "input": 1.25, "output": 2.5, - "cache_read": 0.2 + "cache_read": 0.2, + "tiers": [ + { + "input": 2.5, + "output": 5, + "cache_read": 0.4, + "tier": { + "type": "context", + "size": 200000 + } + } + ], + "context_over_200k": { + "input": 2.5, + "output": 5, + "cache_read": 0.4 + } } } } @@ -4105,7 +5343,11 @@ "mimo-v2.5-pro": { "id": "mimo-v2.5-pro", "name": "MiMo-V2.5-Pro", + "description": "Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution", "family": "mimo", + "knowledge": "2024-12", + "release_date": "2026-04-22", + "last_updated": "2026-06-24", "default": true, "attachment": false, "reasoning": true, @@ -4141,7 +5383,11 @@ "mimo-v2.5": { "id": "mimo-v2.5", "name": "MiMo-V2.5", + "description": "Open MiMo model for multimodal coding agents and long-context automation", "family": "mimo", + "knowledge": "2024-12", + "release_date": "2026-04-22", + "last_updated": "2026-06-24", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -4179,7 +5425,10 @@ "mimo-v2.6-pro": { "id": "mimo-v2.6-pro", "name": "MiMo-V2.6-Pro", + "description": "MiMo Pro model for multimodal coding agents and long-context automation", "family": "mimo", + "release_date": "2026-09-22", + "last_updated": "2026-09-22", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -4217,7 +5466,10 @@ "mimo-v2.6-flash": { "id": "mimo-v2.6-flash", "name": "MiMo-V2.6-Flash", + "description": "MiMo Flash model for multimodal coding agents and long-context automation", "family": "mimo", + "release_date": "2026-09-22", + "last_updated": "2026-09-22", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -4267,6 +5519,10 @@ "step-3.5-flash": { "id": "step-3.5-flash", "name": "Step 3.5 Flash", + "description": "StepFun flash lane for quick multimodal reasoning and coding assistance", + "knowledge": "2025-01", + "release_date": "2026-01-29", + "last_updated": "2026-06-15", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -4306,6 +5562,10 @@ "step-3.5-flash-2603": { "id": "step-3.5-flash-2603", "name": "Step 3.5 Flash 2603", + "description": "StepFun flash model for efficient multimodal reasoning, coding, and tool use", + "knowledge": "2025-01", + "release_date": "2026-04-02", + "last_updated": "2026-04-02", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -4344,7 +5604,12 @@ }, "step-3.7-flash": { "id": "step-3.7-flash", + "canonical_model_id": "stepfun/step-3.7-flash", "name": "Step 3.7 Flash", + "description": "Newer StepFun flash model for faster agents, coding, and multimodal prompts", + "knowledge": "2026-03-01", + "release_date": "2026-05-29", + "last_updated": "2026-06-29", "default": true, "attachment": true, "reasoning": true, @@ -4387,7 +5652,11 @@ }, "step-5-preview": { "id": "step-5-preview", + "canonical_model_id": "stepfun/step-5-preview", "name": "Step 5 Preview", + "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", "attachment": true, "reasoning": true, "reasoning_options": [ diff --git a/crates/config/src/catalog.rs b/crates/config/src/catalog.rs index 99a5b8b16f..7c21f19f46 100644 --- a/crates/config/src/catalog.rs +++ b/crates/config/src/catalog.rs @@ -1218,7 +1218,8 @@ impl CatalogCompiler { .chain(self.overrides) { if let Some(lower) = merged.get(&row.merge_key()) { - row.complete_from(lower, None); + let base_url = row.provider_api.clone(); + row.complete_from(lower, base_url.as_deref()); } merged.insert(row.merge_key(), row); } diff --git a/crates/config/src/catalog/configured.rs b/crates/config/src/catalog/configured.rs index 1a9d0882d3..44e0de1a85 100644 --- a/crates/config/src/catalog/configured.rs +++ b/crates/config/src/catalog/configured.rs @@ -53,6 +53,7 @@ impl ConfiguredModel { provider: self.provider.clone(), wire_model_id: self.id.clone(), endpoint_key: "chat".into(), + provider_api: Some(self.base_url.clone()), limit: self.limit.clone(), cost: self.cost.clone(), modalities: self.modalities.clone(), @@ -187,7 +188,20 @@ fn deserialize_limit<'de, D: Deserializer<'de>>(d: D) -> Result>(d: D) -> Result, D::Error> { - deserialize_known(d, &["input", "output", "cache_read", "cache_write"]) + deserialize_known( + d, + &[ + "input", + "output", + "cache_read", + "cache_write", + "reasoning", + "input_audio", + "output_audio", + "tiers", + "context_over_200k", + ], + ) } fn deserialize_modalities<'de, D: Deserializer<'de>>( d: D, diff --git a/crates/config/src/catalog/tests.rs b/crates/config/src/catalog/tests.rs index 59af3562d5..b2ecf9119a 100644 --- a/crates/config/src/catalog/tests.rs +++ b/crates/config/src/catalog/tests.rs @@ -1262,15 +1262,32 @@ fn bundled_asset_pricing_is_honest() { Some(131_072) ); - // M3 has input-length and service tiers that the flat catalog cost shape - // cannot represent, so the bundled route row stays honestly unpriced. + // M3 publishes a context tier at 512K tokens. The row ships priced only + // because the tier travels with it; dropping the tier would underbill. let minimax_m3 = find(&rows, "minimax-anthropic", "MiniMax-M3"); - assert!(minimax_m3.cost.is_none()); + let cost = minimax_m3 + .cost + .as_ref() + .expect("M3 is priced with its tier"); + assert_eq!(cost.input, Some(0.30)); + assert_eq!(cost.output, Some(1.20)); + let tiers = cost.tiers.as_ref().expect("M3 keeps its context tier"); + assert!(tiers.iter().any(|t| t.tier.size == 512_000)); // Grok 4.6 also has a prompt-length tier, starting at 200K input tokens. - // The usage-aware TUI table prices it; a flat catalog row would underbill. + // It likewise ships priced with the tier preserved for usage-aware pricing. let grok_46 = find(&rows, "xai", "grok-4.6"); - assert!(grok_46.cost.is_none()); + let cost = grok_46 + .cost + .as_ref() + .expect("Grok 4.6 is priced with its tier"); + assert_eq!(cost.input, Some(2.0)); + assert_eq!(cost.output, Some(6.0)); + let tiers = cost + .tiers + .as_ref() + .expect("Grok 4.6 keeps its context tier"); + assert!(tiers.iter().any(|t| t.tier.size == 200_000)); let minimax_m2_7 = find(&rows, "minimax-anthropic", "MiniMax-M2.7"); let cost = minimax_m2_7.cost.as_ref().expect("M2.7 is priced"); diff --git a/crates/config/src/model_reference.rs b/crates/config/src/model_reference.rs index 12ed8cf5f4..7ce7084eb2 100644 --- a/crates/config/src/model_reference.rs +++ b/crates/config/src/model_reference.rs @@ -178,7 +178,11 @@ impl ModelReferenceCard { let Some(pricing) = self.pricing.as_ref() else { return "unknown".to_string(); }; - if pricing.input_per_million.is_none() && pricing.output_per_million.is_none() { + if matches!( + pricing.status_label_at(crate::catalog::now_unix()), + "stale" | "unverified" | "unavailable" + ) || (pricing.input_per_million.is_none() && pricing.output_per_million.is_none()) + { return "unknown".to_string(); } let symbol = currency_symbol(&pricing.currency); @@ -548,12 +552,12 @@ mod tests { .expect("bundled minimax row"); assert_eq!(minimax.price_label(), "$0.30 / $1.20 per Mtok"); - // MiniMax-M3 ships without verified per-token pricing, so its price - // stays honestly unknown rather than inheriting a sibling's rate. + // MiniMax-M3 carries its own base rate and a context tier. let minimax_m3 = db .find("minimax", "MiniMax-M3") .expect("bundled minimax m3 row"); - assert_eq!(minimax_m3.price_label(), "unknown"); + assert_eq!(minimax_m3.price_label(), "$0.30 / $1.20 per Mtok"); + assert_eq!(minimax_m3.pricing.as_ref().unwrap().context_tiers.len(), 1); } #[test] diff --git a/crates/config/src/models_dev.rs b/crates/config/src/models_dev.rs index 21e74ae888..e45ae64c1b 100644 --- a/crates/config/src/models_dev.rs +++ b/crates/config/src/models_dev.rs @@ -519,7 +519,7 @@ fn context_tier_type() -> String { } impl ModelsDevCost { - fn validate(&self) -> serde_json::Result<()> { + pub(crate) fn validate(&self) -> serde_json::Result<()> { if [ self.input, self.output, @@ -578,9 +578,7 @@ impl ModelsDevCost { #[must_use] pub fn context_tiers(&self) -> Vec { let mut tiers = self.tiers.clone().unwrap_or_default(); - if !tiers - .iter() - .any(|tier| tier.tier.kind == "context" && tier.tier.size == 200_000) + if self.tiers.is_none() && let Some(cost) = &self.context_over_200k { tiers.push(ModelsDevCostTier { diff --git a/crates/config/src/pricing.rs b/crates/config/src/pricing.rs index f9c099f740..0929fb5ab4 100644 --- a/crates/config/src/pricing.rs +++ b/crates/config/src/pricing.rs @@ -18,10 +18,11 @@ //! never a fabricated token price, and never an implicit "free" for //! local/custom/subscription routes. +use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use crate::catalog::{CatalogOffering, CatalogSource}; -use crate::models_dev::ModelsDevCost; +use crate::models_dev::{ModelsDevCost, ModelsDevCostTier}; use crate::route::PricingSku; /// Billing currency for a pricing row. Models.dev publishes USD per-million @@ -38,13 +39,12 @@ pub enum Currency { /// Where a pricing row came from. Retained so the UI can show provenance and so /// stale/unknown prices are never silently treated as authoritative. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(tag = "source", rename_all = "snake_case")] pub enum PricingProvenance { - /// Sourced from the Models.dev catalog — the bundled snapshot or a live - /// refresh of it. Both are the same third-party claim about a model, so - /// they share a provenance; `effective_at` is `Some` only for the refresh. + /// Published-price estimate from the offline Models.dev snapshot. ModelsDevBundled, + ModelsDevLive, /// From a provider live `/models` (or pricing) refresh — a rate the /// provider itself published for one endpoint, not a catalog's report of it. ProviderLive, @@ -55,6 +55,7 @@ pub enum PricingProvenance { /// User-supplied override (custom endpoint, enterprise terms, local route). UserOverride, /// No sourced price. + #[default] Unknown, /// From the Codewhale-owned signed/bundled catalog. CodewhaleCatalog, @@ -68,6 +69,7 @@ impl PricingProvenance { pub fn label(&self) -> &'static str { match self { Self::ModelsDevBundled => "models_dev_bundled", + Self::ModelsDevLive => "models_dev_live", Self::ProviderLive => "provider_live", Self::ProviderDocs => "provider_docs", Self::UserOverride => "user_override", @@ -148,7 +150,7 @@ impl LivePricingDefect { /// /// Producing this from provider-specific usage payloads (Chat Completions, /// Responses, Anthropic) is a separate concern; this layer only consumes it. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] pub struct TokenUsage { /// Non-cached input (prompt) tokens. pub input: u64, @@ -164,6 +166,14 @@ pub struct TokenUsage { pub cache_read: u64, /// Cache-write (cache-creation) tokens, billed at the cache-write rate. pub cache_write: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_audio: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_audio: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub request_usages: Vec, } /// A canonical billable token class. @@ -177,11 +187,22 @@ pub enum TokenClass { Output, CacheRead, CacheWrite, + Reasoning, + InputAudio, + OutputAudio, } impl TokenClass { /// Every class, in reporting order. - pub const ALL: [Self; 4] = [Self::Input, Self::Output, Self::CacheRead, Self::CacheWrite]; + pub const ALL: [Self; 7] = [ + Self::Input, + Self::Output, + Self::CacheRead, + Self::CacheWrite, + Self::Reasoning, + Self::InputAudio, + Self::OutputAudio, + ]; /// Stable, non-localized identifier for logs, JSON, and scorecards. #[must_use] @@ -191,6 +212,9 @@ impl TokenClass { Self::Output => "output", Self::CacheRead => "cache_read", Self::CacheWrite => "cache_write", + Self::Reasoning => "reasoning", + Self::InputAudio => "input_audio", + Self::OutputAudio => "output_audio", } } @@ -202,6 +226,9 @@ impl TokenClass { Self::Output => usage.output, Self::CacheRead => usage.cache_read, Self::CacheWrite => usage.cache_write, + Self::Reasoning => usage.reasoning.unwrap_or(0), + Self::InputAudio => usage.input_audio.unwrap_or(0), + Self::OutputAudio => usage.output_audio.unwrap_or(0), } } } @@ -211,7 +238,7 @@ impl TokenClass { /// Prices are per million tokens in [`Currency`]. Any field may be unknown /// (`None`); [`OfferingPricing::estimate_cost`] refuses to invent a number for a /// used class whose price is unknown. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] pub struct OfferingPricing { /// Provider id serving the offering. pub provider: String, @@ -234,11 +261,32 @@ pub struct OfferingPricing { /// Cache-write price per million tokens. #[serde(default, skip_serializing_if = "Option::is_none")] pub cache_write_per_million: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_per_million: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_audio_per_million: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_audio_per_million: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub context_tiers: Vec, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub context_tiers_inclusive: bool, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub rate_withheld: bool, /// Where the price came from. pub provenance: PricingProvenance, - /// Unix seconds the price was fetched / became effective, when known. + /// Actual rate-effective time, when the source declares one. Old serialized + /// provider rows used this as a fetch time; `fetched_at` takes precedence. #[serde(default, skip_serializing_if = "Option::is_none")] pub effective_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fetched_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upstream_last_updated: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_ref: Option, /// Fingerprint of the base URL this price was fetched from, for /// [`PricingProvenance::ProviderLive`] rows. /// @@ -251,16 +299,30 @@ pub struct OfferingPricing { pub endpoint_fingerprint: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuotedCost { + pub amount: String, + pub currency: Currency, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PricingQuoteError { + MissingRate(TokenClass), + MissingUsage(TokenClass), + InvalidUsage, + UnsupportedTier, + InvalidRate, +} + impl OfferingPricing { /// Derive a pricing row from a catalog offering's `cost`, when priced. /// - /// Returns `None` when the offering carries no cost, or a cost object with - /// no concrete price field — those routes are *unknown*, not free, and the - /// caller should render them as such (see [`route_pricing_sku`]). + /// Returns `None` when the offering carries no cost. An explicitly empty + /// cost object stays withheld, so another source cannot refill it. /// /// Models.dev `cost` values are USD per million tokens, so the currency is - /// [`Currency::Usd`]; provenance and `effective_at` follow the offering's - /// [`CatalogSource`]. + /// [`Currency::Usd`]; provenance and `fetched_at` follow the offering's + /// [`CatalogSource`]. The upstream update date stays separate. #[must_use] pub fn from_catalog_offering(offering: &CatalogOffering) -> Option { Self::from_catalog_offering_at(offering, crate::catalog::now_unix()) @@ -288,13 +350,14 @@ impl OfferingPricing { if !catalog_cost_is_valid(cost) { return None; } - if cost.input.is_none() + let rate_withheld = cost.input.is_none() && cost.output.is_none() && cost.cache_read.is_none() && cost.cache_write.is_none() - { - return None; - } + && cost.reasoning.is_none() + && cost.input_audio.is_none() + && cost.output_audio.is_none() + && cost.context_tiers().is_empty(); Some(Self { provider: offering.provider.clone(), wire_model_id: offering.wire_model_id.clone(), @@ -304,8 +367,34 @@ impl OfferingPricing { output_per_million: cost.output, cache_read_per_million: cost.cache_read, cache_write_per_million: cost.cache_write, + reasoning_per_million: cost.reasoning, + input_audio_per_million: cost.input_audio, + output_audio_per_million: cost.output_audio, + context_tiers: cost.context_tiers(), + context_tiers_inclusive: offering.provider == "xai", + rate_withheld, provenance: provenance_from_source(source), - effective_at: effective_at_from_source(source), + effective_at: None, + fetched_at: fetched_at_from_source(source), + expires_at: match source { + CatalogSource::CloudFacts { valid_until, .. } => *valid_until, + CatalogSource::Live { fetched_at, .. } + | CatalogSource::ModelsDevLive { fetched_at } => { + fetched_at.checked_add(LIVE_PRICING_MAX_AGE_SECS) + } + _ => None, + }, + upstream_last_updated: offering.last_updated.clone(), + source_ref: matches!( + source, + CatalogSource::Bundled | CatalogSource::ModelsDevLive { .. } + ) + .then(|| { + format!( + "https://models.dev/catalog.json#{}:{}", + offering.provider, offering.wire_model_id + ) + }), endpoint_fingerprint: endpoint_fingerprint_from_source(source), }) } @@ -317,17 +406,58 @@ impl OfferingPricing { || self.output_per_million.is_some() || self.cache_read_per_million.is_some() || self.cache_write_per_million.is_some() + || self.reasoning_per_million.is_some() + || self.input_audio_per_million.is_some() + || self.output_audio_per_million.is_some() + || !self.context_tiers.is_empty() } /// Whether this price is older than `max_age_secs` at `now_unix`. /// - /// Rows without an `effective_at` (bundled snapshot / user override) carry - /// no fetch clock and are not considered age-stale here; live rows are. + /// Live rows age against their fetch clock. Declared effective dates do + /// not become fetch dates; explicit expiry applies to every source. #[must_use] pub fn is_stale(&self, now_unix: u64, max_age_secs: u64) -> bool { - match self.effective_at { - Some(t) => now_unix.saturating_sub(t) >= max_age_secs, - None => false, + self.expires_at.is_some_and(|expires| now_unix >= expires) + || (matches!( + self.provenance, + PricingProvenance::ModelsDevLive | PricingProvenance::ProviderLive + ) && self + .fetched_at + .or(self.effective_at) + .is_some_and(|time| time > now_unix || now_unix - time >= max_age_secs)) + } + + #[must_use] + pub fn status_label_at(&self, now: u64) -> &'static str { + if self.rate_withheld || !self.has_any_price() { + return "unavailable"; + } + if self.is_stale(now, LIVE_PRICING_MAX_AGE_SECS) { + return "stale"; + } + if matches!( + self.provenance, + PricingProvenance::ProviderLive | PricingProvenance::ModelsDevLive + ) && self + .live_pricing_defect( + self.endpoint_fingerprint.as_deref(), + Some(now), + LIVE_PRICING_MAX_AGE_SECS, + ) + .is_some() + { + return "unverified"; + } + match self.provenance { + PricingProvenance::Unknown => "unverified", + PricingProvenance::ModelsDevBundled | PricingProvenance::CodewhaleCatalog => { + "bundled_estimate" + } + PricingProvenance::ModelsDevLive | PricingProvenance::ProviderLive => "live_estimate", + PricingProvenance::ProviderDocs => "documented_estimate", + PricingProvenance::UserOverride => "operator_estimate", + PricingProvenance::CloudFacts => "verified_cloud_estimate", } } @@ -351,22 +481,27 @@ impl OfferingPricing { now_unix: Option, max_age_secs: u64, ) -> Option { - if self.provenance != PricingProvenance::ProviderLive { + if !matches!( + self.provenance, + PricingProvenance::ProviderLive | PricingProvenance::ModelsDevLive + ) { return None; } - let Some(row_fingerprint) = self.endpoint_fingerprint.as_deref() else { - return Some(LivePricingDefect::MissingEndpointFingerprint); - }; - let Some(route_fingerprint) = route_endpoint_fingerprint else { - return Some(LivePricingDefect::UnknownRouteEndpoint); - }; - if row_fingerprint != route_fingerprint { - return Some(LivePricingDefect::EndpointMismatch { - row_fingerprint: row_fingerprint.to_string(), - route_fingerprint: route_fingerprint.to_string(), - }); + if self.provenance == PricingProvenance::ProviderLive { + let Some(row_fingerprint) = self.endpoint_fingerprint.as_deref() else { + return Some(LivePricingDefect::MissingEndpointFingerprint); + }; + let Some(route_fingerprint) = route_endpoint_fingerprint else { + return Some(LivePricingDefect::UnknownRouteEndpoint); + }; + if row_fingerprint != route_fingerprint { + return Some(LivePricingDefect::EndpointMismatch { + row_fingerprint: row_fingerprint.to_string(), + route_fingerprint: route_fingerprint.to_string(), + }); + } } - let Some(effective_at) = self.effective_at else { + let Some(fetched_at) = self.fetched_at.or(self.effective_at) else { return Some(LivePricingDefect::MissingTimestamp); }; // Without a clock the age is unknowable, so the row stays unproven @@ -374,8 +509,11 @@ impl OfferingPricing { let Some(now_unix) = now_unix else { return Some(LivePricingDefect::MissingTimestamp); }; - let age_secs = now_unix.saturating_sub(effective_at); - if age_secs >= max_age_secs { + let age_secs = now_unix.saturating_sub(fetched_at); + if fetched_at > now_unix + || age_secs >= max_age_secs + || self.expires_at.is_some_and(|expires| now_unix >= expires) + { return Some(LivePricingDefect::Stale { age_secs, max_age_secs, @@ -392,6 +530,9 @@ impl OfferingPricing { TokenClass::Output => self.output_per_million, TokenClass::CacheRead => self.cache_read_per_million, TokenClass::CacheWrite => self.cache_write_per_million, + TokenClass::Reasoning => self.reasoning_per_million, + TokenClass::InputAudio => self.input_audio_per_million, + TokenClass::OutputAudio => self.output_audio_per_million, } } @@ -402,37 +543,205 @@ impl OfferingPricing { /// gap instead of presenting a silently under-counted total. #[must_use] pub fn unpriced_used_classes(&self, usage: &TokenUsage) -> Vec { + if !usage.request_usages.is_empty() { + if usage.request_usages.len() > 1024 + || usage + .request_usages + .iter() + .any(|request| !request.request_usages.is_empty()) + { + return Vec::new(); + } + return usage + .request_usages + .iter() + .flat_map(|request| self.unpriced_used_classes(request)) + .collect::>() + .into_iter() + .collect(); + } + let Ok((cost, billable)) = self.rates_and_usage(usage) else { + return Vec::new(); + }; TokenClass::ALL .into_iter() - .filter(|class| class.tokens(usage) > 0 && self.price_per_million(*class).is_none()) + .filter(|class| class.tokens(&billable) > 0 && rate_for_class(&cost, *class).is_none()) .collect() } - /// Estimate the cost of `usage` in this row's [`Currency`]. - /// - /// Returns `None` if any usage class with a non-zero token count has an - /// unknown price — the estimate would otherwise silently under-report. With - /// all-zero usage the cost is `Some(0.0)`. + fn rates_and_usage( + &self, + usage: &TokenUsage, + ) -> Result<(ModelsDevCost, TokenUsage), PricingQuoteError> { + let base = self.cost_structure(); + if !catalog_cost_is_valid(&base) { + return Err(PricingQuoteError::InvalidRate); + } + if self + .context_tiers + .iter() + .any(|tier| tier.tier.kind != "context" || !tier.cost.context_tiers().is_empty()) + { + return Err(PricingQuoteError::UnsupportedTier); + } + let total_input = usage + .input + .checked_add(usage.cache_read) + .and_then(|total| total.checked_add(usage.cache_write)) + .ok_or(PricingQuoteError::InvalidUsage)?; + let cost = self + .context_tiers + .iter() + .filter(|tier| { + if self.context_tiers_inclusive { + total_input >= tier.tier.size + } else { + total_input > tier.tier.size + } + }) + .max_by_key(|tier| tier.tier.size) + .map_or(base, |tier| tier.cost.clone()); + let mut billable = usage.clone(); + let reasoning = usage.reasoning.unwrap_or(0); + let audio_output = usage.output_audio.unwrap_or(0); + let audio_input = usage.input_audio.unwrap_or(0); + if reasoning + .checked_add(audio_output) + .is_none_or(|total| total > usage.output) + || audio_input > usage.input + { + return Err(PricingQuoteError::InvalidUsage); + } + for (class, tokens, rate, total) in [ + ( + TokenClass::Reasoning, + usage.reasoning, + cost.reasoning, + usage.output, + ), + ( + TokenClass::InputAudio, + usage.input_audio, + cost.input_audio, + total_input, + ), + ( + TokenClass::OutputAudio, + usage.output_audio, + cost.output_audio, + usage.output, + ), + ] { + if rate.is_some() && tokens.is_none() && total > 0 { + return Err(PricingQuoteError::MissingUsage(class)); + } + } + if audio_input > 0 && (usage.cache_read > 0 || usage.cache_write > 0) { + return Err(PricingQuoteError::MissingUsage(TokenClass::InputAudio)); + } + billable.input = usage.input - audio_input; + billable.output = usage.output - audio_output; + if cost.reasoning.is_some() { + billable.output -= reasoning; + } else { + billable.reasoning = None; + } + Ok((cost, billable)) + } + #[must_use] - pub fn estimate_cost(&self, usage: &TokenUsage) -> Option { - let mut total = 0.0_f64; - for class in TokenClass::ALL { - let tokens = class.tokens(usage); - if tokens > 0 { - let price = self.price_per_million(class)?; - // Per-turn token counts are far below 2^53, so this cast is - // exact; revisit if TokenUsage ever aggregates across sessions. - let component = (tokens as f64 / 1_000_000.0) * price; - if !component.is_finite() || component < 0.0 { - return None; + pub fn cost_structure(&self) -> ModelsDevCost { + ModelsDevCost { + input: self.input_per_million, + output: self.output_per_million, + cache_read: self.cache_read_per_million, + cache_write: self.cache_write_per_million, + reasoning: self.reasoning_per_million, + input_audio: self.input_audio_per_million, + output_audio: self.output_audio_per_million, + tiers: (!self.context_tiers.is_empty()).then(|| self.context_tiers.clone()), + context_over_200k: None, + } + } + + pub fn quote_cost(&self, usage: &TokenUsage) -> Result { + if self.rate_withheld { + return Err(PricingQuoteError::MissingRate(TokenClass::Input)); + } + if !usage.request_usages.is_empty() { + if usage.request_usages.len() > 1024 { + return Err(PricingQuoteError::InvalidUsage); + } + let mut aggregate = [0_u64; 4]; + let mut total = Decimal::ZERO; + for request in &usage.request_usages { + if !request.request_usages.is_empty() { + return Err(PricingQuoteError::InvalidUsage); } - total += component; - if !total.is_finite() || total < 0.0 { - return None; + for (sum, count) in aggregate.iter_mut().zip([ + request.input, + request.output, + request.cache_read, + request.cache_write, + ]) { + *sum = sum + .checked_add(count) + .ok_or(PricingQuoteError::InvalidUsage)?; } + let quote = self.quote_cost(request)?; + let amount = Decimal::from_str_exact("e.amount) + .map_err(|_| PricingQuoteError::InvalidRate)?; + total = add_decimal_exact(total, amount)?; + } + if aggregate + != [ + usage.input, + usage.output, + usage.cache_read, + usage.cache_write, + ] + { + return Err(PricingQuoteError::InvalidUsage); + } + return Ok(QuotedCost { + amount: total.normalize().to_string(), + currency: self.currency.clone(), + }); + } + let (cost, billable) = self.rates_and_usage(usage)?; + let mut total = Decimal::ZERO; + for class in TokenClass::ALL { + let tokens = class.tokens(&billable); + if tokens == 0 { + continue; + } + let rate = rate_for_class(&cost, class).ok_or(PricingQuoteError::MissingRate(class))?; + let rate = Decimal::from_str_exact(&rate.to_string()) + .map_err(|_| PricingQuoteError::InvalidRate)?; + let mut coefficient = rate + .mantissa() + .checked_mul(i128::from(tokens)) + .ok_or(PricingQuoteError::InvalidRate)?; + let mut scale = rate.scale() + 6; + while scale > 0 && coefficient % 10 == 0 { + coefficient /= 10; + scale -= 1; } + let component = Decimal::try_from_i128_with_scale(coefficient, scale) + .map_err(|_| PricingQuoteError::InvalidRate)?; + total = add_decimal_exact(total, component)?; } - Some(total) + Ok(QuotedCost { + amount: total.normalize().to_string(), + currency: self.currency.clone(), + }) + } + + /// Estimate in major currency units; floating point is a display projection + /// of the exact decimal quote, never a second calculation authority. + #[must_use] + pub fn estimate_cost(&self, usage: &TokenUsage) -> Option { + self.quote_cost(usage).ok()?.amount.parse().ok() } /// Project to the coarse route-facing meter shape. @@ -460,8 +769,8 @@ impl OfferingPricing { /// An offering with a usable input/output rate becomes [`PricingSku::Token`]; /// everything else — no cost, a cost object with no concrete price, or a /// cache-only price — becomes [`PricingSku::UnknownOrStale`] rather than a -/// fabricated zero price. (`from_catalog_offering` collapses the unpriced case -/// to `None`; `to_route_sku` collapses the cache-only case.) +/// fabricated zero price. An explicit empty cost stays withheld; +/// `to_route_sku` keeps both it and a cache-only rate unknown. #[must_use] pub fn route_pricing_sku(offering: &CatalogOffering) -> PricingSku { OfferingPricing::from_catalog_offering(offering) @@ -519,18 +828,58 @@ pub const MAX_PLAUSIBLE_PRICE_PER_MILLION: f64 = 100_000.0; /// silently under-counted bill, which is worse than no price at all. #[must_use] pub fn catalog_cost_is_valid(cost: &ModelsDevCost) -> bool { - [cost.input, cost.output, cost.cache_read, cost.cache_write] + cost.validate().is_ok() + && cost.tiers.as_ref().is_none_or(|tiers| tiers.len() <= 128) + && [ + cost.input, + cost.output, + cost.cache_read, + cost.cache_write, + cost.reasoning, + cost.input_audio, + cost.output_audio, + ] .into_iter() .flatten() .all(|price| price.is_finite() && (0.0..=MAX_PLAUSIBLE_PRICE_PER_MILLION).contains(&price)) + && cost + .tiers + .iter() + .flatten() + .all(|tier| catalog_cost_is_valid(&tier.cost)) + && cost + .context_over_200k + .as_ref() + .is_none_or(|cost| catalog_cost_is_valid(cost)) +} + +fn add_decimal_exact(total: Decimal, component: Decimal) -> Result { + let next = total + .checked_add(component) + .ok_or(PricingQuoteError::InvalidRate)?; + if next.checked_sub(total) != Some(component) { + return Err(PricingQuoteError::InvalidRate); + } + Ok(next) +} + +fn rate_for_class(cost: &ModelsDevCost, class: TokenClass) -> Option { + match class { + TokenClass::Input => cost.input, + TokenClass::Output => cost.output, + TokenClass::CacheRead => cost.cache_read, + TokenClass::CacheWrite => cost.cache_write, + TokenClass::Reasoning => cost.reasoning, + TokenClass::InputAudio => cost.input_audio, + TokenClass::OutputAudio => cost.output_audio, + } } fn provenance_from_source(source: &CatalogSource) -> PricingProvenance { match source { CatalogSource::CloudFacts { .. } => PricingProvenance::CloudFacts, - CatalogSource::Bundled | CatalogSource::ModelsDevLive { .. } => { - PricingProvenance::ModelsDevBundled - } + CatalogSource::Bundled => PricingProvenance::ModelsDevBundled, + CatalogSource::ModelsDevLive { .. } => PricingProvenance::ModelsDevLive, CatalogSource::Live { .. } => PricingProvenance::ProviderLive, CatalogSource::ConfigOverride | CatalogSource::UserOverride => { PricingProvenance::UserOverride @@ -539,7 +888,7 @@ fn provenance_from_source(source: &CatalogSource) -> PricingProvenance { } } -fn effective_at_from_source(source: &CatalogSource) -> Option { +fn fetched_at_from_source(source: &CatalogSource) -> Option { match source { CatalogSource::Live { fetched_at, .. } | CatalogSource::ModelsDevLive { fetched_at } diff --git a/crates/config/src/pricing/tests.rs b/crates/config/src/pricing/tests.rs index 26339fd1ab..7fb86d8025 100644 --- a/crates/config/src/pricing/tests.rs +++ b/crates/config/src/pricing/tests.rs @@ -42,7 +42,7 @@ fn maps_models_dev_cost_with_bundled_provenance_in_usd() { #[test] fn malformed_catalog_prices_fail_closed_at_every_projection() { for invalid in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -0.01] { - for field in 0..4 { + for field in 0..7 { let mut offering = priced(CatalogSource::Bundled); offering.provider = "openrouter".to_string(); offering.wire_model_id = "openai/gpt-5.5".to_string(); @@ -52,6 +52,9 @@ fn malformed_catalog_prices_fail_closed_at_every_projection() { 1 => cost.output = Some(invalid), 2 => cost.cache_read = Some(invalid), 3 => cost.cache_write = Some(invalid), + 4 => cost.reasoning = Some(invalid), + 5 => cost.input_audio = Some(invalid), + 6 => cost.output_audio = Some(invalid), _ => unreachable!(), } assert!( @@ -94,7 +97,7 @@ fn absurd_catalog_prices_fail_closed_at_every_projection() { f64::MAX, ]; for price in absurd { - for field in 0..4 { + for field in 0..7 { let mut offering = priced(CatalogSource::Bundled); let cost = offering.cost.as_mut().expect("cost fixture"); match field { @@ -102,6 +105,9 @@ fn absurd_catalog_prices_fail_closed_at_every_projection() { 1 => cost.output = Some(price), 2 => cost.cache_read = Some(price), 3 => cost.cache_write = Some(price), + 4 => cost.reasoning = Some(price), + 5 => cost.input_audio = Some(price), + 6 => cost.output_audio = Some(price), _ => unreachable!(), } assert!( @@ -139,14 +145,15 @@ fn absurd_catalog_prices_fail_closed_at_every_projection() { } #[test] -fn live_source_carries_provider_live_provenance_and_effective_at() { +fn live_source_carries_provider_live_provenance_and_fetch_time() { let src = CatalogSource::Live { base_url_fingerprint: "fp".into(), fetched_at: 1_700, }; let p = OfferingPricing::from_catalog_offering(&priced(src)).expect("priced"); assert_eq!(p.provenance, PricingProvenance::ProviderLive); - assert_eq!(p.effective_at, Some(1_700)); + assert_eq!(p.effective_at, None); + assert_eq!(p.fetched_at, Some(1_700)); assert_eq!(p.endpoint_fingerprint.as_deref(), Some("fp")); } @@ -246,7 +253,10 @@ fn no_cost_or_empty_cost_object_is_unknown() { // A cost object present but with no concrete price is still unknown. offering.cost = Some(ModelsDevCost::default()); - assert!(OfferingPricing::from_catalog_offering(&offering).is_none()); + let withheld = + OfferingPricing::from_catalog_offering(&offering).expect("explicit withheld rate"); + assert!(withheld.rate_withheld); + assert_eq!(withheld.estimate_cost(&TokenUsage::default()), None); } #[test] @@ -258,6 +268,12 @@ fn estimate_cost_sums_priced_classes() { output: 500_000, cache_read: 2_000_000, cache_write: 0, + + reasoning: None, + input_audio: None, + output_audio: None, + + request_usages: Vec::new(), }; let cost = p.estimate_cost(&usage).expect("priced classes estimate"); assert!((cost - 0.546).abs() < 1e-9, "got {cost}"); @@ -273,6 +289,12 @@ fn estimate_cost_is_none_when_a_used_class_is_unpriced() { output: 0, cache_read: 0, cache_write: 10, + + reasoning: None, + input_audio: None, + output_audio: None, + + request_usages: Vec::new(), }; assert!(p.estimate_cost(&usage).is_none()); } @@ -303,6 +325,8 @@ fn finite_rates_that_overflow_the_computed_total_fail_closed() { provenance: PricingProvenance::ModelsDevBundled, effective_at: None, endpoint_fingerprint: None, + + ..Default::default() }; let usage = TokenUsage { input: u64::MAX, @@ -362,6 +386,8 @@ fn user_override_pricing_round_trips_and_carries_no_secrets() { provenance: PricingProvenance::UserOverride, effective_at: None, endpoint_fingerprint: None, + + ..Default::default() }; let json = serde_json::to_string_pretty(&pricing).expect("serialize"); let back: OfferingPricing = serde_json::from_str(&json).expect("round-trip"); @@ -488,6 +514,12 @@ fn unpriced_used_classes_names_exactly_what_makes_an_estimate_fail_closed() { output: 1_000_000, cache_read: 1_000_000, cache_write: 0, + + reasoning: None, + input_audio: None, + output_audio: None, + + request_usages: Vec::new(), }; assert!(pricing.unpriced_used_classes(&no_write).is_empty()); assert_eq!(pricing.estimate_cost(&no_write), Some(0.28 + 0.42 + 0.028)); @@ -517,6 +549,12 @@ fn token_class_labels_and_counts_stay_aligned_with_token_usage() { output: 2, cache_read: 3, cache_write: 4, + + reasoning: None, + input_audio: None, + output_audio: None, + + request_usages: Vec::new(), }; let seen: Vec<(&str, u64)> = TokenClass::ALL .into_iter() @@ -529,6 +567,9 @@ fn token_class_labels_and_counts_stay_aligned_with_token_usage() { ("output", 2), ("cache_read", 3), ("cache_write", 4), + ("reasoning", 0), + ("input_audio", 0), + ("output_audio", 0), ] ); } diff --git a/crates/config/src/route/providers-export.golden.json b/crates/config/src/route/providers-export.golden.json index 3b87521650..880711184b 100644 --- a/crates/config/src/route/providers-export.golden.json +++ b/crates/config/src/route/providers-export.golden.json @@ -27,13 +27,15 @@ "id": "claude-fable-5" }, { - "id": "claude-haiku-4-5" + "id": "claude-haiku-4-5", + "canonical": "anthropic/claude-haiku-4-5" }, { "id": "claude-opus-4-8" }, { - "id": "claude-opus-5" + "id": "claude-opus-5", + "canonical": "anthropic/claude-opus-5" }, { "id": "claude-sonnet-4-6", @@ -64,6 +66,7 @@ "models": [ { "id": "trinity-large-thinking", + "canonical": "arcee-ai/trinity-large-thinking", "default": true }, { @@ -198,20 +201,28 @@ "models": [ { "id": "deepseek-flash", - "canonical": "deepseek-flash", + "canonical": "deepseek/deepseek-v4.1-flash", "default": true }, { "id": "deepseek-v4-flash", - "canonical": "deepseek-v4-flash" + "canonical": "deepseek/deepseek-v4.1-flash" }, { "id": "deepseek-v4-flash-vision-exp", - "canonical": "deepseek-v4-flash" + "canonical": "deepseek/deepseek-v4.1-flash" }, { "id": "deepseek-v4-pro", - "canonical": "deepseek-v4-pro" + "canonical": "deepseek/deepseek-v4-pro-0813" + }, + { + "id": "deepseek-v4-pro-0813", + "canonical": "deepseek/deepseek-v4-pro-0813" + }, + { + "id": "deepseek-v4.1-flash", + "canonical": "deepseek/deepseek-v4.1-flash" } ], "transport": "model-aware" @@ -352,14 +363,17 @@ ], "models": [ { - "id": "muse-spark-1.1" + "id": "muse-spark-1.1", + "canonical": "meta/muse-spark-1.1" }, { "id": "muse-spark-1.2", + "canonical": "meta/muse-spark-1.2", "default": true }, { - "id": "muse-spark-1.2-contributor" + "id": "muse-spark-1.2-contributor", + "canonical": "meta/muse-spark-1.2" } ], "transport": "chat-completions" @@ -479,29 +493,29 @@ ], "models": [ { - "id": "deepseek-v4-flash-0731" - }, - { - "id": "deepseek-v4-pro" - }, - { - "id": "glm-5.2" + "id": "deepseek-v4-flash-0731", + "canonical": "deepseek/deepseek-v4-flash-0731" }, { - "id": "qwen3.6-flash" + "id": "deepseek-v4-pro", + "canonical": "deepseek/deepseek-v4-pro" }, { - "id": "qwen3.7-max" + "id": "glm-5.2", + "canonical": "zhipuai/glm-5.2" }, { - "id": "qwen3.7-plus" + "id": "qwen3.7-plus", + "canonical": "alibaba/qwen3.7-plus" }, { "id": "qwen3.8-max", + "canonical": "alibaba/qwen3.8-max", "default": true }, { - "id": "qwen3.8-max-preview" + "id": "qwen3.8-max-preview", + "canonical": "alibaba/qwen3.8-max-preview" } ], "transport": "chat-completions" @@ -525,29 +539,29 @@ ], "models": [ { - "id": "deepseek-v4-flash-0731" + "id": "deepseek-v4-flash-0731", + "canonical": "deepseek/deepseek-v4-flash-0731" }, { - "id": "deepseek-v4-pro" - }, - { - "id": "glm-5.2" - }, - { - "id": "qwen3.6-flash" + "id": "deepseek-v4-pro", + "canonical": "deepseek/deepseek-v4-pro" }, { - "id": "qwen3.7-max" + "id": "glm-5.2", + "canonical": "zhipuai/glm-5.2" }, { - "id": "qwen3.7-plus" + "id": "qwen3.7-plus", + "canonical": "alibaba/qwen3.7-plus" }, { "id": "qwen3.8-max", + "canonical": "alibaba/qwen3.8-max", "default": true }, { - "id": "qwen3.8-max-preview" + "id": "qwen3.8-max-preview", + "canonical": "alibaba/qwen3.8-max-preview" } ], "transport": "anthropic-messages" @@ -571,29 +585,37 @@ ], "models": [ { - "id": "deepseek-v4-flash-0731" + "id": "deepseek-v4-flash-0731", + "canonical": "deepseek/deepseek-v4-flash-0731" }, { - "id": "deepseek-v4-pro" + "id": "deepseek-v4-pro", + "canonical": "deepseek/deepseek-v4-pro" }, { - "id": "glm-5.2" + "id": "glm-5.2", + "canonical": "zhipuai/glm-5.2" }, { - "id": "qwen3.6-flash" + "id": "qwen3.6-flash", + "canonical": "alibaba/qwen3.6-flash" }, { - "id": "qwen3.7-max" + "id": "qwen3.7-max", + "canonical": "alibaba/qwen3.7-max" }, { - "id": "qwen3.7-plus" + "id": "qwen3.7-plus", + "canonical": "alibaba/qwen3.7-plus" }, { "id": "qwen3.8-max", + "canonical": "alibaba/qwen3.8-max", "default": true }, { - "id": "qwen3.8-max-preview" + "id": "qwen3.8-max-preview", + "canonical": "alibaba/qwen3.8-max-preview" } ], "transport": "chat-completions" @@ -617,29 +639,37 @@ ], "models": [ { - "id": "deepseek-v4-flash-0731" + "id": "deepseek-v4-flash-0731", + "canonical": "deepseek/deepseek-v4-flash-0731" }, { - "id": "deepseek-v4-pro" + "id": "deepseek-v4-pro", + "canonical": "deepseek/deepseek-v4-pro" }, { - "id": "glm-5.2" + "id": "glm-5.2", + "canonical": "zhipuai/glm-5.2" }, { - "id": "qwen3.6-flash" + "id": "qwen3.6-flash", + "canonical": "alibaba/qwen3.6-flash" }, { - "id": "qwen3.7-max" + "id": "qwen3.7-max", + "canonical": "alibaba/qwen3.7-max" }, { - "id": "qwen3.7-plus" + "id": "qwen3.7-plus", + "canonical": "alibaba/qwen3.7-plus" }, { "id": "qwen3.8-max", + "canonical": "alibaba/qwen3.8-max", "default": true }, { - "id": "qwen3.8-max-preview" + "id": "qwen3.8-max-preview", + "canonical": "alibaba/qwen3.8-max-preview" } ], "transport": "anthropic-messages" @@ -667,13 +697,16 @@ }, { "id": "kimi-k2.7-code", + "canonical": "moonshotai/kimi-k2.7-code", "default": true }, { - "id": "kimi-k2.7-code-highspeed" + "id": "kimi-k2.7-code-highspeed", + "canonical": "moonshotai/kimi-k2.7-code" }, { - "id": "kimi-k3" + "id": "kimi-k3", + "canonical": "moonshotai/kimi-k3" } ], "transport": "chat-completions" @@ -695,13 +728,9 @@ } ], "models": [ - { - "id": "deepseek/deepseek-v4-flash", - "canonical": "deepseek-v4-flash" - }, { "id": "deepseek/deepseek-v4-pro", - "canonical": "deepseek-v4-pro", + "canonical": "deepseek/deepseek-v4-pro", "default": true } ], @@ -791,16 +820,20 @@ }, { "id": "gpt-5.6", + "canonical": "openai/gpt-5.6-sol", "default": true }, { - "id": "gpt-5.6-luna" + "id": "gpt-5.6-luna", + "canonical": "openai/gpt-5.6-luna" }, { - "id": "gpt-5.6-sol" + "id": "gpt-5.6-sol", + "canonical": "openai/gpt-5.6-sol" }, { - "id": "gpt-5.6-terra" + "id": "gpt-5.6-terra", + "canonical": "openai/gpt-5.6-terra" } ], "transport": "chat-completions" @@ -898,42 +931,51 @@ "models": [ { "id": "deepseek/deepseek-v4-flash", - "canonical": "deepseek-v4-flash" + "canonical": "deepseek/deepseek-v4-flash" }, { "id": "deepseek/deepseek-v4-pro", - "canonical": "deepseek-v4-pro", + "canonical": "deepseek/deepseek-v4-pro", "default": true }, { "id": "dots-studio/dots-3-note-preview:free" }, { - "id": "minimax/minimax-m3" + "id": "minimax/minimax-m3", + "canonical": "minimax/MiniMax-M3" }, { - "id": "qwen/qwen3.6-35b-a3b" + "id": "qwen/qwen3.6-35b-a3b", + "canonical": "alibaba/qwen3.6-35b-a3b" }, { - "id": "qwen/qwen3.6-flash" + "id": "qwen/qwen3.6-flash", + "canonical": "alibaba/qwen3.6-flash" }, { - "id": "qwen/qwen3.6-plus" + "id": "qwen/qwen3.6-plus", + "canonical": "alibaba/qwen3.6-plus" }, { - "id": "qwen/qwen3.7-plus" + "id": "qwen/qwen3.7-plus", + "canonical": "alibaba/qwen3.7-plus" }, { - "id": "qwen/qwen3.8-flash" + "id": "qwen/qwen3.8-flash", + "canonical": "alibaba/qwen3.8-flash" }, { - "id": "z-ai/glm-5.2" + "id": "z-ai/glm-5.2", + "canonical": "zhipuai/glm-5.2" }, { - "id": "z-ai/glm-5.3" + "id": "z-ai/glm-5.3", + "canonical": "zhipuai/glm-5.3" }, { - "id": "z-ai/glm-5.3-flash" + "id": "z-ai/glm-5.3-flash", + "canonical": "zhipuai/glm-5.3-flash" } ], "transport": "chat-completions" @@ -1035,11 +1077,11 @@ "models": [ { "id": "deepseek-ai/DeepSeek-V4-Flash", - "canonical": "deepseek-v4-flash" + "canonical": "deepseek/deepseek-v4-flash" }, { "id": "deepseek-ai/DeepSeek-V4-Pro", - "canonical": "deepseek-v4-pro", + "canonical": "deepseek/deepseek-v4-pro", "default": true } ], @@ -1089,10 +1131,12 @@ }, { "id": "step-3.7-flash", + "canonical": "stepfun/step-3.7-flash", "default": true }, { - "id": "step-5-preview" + "id": "step-5-preview", + "canonical": "stepfun/step-5-preview" } ], "transport": "chat-completions" @@ -1227,14 +1271,17 @@ "id": "grok-4.3" }, { - "id": "grok-4.5" + "id": "grok-4.5", + "canonical": "xai/grok-4.5" }, { "id": "grok-4.6", + "canonical": "xai/grok-4.6", "default": true }, { - "id": "grok-4.7" + "id": "grok-4.7", + "canonical": "xai/grok-4.7" } ], "transport": "chat-completions" @@ -1300,17 +1347,21 @@ "id": "GLM-5-Turbo" }, { - "id": "GLM-5.2" + "id": "GLM-5.2", + "canonical": "zhipuai/glm-5.2" }, { "id": "GLM-5.3", + "canonical": "zhipuai/glm-5.3", "default": true }, { - "id": "GLM-5.3-Flash" + "id": "GLM-5.3-Flash", + "canonical": "zhipuai/glm-5.3-flash" }, { - "id": "glm-5.1" + "id": "glm-5.1", + "canonical": "zhipuai/glm-5.1" } ], "transport": "chat-completions" diff --git a/crates/config/src/route/resolver.rs b/crates/config/src/route/resolver.rs index 00a17b89da..57b2b109a6 100644 --- a/crates/config/src/route/resolver.rs +++ b/crates/config/src/route/resolver.rs @@ -610,6 +610,8 @@ impl RouteResolver { && matches!(provider_kind, ProviderKind::Deepseek | ProviderKind::Zai); let mut casefold_match = None; let mut casefold_ambiguous = false; + let mut suffix_match = None; + let mut suffix_ambiguous = false; for offering in &self.offerings { if offering.provider != *provider_id { continue; @@ -622,6 +624,30 @@ impl RouteResolver { if matches_canonical || matches_wire { return Ok(ResolvedOffering::from_offering(offering)); } + // Aggregator wire ids carry an org namespace (`deepseek/`) + // while saved selectors and canonical joins use the bare logical + // id. A bare selector that uniquely names the last segment of + // one wire id owned by THIS provider resolves to that offering; + // ambiguity stays unmatched rather than guessing. + let wire_tail = offering + .wire_model_id + .as_str() + .rsplit('/') + .next() + .unwrap_or_default(); + let canonical_tail = offering + .canonical_model + .as_ref() + .map(|model| model.as_str().rsplit('/').next().unwrap_or_default()); + if !raw.contains('/') && (wire_tail == raw || canonical_tail == Some(raw)) { + match suffix_match { + None => suffix_match = Some(offering), + Some(first) if first.wire_model_id != offering.wire_model_id => { + suffix_ambiguous = true; + } + Some(_) => {} + } + } if allow_casefold_wire_match && offering.wire_model_id.as_str().eq_ignore_ascii_case(raw) { @@ -632,6 +658,9 @@ impl RouteResolver { } } } + if !suffix_ambiguous && let Some(offering) = suffix_match { + return Ok(ResolvedOffering::from_offering(offering)); + } if !casefold_ambiguous && let Some(offering) = casefold_match { return Ok(ResolvedOffering::from_offering(offering)); } diff --git a/crates/localization/locales/ca.json b/crates/localization/locales/ca.json index b05c0f1d7f..b9411ac284 100644 --- a/crates/localization/locales/ca.json +++ b/crates/localization/locales/ca.json @@ -2412,6 +2412,7 @@ "CostReasonInvalidRate": "tarifa invàlida", "CostReasonCurrencyMissing": "tarifa en aquesta moneda no disponible", "CostReasonUsageConflict": "recomptes de tokens contradictoris", + "CostReasonUsageBreakdown": "desglossament de tokens no disponible", "CostChipUnknown": "cost: desconegut ({reasons})", "CostChipSubtotal": "subtotal {amount} + desconegut ({reasons})", "CostChipSavedSubtotal": "subtotal desat {amount} + desconegut ({reasons})", diff --git a/crates/localization/locales/de.json b/crates/localization/locales/de.json index 6c8cf27432..fa6cd6b19c 100644 --- a/crates/localization/locales/de.json +++ b/crates/localization/locales/de.json @@ -2412,6 +2412,7 @@ "CostReasonInvalidRate": "ungültiger Tarif", "CostReasonCurrencyMissing": "Tarif in dieser Währung fehlt", "CostReasonUsageConflict": "widersprüchliche Tokenzahlen", + "CostReasonUsageBreakdown": "Aufschlüsselung der Token-Nutzung nicht verfügbar", "CostChipUnknown": "Kosten: unbekannt ({reasons})", "CostChipSubtotal": "Zwischensumme {amount} + unbekannt ({reasons})", "CostChipSavedSubtotal": "gespeicherte Zwischensumme {amount} + unbekannt ({reasons})", diff --git a/crates/localization/locales/en.json b/crates/localization/locales/en.json index 60b7d7583e..3f5eda45a7 100644 --- a/crates/localization/locales/en.json +++ b/crates/localization/locales/en.json @@ -2412,6 +2412,7 @@ "CostReasonInvalidRate": "invalid rate", "CostReasonCurrencyMissing": "currency rate unavailable", "CostReasonUsageConflict": "conflicting token counts", + "CostReasonUsageBreakdown": "token usage breakdown unavailable", "CostChipUnknown": "cost: unknown ({reasons})", "CostChipSubtotal": "subtotal {amount} + unknown ({reasons})", "CostChipSavedSubtotal": "saved subtotal {amount} + unknown ({reasons})", diff --git a/crates/localization/locales/es-419.json b/crates/localization/locales/es-419.json index 4be45f651e..2581715fc0 100644 --- a/crates/localization/locales/es-419.json +++ b/crates/localization/locales/es-419.json @@ -2412,6 +2412,7 @@ "CostReasonInvalidRate": "tarifa inválida", "CostReasonCurrencyMissing": "tarifa en esta moneda no disponible", "CostReasonUsageConflict": "recuentos de tokens contradictorios", + "CostReasonUsageBreakdown": "desglose de tokens no disponible", "CostChipUnknown": "costo: desconocido ({reasons})", "CostChipSubtotal": "subtotal {amount} + desconocido ({reasons})", "CostChipSavedSubtotal": "subtotal guardado {amount} + desconocido ({reasons})", diff --git a/crates/localization/locales/fr.json b/crates/localization/locales/fr.json index c3e08d8815..69c3384758 100644 --- a/crates/localization/locales/fr.json +++ b/crates/localization/locales/fr.json @@ -2412,6 +2412,7 @@ "CostReasonInvalidRate": "tarif invalide", "CostReasonCurrencyMissing": "tarif dans cette devise indisponible", "CostReasonUsageConflict": "comptages de tokens contradictoires", + "CostReasonUsageBreakdown": "répartition des tokens indisponible", "CostChipUnknown": "coût : inconnu ({reasons})", "CostChipSubtotal": "sous-total {amount} + inconnu ({reasons})", "CostChipSavedSubtotal": "sous-total enregistré {amount} + inconnu ({reasons})", diff --git a/crates/localization/locales/hi.json b/crates/localization/locales/hi.json index 8e32a0ea16..fdfb6e7485 100644 --- a/crates/localization/locales/hi.json +++ b/crates/localization/locales/hi.json @@ -2412,6 +2412,7 @@ "CostReasonInvalidRate": "अमान्य दर", "CostReasonCurrencyMissing": "इस मुद्रा की दर उपलब्ध नहीं", "CostReasonUsageConflict": "टोकन गणनाओं में विरोध", + "CostReasonUsageBreakdown": "टोकन उपयोग का विवरण उपलब्ध नहीं", "CostChipUnknown": "लागत: अज्ञात ({reasons})", "CostChipSubtotal": "उप-योग {amount} + अज्ञात ({reasons})", "CostChipSavedSubtotal": "सहेजा गया उप-योग {amount} + अज्ञात ({reasons})", diff --git a/crates/localization/locales/id.json b/crates/localization/locales/id.json index a4e1d61205..f7d1e6cc50 100644 --- a/crates/localization/locales/id.json +++ b/crates/localization/locales/id.json @@ -2412,6 +2412,7 @@ "CostReasonInvalidRate": "tarif tidak valid", "CostReasonCurrencyMissing": "tarif mata uang tidak tersedia", "CostReasonUsageConflict": "jumlah token bertentangan", + "CostReasonUsageBreakdown": "rincian penggunaan token tidak tersedia", "CostChipUnknown": "biaya: belum diketahui ({reasons})", "CostChipSubtotal": "subtotal {amount} + belum diketahui ({reasons})", "CostChipSavedSubtotal": "subtotal tersimpan {amount} + belum diketahui ({reasons})", diff --git a/crates/localization/locales/ja.json b/crates/localization/locales/ja.json index 8be323bbf2..be58ba9980 100644 --- a/crates/localization/locales/ja.json +++ b/crates/localization/locales/ja.json @@ -2412,6 +2412,7 @@ "CostReasonInvalidRate": "無効な料金", "CostReasonCurrencyMissing": "通貨の料金情報なし", "CostReasonUsageConflict": "トークン数が不整合", + "CostReasonUsageBreakdown": "トークン使用量の内訳が不明", "CostChipUnknown": "費用: 不明({reasons})", "CostChipSubtotal": "小計 {amount} + 不明({reasons})", "CostChipSavedSubtotal": "保存済み小計 {amount} + 不明({reasons})", diff --git a/crates/localization/locales/ko.json b/crates/localization/locales/ko.json index ef2fa8368c..95b9a9802e 100644 --- a/crates/localization/locales/ko.json +++ b/crates/localization/locales/ko.json @@ -2412,6 +2412,7 @@ "CostReasonInvalidRate": "잘못된 요금", "CostReasonCurrencyMissing": "해당 통화 요금 없음", "CostReasonUsageConflict": "토큰 수 불일치", + "CostReasonUsageBreakdown": "토큰 사용량 내역을 알 수 없음", "CostChipUnknown": "비용: 알 수 없음 ({reasons})", "CostChipSubtotal": "소계 {amount} + 알 수 없음 ({reasons})", "CostChipSavedSubtotal": "저장된 소계 {amount} + 알 수 없음 ({reasons})", diff --git a/crates/localization/locales/pt-BR.json b/crates/localization/locales/pt-BR.json index 75025f1551..10c2b38a97 100644 --- a/crates/localization/locales/pt-BR.json +++ b/crates/localization/locales/pt-BR.json @@ -2412,6 +2412,7 @@ "CostReasonInvalidRate": "tarifa inválida", "CostReasonCurrencyMissing": "tarifa nesta moeda indisponível", "CostReasonUsageConflict": "contagens de tokens conflitantes", + "CostReasonUsageBreakdown": "detalhamento de tokens indisponível", "CostChipUnknown": "custo: desconhecido ({reasons})", "CostChipSubtotal": "subtotal {amount} + desconhecido ({reasons})", "CostChipSavedSubtotal": "subtotal salvo {amount} + desconhecido ({reasons})", diff --git a/crates/localization/locales/ru.json b/crates/localization/locales/ru.json index f8be8f7141..84e822826c 100644 --- a/crates/localization/locales/ru.json +++ b/crates/localization/locales/ru.json @@ -2412,6 +2412,7 @@ "CostReasonInvalidRate": "некорректный тариф", "CostReasonCurrencyMissing": "тариф в этой валюте недоступен", "CostReasonUsageConflict": "противоречивые счётчики токенов", + "CostReasonUsageBreakdown": "разбивка токенов недоступна", "CostChipUnknown": "стоимость: неизвестна ({reasons})", "CostChipSubtotal": "подытог {amount} + неизвестно ({reasons})", "CostChipSavedSubtotal": "сохранённый подытог {amount} + неизвестно ({reasons})", diff --git a/crates/localization/locales/uk.json b/crates/localization/locales/uk.json index 1cd99f2038..ee517062ad 100644 --- a/crates/localization/locales/uk.json +++ b/crates/localization/locales/uk.json @@ -2412,6 +2412,7 @@ "CostReasonInvalidRate": "некоректний тариф", "CostReasonCurrencyMissing": "тариф у цій валюті недоступний", "CostReasonUsageConflict": "суперечливі лічильники токенів", + "CostReasonUsageBreakdown": "деталізація токенів недоступна", "CostChipUnknown": "вартість: невідома ({reasons})", "CostChipSubtotal": "проміжний підсумок {amount} + невідомо ({reasons})", "CostChipSavedSubtotal": "збережений проміжний підсумок {amount} + невідомо ({reasons})", diff --git a/crates/localization/locales/vi.json b/crates/localization/locales/vi.json index a2455947aa..4db81cdc4a 100644 --- a/crates/localization/locales/vi.json +++ b/crates/localization/locales/vi.json @@ -2412,6 +2412,7 @@ "CostReasonInvalidRate": "đơn giá không hợp lệ", "CostReasonCurrencyMissing": "thiếu giá cho loại tiền", "CostReasonUsageConflict": "số token không nhất quán", + "CostReasonUsageBreakdown": "không có chi tiết sử dụng token", "CostChipUnknown": "chi phí: chưa rõ ({reasons})", "CostChipSubtotal": "tạm tính {amount} + chưa rõ ({reasons})", "CostChipSavedSubtotal": "tạm tính đã lưu {amount} + chưa rõ ({reasons})", diff --git a/crates/localization/locales/zh-Hans.json b/crates/localization/locales/zh-Hans.json index 17fa785f8f..ccb9c3f5fb 100644 --- a/crates/localization/locales/zh-Hans.json +++ b/crates/localization/locales/zh-Hans.json @@ -2412,6 +2412,7 @@ "CostReasonInvalidRate": "费率无效", "CostReasonCurrencyMissing": "缺少该币种费率", "CostReasonUsageConflict": "token 计数冲突", + "CostReasonUsageBreakdown": "token 用量明细不可用", "CostChipUnknown": "费用:未知({reasons})", "CostChipSubtotal": "小计 {amount} + 未知({reasons})", "CostChipSavedSubtotal": "已保存小计 {amount} + 未知({reasons})", diff --git a/crates/localization/locales/zh-Hant.json b/crates/localization/locales/zh-Hant.json index e92d5ee523..72f6c27095 100644 --- a/crates/localization/locales/zh-Hant.json +++ b/crates/localization/locales/zh-Hant.json @@ -2410,6 +2410,7 @@ "CostReasonInvalidRate": "費率無效", "CostReasonCurrencyMissing": "缺少該幣別費率", "CostReasonUsageConflict": "token 計數衝突", + "CostReasonUsageBreakdown": "token 用量明細不可用", "CostChipUnknown": "費用:未知({reasons})", "CostChipSubtotal": "小計 {amount} + 未知({reasons})", "CostChipSavedSubtotal": "已儲存小計 {amount} + 未知({reasons})", diff --git a/crates/localization/src/lib.rs b/crates/localization/src/lib.rs index 2b5359165b..89cb3bf64c 100644 --- a/crates/localization/src/lib.rs +++ b/crates/localization/src/lib.rs @@ -145,6 +145,7 @@ pub enum MessageId { CostReasonInvalidRate, CostReasonCurrencyMissing, CostReasonUsageConflict, + CostReasonUsageBreakdown, CostChipUnknown, CostChipSubtotal, CostChipSavedSubtotal, @@ -2817,6 +2818,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::CostReasonInvalidRate, MessageId::CostReasonCurrencyMissing, MessageId::CostReasonUsageConflict, + MessageId::CostReasonUsageBreakdown, MessageId::CostChipUnknown, MessageId::CostChipSubtotal, MessageId::CostChipSavedSubtotal, diff --git a/crates/models/src/lib.rs b/crates/models/src/lib.rs index d6b586525e..00993e2721 100644 --- a/crates/models/src/lib.rs +++ b/crates/models/src/lib.rs @@ -176,6 +176,12 @@ pub struct Usage { pub prompt_cache_write_tokens: Option, #[serde(skip_serializing_if = "Option::is_none")] pub reasoning_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_audio_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_audio_tokens: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub request_usages: Vec, /// Approximate input tokens spent re-sending prior `reasoning_content` /// across user-message boundaries in DeepSeek V4 thinking-mode tool-calling /// turns (V4 §5.1.1 "Interleaved Thinking"). Estimated client-side at diff --git a/crates/runtime/src/llm_response_cache.rs b/crates/runtime/src/llm_response_cache.rs index feab5a8a85..446c2089a9 100644 --- a/crates/runtime/src/llm_response_cache.rs +++ b/crates/runtime/src/llm_response_cache.rs @@ -98,6 +98,11 @@ mod tests { reasoning_tokens: Some(5), reasoning_replay_tokens: Some(2), server_tool_use: None, + + input_audio_tokens: None, + output_audio_tokens: None, + + request_usages: Vec::new(), }, } } diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 29592b8cff..32ee7352e7 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.10.2] - 2026-10-10 +- Model lists, resolution, picker controls and Runtime facts use the same + endpoint-scoped catalog facts. Cost estimates preserve complete price tiers, + calculate exact decimal amounts per request, and freeze rates at dispatch. + Missing rates or required usage breakdowns stay unpriced; public prices do + not establish account access. The offline seed records its source snapshot + and keeps absent default wire IDs as dated compatibility fallbacks. Refs #6396. + - Desktop and HTTP app-server turns feed an existing shared pet companion when its followed session has no live producer. Automatic selection is checked atomically by the owner; a live TUI keeps its source. The Runtime producer @@ -444,12 +451,6 @@ launcher guidance. - Idle CPU has not been measured on FreeBSD or Linux for this release ([#6728](https://github.com/codewhale-hq/Codewhale/issues/6728)). -- Models.dev prices with context tiers are not applied. A turn at or above a - model's first context tier reads as unpriced ("pricing tier unavailable") - and is counted as missing from the cost total, instead of being priced at - its base rate. The bundled catalog has no tiered rows, so this appears only - after a live models.dev refresh - ([#6396](https://github.com/codewhale-hq/Codewhale/issues/6396)). - Shell commands run by delegated agents appear in `/jobs` and the work dock with their owner, not in the agent's own detail view; named terminal sessions live in the terminal dock diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 64e53f2dea..55f7aab60a 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -332,6 +332,7 @@ pub struct CodewhaleClient { /// they must not reconstruct output caps with `None` and discard a custom /// route's context window. route_limits: Option, + route_facts: Option>, /// Verified ChatGPT subject captured with Codewhale's own selected grant. pub(super) codex_account_id: Option, /// Opaque reasoning is bound to this verified grant identity, stable across @@ -668,6 +669,7 @@ impl Clone for CodewhaleClient { billing_mode: self.billing_mode, configured_models: Arc::clone(&self.configured_models), route_limits: self.route_limits, + route_facts: self.route_facts.clone(), codex_account_id: self.codex_account_id.clone(), chatgpt_reasoning_api: self.chatgpt_reasoning_api.clone(), wire_format: self.wire_format, @@ -1889,6 +1891,14 @@ impl CodewhaleClient { } .build()?; + let route_facts = crate::provider_lake::configured_catalog_offering_for_route( + config, + api_provider, + admitted_identity.key.as_str(), + &base_url, + &default_model, + ) + .map(Arc::new); let catalog_error_secret_values = Arc::new(catalog_error_secret_values( &api_key, &http_headers, @@ -1916,6 +1926,7 @@ impl CodewhaleClient { billing_mode, configured_models: Arc::new(config.custom_models.clone().unwrap_or_default()), route_limits, + route_facts, codex_account_id, chatgpt_reasoning_api, wire_format, @@ -2907,6 +2918,98 @@ impl CodewhaleClient { let (request, request_route_limits) = self.bind_request_to_protocol(self.prepare_model_bound_request(request))?; let mut request = clamp_output_cap(request, request_route_limits); + let facts = if request.model == self.default_model { + self.route_facts.clone() + } else { + crate::provider_lake::catalog_offering_for_route( + self.api_provider, + self.admitted_identity.key.as_str(), + &self.base_url, + &request.model, + ) + .map(Arc::new) + }; + if let Some(facts) = facts.as_deref() { + if facts.temperature == Some(false) { + request.temperature = None; + request.top_p = None; + } + if facts.tool_call == Some(false) + && request + .tools + .as_ref() + .is_some_and(|tools| !tools.is_empty()) + { + anyhow::bail!("The selected provider offering does not support tool calls"); + } + if codewhale_config::models_dev::image_input_support(facts.modalities.as_ref()) + == codewhale_config::route::CapabilityState::Unsupported + && request + .messages + .iter() + .flat_map(|message| &message.content) + .any(|block| matches!(block, ContentBlock::ImageUrl { .. })) + { + anyhow::bail!("The selected provider offering does not support image input"); + } + if facts.reasoning == Some(false) { + request.reasoning_effort = None; + request.thinking = None; + } else { + for option in &facts.reasoning_options { + if option.get("type").and_then(Value::as_str) != Some("effort") { + continue; + } + if request + .reasoning_effort + .as_deref() + .is_some_and(|effort| matches!(effort, "auto" | "default")) + { + request.reasoning_effort = option + .get("default") + .and_then(Value::as_str) + .map(str::to_string); + } + let wire_effort = if matches!(self.wire_format, WireFormat::ChatCompletions) { + request.reasoning_effort.as_deref().and_then(|effort| { + let provider = self.chat_shape_provider(&request.model); + let model = crate::config::wire_model_for_provider_route( + provider, + &self.base_url, + &request.model, + ); + let mut body = json!({ "model": model }); + chat::apply_route_reasoning_controls( + &mut body, + provider, + &self.base_url, + &model, + Some(effort), + ); + body.get("reasoning_effort") + .or_else(|| body.pointer("/reasoning/effort")) + .and_then(Value::as_str) + .map(str::to_string) + }) + } else { + request.reasoning_effort.clone() + }; + if let Some(effort) = wire_effort.as_deref() + && let Some(values) = option.get("values").and_then(Value::as_array) + && !values.iter().any(|value| value.as_str() == Some(effort)) + && !(matches!(effort, "off" | "disabled" | "false") + && (values.iter().any(|value| value.as_str() == Some("none")) + || facts.reasoning_options.iter().any(|option| { + option.get("type").and_then(Value::as_str) == Some("toggle") + }))) + { + anyhow::bail!( + "The selected provider offering does not support the configured reasoning effort; inspect model details and choose a published value" + ); + } + } + } + } let declared_wire_model = self.declared_wire_model(&request.model).map(str::to_string); if self.is_local_ds4_model(&request.model) && let Some(tools) = request.tools.as_mut() diff --git a/crates/tui/src/client/anthropic.rs b/crates/tui/src/client/anthropic.rs index 47ad27b168..efc93ad445 100644 --- a/crates/tui/src/client/anthropic.rs +++ b/crates/tui/src/client/anthropic.rs @@ -130,7 +130,12 @@ impl CodewhaleClient { // gateways (#4978, e.g. Sensenova) only accept the documented // enabled/disabled/auto thinking types, so non-native routes get the // portable `{"type":"enabled","budget_tokens":N}` shape instead. - let thinking_capable = codewhale_models::model_supports_reasoning(&model); + let thinking_capable = self + .route_facts + .as_ref() + .filter(|facts| facts.wire_model_id == request.model) + .and_then(|facts| facts.reasoning) + .unwrap_or_else(|| codewhale_models::model_supports_reasoning(&model)); let is_minimax_provider = self.api_provider == ProviderKind::MinimaxAnthropic; let is_minimax = crate::config::is_exact_minimax_anthropic_m3_route( self.api_provider, @@ -190,9 +195,36 @@ impl CodewhaleClient { body["thinking"] = json!({ "type": "adaptive" }); } _ if thinking_capable => { - if let Some(budget) = compat_thinking_budget(effort.as_deref(), request.max_tokens) + if let Some(mut budget) = + compat_thinking_budget(effort.as_deref(), request.max_tokens) { - body["thinking"] = json!({ "type": "enabled", "budget_tokens": budget }); + let mut minimum = u64::from(MIN_THINKING_BUDGET_TOKENS); + if let Some(facts) = self + .route_facts + .as_ref() + .filter(|facts| facts.wire_model_id == request.model) + { + if let Some(option) = facts.reasoning_options.iter().find(|option| { + matches!( + option.get("type").and_then(Value::as_str), + Some("budget" | "budget_tokens") + ) + }) { + minimum = option.get("min").and_then(Value::as_u64).unwrap_or(minimum); + let maximum = option + .get("max") + .and_then(Value::as_u64) + .unwrap_or(u64::from(request.max_tokens.saturating_sub(1))); + budget = budget.min(u32::try_from(maximum).unwrap_or(u32::MAX)); + } + } + if u64::from(budget) >= minimum { + body["thinking"] = json!({ "type": "enabled", "budget_tokens": budget }); + } else { + tracing::warn!( + "The output allowance cannot represent the offering's minimum reasoning budget; thinking was omitted" + ); + } } } _ => {} @@ -1007,6 +1039,11 @@ fn parse_anthropic_usage(usage: &Value) -> Usage { reasoning_tokens: None, reasoning_replay_tokens: None, server_tool_use: None, + + input_audio_tokens: None, + output_audio_tokens: None, + + request_usages: Vec::new(), } } diff --git a/crates/tui/src/client/responses.rs b/crates/tui/src/client/responses.rs index dc9e9af62d..3180c30b9f 100644 --- a/crates/tui/src/client/responses.rs +++ b/crates/tui/src/client/responses.rs @@ -1198,6 +1198,17 @@ fn parse_responses_usage(val: &Value) -> Usage { reasoning_tokens, reasoning_replay_tokens: None, server_tool_use: None, + + input_audio_tokens: val + .pointer("/input_tokens_details/audio_tokens") + .and_then(Value::as_u64) + .map(super::saturating_u32), + output_audio_tokens: val + .pointer("/output_tokens_details/audio_tokens") + .and_then(Value::as_u64) + .map(super::saturating_u32), + + request_usages: Vec::new(), } } diff --git a/crates/tui/src/client/wire.rs b/crates/tui/src/client/wire.rs index b85cdfe908..1db76c3495 100644 --- a/crates/tui/src/client/wire.rs +++ b/crates/tui/src/client/wire.rs @@ -111,6 +111,25 @@ pub(crate) fn parse_usage(usage: Option<&Value>) -> Usage { reasoning_tokens, reasoning_replay_tokens: None, server_tool_use, + + input_audio_tokens: usage + .and_then(|usage| { + usage + .pointer("/prompt_tokens_details/audio_tokens") + .or_else(|| usage.pointer("/input_tokens_details/audio_tokens")) + }) + .and_then(Value::as_u64) + .map(saturating_u32), + output_audio_tokens: usage + .and_then(|usage| { + usage + .pointer("/completion_tokens_details/audio_tokens") + .or_else(|| usage.pointer("/output_tokens_details/audio_tokens")) + }) + .and_then(Value::as_u64) + .map(saturating_u32), + + request_usages: Vec::new(), } } diff --git a/crates/tui/src/commands/contract/config_policy_baseline.rs b/crates/tui/src/commands/contract/config_policy_baseline.rs index 61caf9d68d..22457343d3 100644 --- a/crates/tui/src/commands/contract/config_policy_baseline.rs +++ b/crates/tui/src/commands/contract/config_policy_baseline.rs @@ -54,6 +54,14 @@ fn normalize(message: &str, temp: &TempDir) -> String { if let Some(home) = crate::config::effective_home_dir() { text = text.replace(home.to_str().unwrap(), ""); } + // Catalog freshness depends on the bundled snapshot's age against the + // wall clock, so it cannot be part of a stable baseline. + while let Some(start) = text.find(" · Catalog: ") { + let Some(end) = text[start..].find(" · Cloud facts:") else { + break; + }; + text.replace_range(start..start + end, ""); + } text } diff --git a/crates/tui/src/commands/contract/debug_diagnostics.rs b/crates/tui/src/commands/contract/debug_diagnostics.rs index d8aa06551b..2d809f9cc5 100644 --- a/crates/tui/src/commands/contract/debug_diagnostics.rs +++ b/crates/tui/src/commands/contract/debug_diagnostics.rs @@ -105,6 +105,11 @@ impl CommandDebugDiagnosticsContext for DebugDiagnosticsAdapter<'_> { reasoning_tokens: rec.reasoning_tokens, reasoning_replay_tokens: rec.reasoning_replay_tokens, server_tool_use: None, + + input_audio_tokens: None, + output_audio_tokens: None, + + request_usages: Vec::new(), }); DebugCacheTurn { provider: rec.provider.map(|provider| provider.as_str().to_string()), diff --git a/crates/tui/src/commands/contract/fixtures/config_policy/status.json b/crates/tui/src/commands/contract/fixtures/config_policy/status.json index 37b5f8d734..3835e88743 100644 --- a/crates/tui/src/commands/contract/fixtures/config_policy/status.json +++ b/crates/tui/src/commands/contract/fixtures/config_policy/status.json @@ -1,5 +1,5 @@ { - "message": "codewhale 0.10.2\n\n Route: deepseek · baseline-{provider}-model · reasoning max\n Directory: \n Project docs: AGENTS.md\n Mode: Work · approvals ask · shell off · workspace\n Safety: no OS sandbox on this platform (workspace-write requested, not enforced), network requested off, not enforced\n MCP: 0 configured\n\n Context window: 0.0% used (48 / 1000000 tokens)\n Window source: catalog · Catalog: models.dev stale · Cloud facts: off\n Window override: [providers.deepseek] context_window in config.toml\n Session: baseline-{model}-session · 0 cells · 0 API messages\n Session tokens: 0 in · 0 out · 1234 total · cache not reported\n Session cost: $0.0000\n Session metrics: 4 turns\n Tool outputs: no large outputs tracked\n\n Per-turn tokens: /tokens · Footer items: /statusline\n", + "message": "codewhale 0.10.2\n\n Route: deepseek · baseline-{provider}-model · reasoning max\n Directory: \n Project docs: AGENTS.md\n Mode: Work · approvals ask · shell off · workspace\n Safety: no OS sandbox on this platform (workspace-write requested, not enforced), network requested off, not enforced\n MCP: 0 configured\n\n Context window: 0.0% used (48 / 1000000 tokens)\n Window source: catalog · Cloud facts: off\n Window override: [providers.deepseek] context_window in config.toml\n Session: baseline-{model}-session · 0 cells · 0 API messages\n Session tokens: 0 in · 0 out · 1234 total · cache not reported\n Session cost: $0.0000\n Session metrics: 4 turns\n Tool outputs: no large outputs tracked\n\n Per-turn tokens: /tokens · Footer items: /statusline\n", "error": false, "action": "None" } diff --git a/crates/tui/src/commands/debug_diagnostics_host_tests.rs b/crates/tui/src/commands/debug_diagnostics_host_tests.rs index f2fb3a55f0..09566d85e5 100644 --- a/crates/tui/src/commands/debug_diagnostics_host_tests.rs +++ b/crates/tui/src/commands/debug_diagnostics_host_tests.rs @@ -1506,6 +1506,8 @@ mod cost_breakdown_tests { live_pricing_defect: None, usd_priced: true, cny_priced: estimate.cny > 0.0, + exact_usd: None, + exact_cny: None, } } diff --git a/crates/tui/src/config/tests.rs b/crates/tui/src/config/tests.rs index 3ce15b46b2..ab4d04af4d 100644 --- a/crates/tui/src/config/tests.rs +++ b/crates/tui/src/config/tests.rs @@ -16920,6 +16920,7 @@ mod released_provider_serde { currency: Currency::Usd, provenance: PricingProvenance::UserOverride, cloud_facts: None, + pricing_schedule: None, input_per_million: input, output_per_million: output, cache_read_per_million: None, diff --git a/crates/tui/src/core/engine/tests/test_cases_07.rs b/crates/tui/src/core/engine/tests/test_cases_07.rs index 23ee76aed4..bdcf411250 100644 --- a/crates/tui/src/core/engine/tests/test_cases_07.rs +++ b/crates/tui/src/core/engine/tests/test_cases_07.rs @@ -316,8 +316,10 @@ async fn terminal_diagnostics_merge_cumulative_usage_within_one_request() { let (status, error) = engine.run_turn(&mut turn, surface, None, None).await; assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}"); assert_eq!(mock.captured_requests().len(), 1); + let mut expected_usage = usage.clone(); + expected_usage.request_usages = vec![usage.clone()]; assert_eq!( - turn.usage, usage, + turn.usage, expected_usage, "repeated cumulative receipts are counted once" ); let mut events = handle.rx_event.write().await; @@ -1012,7 +1014,9 @@ async fn initial_routed_usage_is_total_only_emitted_once_and_keeps_parent_route_ drop(rx); assert_eq!(routed_events, vec![classifier_usage.clone()]); - assert_eq!(terminal_parent_usage, parent_usage); + let mut expected_parent_usage = parent_usage.clone(); + expected_parent_usage.request_usages = vec![parent_usage.clone()]; + assert_eq!(terminal_parent_usage, expected_parent_usage); assert_eq!(total_usage.input_tokens, 18); assert_eq!(total_usage.output_tokens, 8); assert_eq!(dropped_records, 2); diff --git a/crates/tui/src/core/session.rs b/crates/tui/src/core/session.rs index 9f409ad227..d870b9673d 100644 --- a/crates/tui/src/core/session.rs +++ b/crates/tui/src/core/session.rs @@ -413,6 +413,11 @@ mod tests { reasoning_tokens: None, reasoning_replay_tokens: None, server_tool_use: None, + + input_audio_tokens: None, + output_audio_tokens: None, + + request_usages: Vec::new(), }; usage.add(&api_usage); assert!(usage.cache_creation_input_tokens.is_none()); @@ -431,6 +436,11 @@ mod tests { reasoning_tokens: None, reasoning_replay_tokens: None, server_tool_use: None, + + input_audio_tokens: None, + output_audio_tokens: None, + + request_usages: Vec::new(), }; usage.add(&api_usage); assert_eq!(usage.cache_read_input_tokens, Some(30)); @@ -452,6 +462,11 @@ mod tests { reasoning_tokens: None, reasoning_replay_tokens: None, server_tool_use: None, + + input_audio_tokens: None, + output_audio_tokens: None, + + request_usages: Vec::new(), }; usage.add(&api_usage); // 0 is a valid observed value, must NOT be converted to None diff --git a/crates/tui/src/core/turn.rs b/crates/tui/src/core/turn.rs index 7fe841db54..1bf6d45e9c 100644 --- a/crates/tui/src/core/turn.rs +++ b/crates/tui/src/core/turn.rs @@ -244,6 +244,19 @@ impl TurnContext { } pub(crate) fn add_usage_to(total: &mut Usage, delta: &Usage) { + if total.request_usages.is_empty() && (total.input_tokens > 0 || total.output_tokens > 0) { + total.request_usages.push(total.clone()); + } + if total.request_usages.len() <= 1024 { + if delta.request_usages.is_empty() { + total.request_usages.push(delta.clone()); + } else { + let remaining = 1025 - total.request_usages.len(); + total + .request_usages + .extend(delta.request_usages.iter().take(remaining).cloned()); + } + } total.input_tokens = total.input_tokens.saturating_add(delta.input_tokens); total.output_tokens = total.output_tokens.saturating_add(delta.output_tokens); total.prompt_cache_hit_tokens = @@ -257,6 +270,10 @@ pub(crate) fn add_usage_to(total: &mut Usage, delta: &Usage) { delta.prompt_cache_write_tokens, ); total.reasoning_tokens = add_optional_usage(total.reasoning_tokens, delta.reasoning_tokens); + total.input_audio_tokens = + add_optional_usage(total.input_audio_tokens, delta.input_audio_tokens); + total.output_audio_tokens = + add_optional_usage(total.output_audio_tokens, delta.output_audio_tokens); total.reasoning_replay_tokens = add_optional_usage(total.reasoning_replay_tokens, delta.reasoning_replay_tokens); if let Some(delta) = delta.server_tool_use.as_ref() { diff --git a/crates/tui/src/cost_status.rs b/crates/tui/src/cost_status.rs index 91d58acb98..0d3bbf3701 100644 --- a/crates/tui/src/cost_status.rs +++ b/crates/tui/src/cost_status.rs @@ -912,6 +912,11 @@ pub fn child_usage_from_metadata(metadata: &serde_json::Value) -> Option .get("child_server_tool_use") .cloned() .and_then(|value| serde_json::from_value(value).ok()), + + input_audio_tokens: None, + output_audio_tokens: None, + + request_usages: Vec::new(), }) } diff --git a/crates/tui/src/hooks/executor.rs b/crates/tui/src/hooks/executor.rs index 2dd508fa61..903834d78e 100644 --- a/crates/tui/src/hooks/executor.rs +++ b/crates/tui/src/hooks/executor.rs @@ -3847,6 +3847,11 @@ NOEQUAL line dropped reasoning_tokens: Some(4), reasoning_replay_tokens: Some(2), server_tool_use: None, + + input_audio_tokens: None, + output_audio_tokens: None, + + request_usages: Vec::new(), }; let payload = super::turn_end_payload(TurnEndPayloadInput { @@ -4300,6 +4305,11 @@ printf '%s\n' '{{"text":"stdout is not a mutation contract"}}' reasoning_tokens: None, reasoning_replay_tokens: None, server_tool_use: None, + + input_audio_tokens: None, + output_audio_tokens: None, + + request_usages: Vec::new(), }; let context = submit_context(&dir).with_tokens(15); let payload = super::turn_end_payload(TurnEndPayloadInput { diff --git a/crates/tui/src/models_dev_live.rs b/crates/tui/src/models_dev_live.rs index e67d2a19fa..66b7973ffa 100644 --- a/crates/tui/src/models_dev_live.rs +++ b/crates/tui/src/models_dev_live.rs @@ -236,13 +236,15 @@ pub fn maybe_load_persisted_cache() { let Some(cache) = load_cache_file(&path) else { return; }; - // Live only when the cache is recent by a trustworthy clock *and* came - // from the source this process would fetch now. A cache written for - // another mirror or override file still publishes (offline startups keep - // rows) but as stale, so the next refresh replaces it. - let freshness = if within_ttl(cache.fetched_at, now_unix()) - && cache.source_fingerprint == current_source_fingerprint() - { + // The cache belongs to the selected source. Another mirror or override + // cannot supply fallback facts for this selection. + if cache.source_fingerprint != current_source_fingerprint() { + mark_failed(ModelsDevRefreshError::InvalidResponse( + "cached catalog belongs to a different source".into(), + )); + return; + } + let freshness = if within_ttl(cache.fetched_at, now_unix()) { ModelsDevFreshness::Live } else { ModelsDevFreshness::Stale @@ -344,7 +346,10 @@ pub fn spawn_background_refresh() { if env_truthy(ENV_DISABLE_FETCH) && std::env::var(ENV_MODELS_DEV_PATH).is_err() { return; } - tokio::spawn(async { + let Ok(handle) = tokio::runtime::Handle::try_current() else { + return; + }; + handle.spawn(async { match refresh(false).await { Ok(count) => { tracing::debug!( @@ -815,7 +820,7 @@ mod tests { let stale = PersistedModelsDevCache { schema_version: CACHE_SCHEMA_VERSION, fetched_at: 1, // far in the past → stale - source_fingerprint: "stale-fp".into(), + source_fingerprint: current_source_fingerprint(), source_label: "https://models.dev/catalog.json".into(), body: FIXTURE.into(), }; @@ -863,7 +868,7 @@ mod tests { }; save_cache_file(&cache, &foreign).expect("save"); maybe_load_persisted_cache(); - assert_eq!(status().freshness, ModelsDevFreshness::Stale); + assert_eq!(status().freshness, ModelsDevFreshness::Failed); // From the current source, but stamped far in the future. let future = PersistedModelsDevCache { diff --git a/crates/tui/src/pricing.rs b/crates/tui/src/pricing.rs index 05b77a4600..db4a479541 100644 --- a/crates/tui/src/pricing.rs +++ b/crates/tui/src/pricing.rs @@ -8,7 +8,7 @@ use chrono::{DateTime, Datelike, FixedOffset, TimeZone, Timelike, Utc, Weekday}; use codewhale_config::pricing::{ Currency, LIVE_PRICING_MAX_AGE_SECS, LivePricingDefect, OfferingPricing, PricingProvenance, - TokenClass, TokenUsage, + PricingQuoteError, TokenClass, TokenUsage, }; #[cfg(test)] @@ -875,6 +875,7 @@ fn is_grok_tiered(model: &str) -> bool { ) } +#[cfg(test)] fn pricing_for_model_and_usage(model: &str, usage: &Usage) -> Option { if is_minimax_m3(model) { return Some(minimax_m3_standard_pricing( @@ -1093,27 +1094,27 @@ pub fn calculate_turn_cost_estimate_from_usage(model: &str, usage: &Usage) -> Op /// Only cache-write can fail here: input, cache-read, and output rates are /// mandatory on every hand row, while a cache-creation rate exists only where a /// provider publishes one or documents that writes cost nothing extra. +#[cfg(test)] fn cost_estimate_with_pricing_checked( pricing: ModelPricing, usage: &Usage, ) -> Result> { let classes = token_usage_for_pricing(usage); - if classes.cache_write > 0 - && pricing - .usd - .cache_write - .rate(pricing.usd.input_cache_miss_per_million) - .is_none() - { - return Err(vec![TokenClass::CacheWrite]); + let usd = currency_offering_pricing(pricing.usd, Currency::Usd); + let missing = usd.unpriced_used_classes(&classes); + if !missing.is_empty() { + return Err(missing); } - Ok(CostEstimate { - usd: calculate_turn_cost_from_usage_with_pricing(pricing.usd, usage), - cny: pricing - .cny - .map(|pricing| calculate_turn_cost_from_usage_with_pricing(pricing, usage)) - .unwrap_or(0.0), - }) + let Some(usd) = usd.estimate_cost(&classes) else { + return Err(Vec::new()); + }; + let cny = match pricing.cny { + Some(pricing) => currency_offering_pricing(pricing, Currency::Cny) + .estimate_cost(&classes) + .ok_or_else(Vec::new)?, + None => 0.0, + }; + Ok(CostEstimate { usd, cny }) } /// Unchecked projection for the legacy model-only test helpers, which construct @@ -1124,13 +1125,8 @@ fn cost_estimate_with_pricing_checked( /// input rate. #[cfg(test)] fn cost_estimate_with_pricing(pricing: ModelPricing, usage: &Usage) -> CostEstimate { - CostEstimate { - usd: calculate_turn_cost_from_usage_with_pricing(pricing.usd, usage), - cny: pricing - .cny - .map(|pricing| calculate_turn_cost_from_usage_with_pricing(pricing, usage)) - .unwrap_or(0.0), - } + cost_estimate_with_pricing_checked(pricing, usage) + .expect("validated hand row and usage classes") } /// Calculate cost from provider/model usage when that pair identifies a single @@ -1254,6 +1250,7 @@ pub enum UnpricedReason { /// reported input total. Pricing that contradictory partition would /// over-count input, so the call is retained but fails closed. InconsistentUsage, + MissingUsageBreakdown, } impl UnpricedReason { @@ -1276,6 +1273,7 @@ impl UnpricedReason { "invalid_pricing_row" => Self::InvalidPricingRow, "unsupported_currency" | "currency_not_published" => Self::UnsupportedCurrency, "inconsistent_usage" => Self::InconsistentUsage, + "missing_usage_breakdown" => Self::MissingUsageBreakdown, "late_usage_ledger_unavailable" => Self::LateUsageUnavailable, "late_usage_ledger_overflow" => Self::LateUsageOverflow, _ => Self::UnrecordedCoverage, @@ -1303,6 +1301,7 @@ impl UnpricedReason { Self::InvalidPricingRow => MessageId::CostReasonInvalidRate, Self::UnsupportedCurrency => MessageId::CostReasonCurrencyMissing, Self::InconsistentUsage => MessageId::CostReasonUsageConflict, + Self::MissingUsageBreakdown => MessageId::CostReasonUsageBreakdown, } } @@ -1327,6 +1326,7 @@ impl UnpricedReason { Self::InvalidPricingRow => "invalid_pricing_row", Self::UnsupportedCurrency => "unsupported_currency", Self::InconsistentUsage => "inconsistent_usage", + Self::MissingUsageBreakdown => "missing_usage_breakdown", } } @@ -1370,6 +1370,8 @@ pub struct TurnCostAudit { /// be inferred from `estimate > 0`. pub usd_priced: bool, pub cny_priced: bool, + pub exact_usd: Option, + pub exact_cny: Option, } impl TurnCostAudit { @@ -1387,6 +1389,8 @@ impl TurnCostAudit { live_pricing_defect: None, usd_priced, cny_priced, + exact_usd: None, + exact_cny: None, } } @@ -1399,6 +1403,8 @@ impl TurnCostAudit { live_pricing_defect: None, usd_priced: false, cny_priced: false, + exact_usd: None, + exact_cny: None, } } @@ -1411,6 +1417,8 @@ impl TurnCostAudit { live_pricing_defect: None, usd_priced: false, cny_priced: false, + exact_usd: None, + exact_cny: None, } } @@ -1425,6 +1433,8 @@ impl TurnCostAudit { live_pricing_defect: Some(defect), usd_priced: false, cny_priced: false, + exact_usd: None, + exact_cny: None, } } @@ -1565,41 +1575,27 @@ fn audit_turn_cost_for_provider_on_endpoint_for_identity_at( return TurnCostAudit::unpriced(UnpricedReason::UnrepresentedTier); } - // MiniMax-M3 doubles its published rates above 512K total input. The - // catalog row is necessarily static, so retain the usage-aware first-party - // table for both direct wire protocols after provider/model provenance has - // been canonicalized. + // Direct DeepSeek pricing carries an authoritative CNY row and recorded-time + // peak/off-peak tiers that a static catalog row cannot represent. These + // exact first-party routes intentionally override the catalog; no other + // provider/model text match is allowed to do so. if matches!( provider, ProviderKind::Minimax | ProviderKind::MinimaxAnthropic ) && catalog_model.eq_ignore_ascii_case("minimax-m3") { - return hand_priced_audit(pricing_for_model_and_usage(&catalog_model, usage), usage); + return hand_provider_audit(provider, &catalog_model, usage, recorded_at); } - // xAI doubles Grok 4.6 / 4.5 / 4.3 input, cached-input, and output rates - // once the prompt reaches 200K tokens. Keep this provider-owned and - // usage-aware so a third-party route reusing the model slug never - // inherits xAI billing. if provider == ProviderKind::Xai && is_grok_tiered(&catalog_model) { - return hand_priced_audit(pricing_for_model_and_usage(&catalog_model, usage), usage); + return hand_provider_audit(provider, &catalog_model, usage, recorded_at); } - // Direct DeepSeek pricing carries an authoritative CNY row and recorded-time - // peak/off-peak tiers that a static catalog row cannot represent; Sonnet 5 - // keeps riding the same recorded-time hand row (its rate is flat again - // since Anthropic cancelled the 2026-09-01 increase, but the contract that - // first-party Anthropic prices Sonnet 5 from its own row stays). These - // exact first-party routes intentionally override the catalog; no other - // provider/model text match is allowed to do so. if direct_deepseek || (provider == ProviderKind::Anthropic && catalog_model.eq_ignore_ascii_case("claude-sonnet-5")) { - return hand_priced_audit( - provider_owned_hand_pricing_at(provider, &catalog_model, recorded_at), - usage, - ); + return hand_provider_audit(provider, &catalog_model, usage, recorded_at); } if let Some(pricing) = frozen_cloud_pricing { @@ -1645,31 +1641,7 @@ fn audit_turn_cost_for_provider_on_endpoint_for_identity_at( && let Some(pricing) = effective_offering_pricing(provider, &catalog_model, offering, &classes) { - if let Some(estimate) = - catalog_cost_estimate_for_route(provider, &catalog_model, offering, usage) - { - let (usd_priced, cny_priced) = match pricing.currency { - Currency::Usd => (true, false), - Currency::Cny => (false, true), - Currency::Other(_) => (false, false), - }; - return TurnCostAudit::priced( - estimate, - pricing.provenance.clone(), - usd_priced, - cny_priced, - ) - .with_live_defect(live_defect); - } - let classes = pricing.unpriced_used_classes(&classes); - if classes.is_empty() { - // Every used class is priced, so the only way the estimate failed - // is a currency CodeWhale does not carry. Never convert. - return TurnCostAudit::unpriced(UnpricedReason::UnsupportedCurrency) - .with_live_defect(live_defect); - } - return TurnCostAudit::missing_classes(pricing.provenance, classes) - .with_live_defect(live_defect); + return audit_offering_pricing(pricing, usage).with_live_defect(live_defect); } // A few first-party rows predate or intentionally omit a Models.dev entry @@ -1707,10 +1679,12 @@ fn refused_catalog_pricing_audit( if !codewhale_config::pricing::catalog_cost_is_valid(cost) { return Some(TurnCostAudit::unpriced(UnpricedReason::InvalidPricingRow)); } - cost.context_tiers() - .iter() - .any(|tier| tier.tier.kind != "context" || u64::from(usage.input_tokens) >= tier.tier.size) - .then(|| TurnCostAudit::unpriced(UnpricedReason::UnrepresentedTier)) + let pricing = OfferingPricing::from_catalog_offering(offering)?; + matches!( + pricing.quote_cost(&token_usage_for_pricing(usage)), + Err(PricingQuoteError::UnsupportedTier) + ) + .then(|| TurnCostAudit::unpriced(UnpricedReason::UnrepresentedTier)) } /// Outcome of checking a catalog row's pricing provenance against the route. @@ -1815,11 +1789,14 @@ fn verified_catalog_offering( // `recorded_at` is the turn's own clock, which is the right reference for // "was this price current when the turn happened". let now_unix = u64::try_from(recorded_at.timestamp()).ok(); - if pricing.provenance == PricingProvenance::ProviderLive - && pricing - .effective_at - .zip(now_unix) - .is_some_and(|(effective_at, dispatched_at)| effective_at > dispatched_at) + if matches!( + pricing.provenance, + PricingProvenance::ProviderLive | PricingProvenance::ModelsDevLive + ) && pricing + .fetched_at + .or(pricing.effective_at) + .zip(now_unix) + .is_some_and(|(effective_at, dispatched_at)| effective_at > dispatched_at) { return VerifiedOffering::FutureEffective; } @@ -1849,13 +1826,123 @@ fn hand_priced_audit(pricing: Option, usage: &Usage) -> TurnCostAu let Some(pricing) = pricing else { return TurnCostAudit::unpriced(UnpricedReason::NoPricingRow); }; - let has_cny = pricing.cny.is_some(); - match cost_estimate_with_pricing_checked(pricing, usage) { - Ok(estimate) => { - TurnCostAudit::priced(estimate, PricingProvenance::ProviderDocs, true, has_cny) + audit_pricing_pair( + currency_offering_pricing(pricing.usd, Currency::Usd), + pricing + .cny + .map(|row| currency_offering_pricing(row, Currency::Cny)), + usage, + ) +} + +fn audit_pricing_pair( + primary: OfferingPricing, + secondary: Option, + usage: &Usage, +) -> TurnCostAudit { + let mut audit = audit_offering_pricing(primary, usage); + if audit.estimate.is_none() { + return audit; + } + if let Some(secondary) = secondary { + let secondary = audit_offering_pricing(secondary, usage); + let Some(cost) = secondary.estimate else { + return secondary; + }; + audit.estimate.as_mut().expect("priced primary").cny = cost.cny; + audit.cny_priced = secondary.cny_priced; + audit.exact_cny = secondary.exact_cny; + } + audit +} + +pub(crate) fn provider_owned_hand_quote_rows( + provider: ProviderKind, + model: &str, + recorded_at: DateTime, +) -> Option<(OfferingPricing, Option)> { + let pricing = provider_owned_hand_pricing_at(provider, model, recorded_at)?; + let mut primary = currency_offering_pricing(pricing.usd, Currency::Usd); + let mut secondary = pricing + .cny + .map(|row| currency_offering_pricing(row, Currency::Cny)); + let tier = if provider == ProviderKind::Xai && is_grok_tiered(model) { + Some(( + u64::from(GROK_4_6_LONG_CONTEXT_THRESHOLD), + true, + grok_tiered_pricing(&model.to_ascii_lowercase(), true)?, + )) + } else if matches!( + provider, + ProviderKind::Minimax | ProviderKind::MinimaxAnthropic + ) && is_minimax_m3(model) + { + Some(( + u64::from(MINIMAX_M3_LONG_CONTEXT_THRESHOLD), + false, + minimax_m3_standard_pricing(true), + )) + } else { + None + }; + if let Some((size, inclusive, higher)) = tier { + primary + .context_tiers + .push(codewhale_config::models_dev::ModelsDevCostTier { + tier: codewhale_config::models_dev::ModelsDevContextTier { + kind: "context".into(), + size, + }, + cost: currency_offering_pricing(higher.usd, Currency::Usd).cost_structure(), + }); + primary.context_tiers_inclusive = inclusive; + if let Some(row) = secondary.as_mut() + && let Some(higher) = higher.cny + { + row.context_tiers + .push(codewhale_config::models_dev::ModelsDevCostTier { + tier: codewhale_config::models_dev::ModelsDevContextTier { + kind: "context".into(), + size, + }, + cost: currency_offering_pricing(higher, Currency::Cny).cost_structure(), + }); + row.context_tiers_inclusive = inclusive; } - Err(classes) => TurnCostAudit::missing_classes(PricingProvenance::ProviderDocs, classes), } + for row in std::iter::once(&mut primary).chain(secondary.iter_mut()) { + row.provider = provider.as_str().into(); + row.wire_model_id = model.into(); + row.source_ref = Some( + match provider { + ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic => { + "https://api-docs.deepseek.com/quick_start/pricing" + } + ProviderKind::Xai => "https://docs.x.ai/docs/models", + ProviderKind::Minimax | ProviderKind::MinimaxAnthropic => { + "https://platform.minimax.io/docs/guides/pricing-paygo" + } + ProviderKind::Anthropic => { + "https://platform.claude.com/docs/en/about-claude/pricing" + } + _ => "codewhale:reviewed-provider-price", + } + .into(), + ); + } + Some((primary, secondary)) +} + +fn hand_provider_audit( + provider: ProviderKind, + model: &str, + usage: &Usage, + at: DateTime, +) -> TurnCostAudit { + provider_owned_hand_quote_rows(provider, model, at).map_or_else( + || TurnCostAudit::unpriced(UnpricedReason::NoPricingRow), + |(primary, secondary)| audit_pricing_pair(primary, secondary, usage), + ) } /// Recorded-time variant with explicit billing-surface provenance. @@ -1925,21 +2012,34 @@ pub(crate) fn audit_turn_cost_for_route_on_endpoint_for_identity_at( usage: &Usage, recorded_at: DateTime, ) -> TurnCostAudit { - let declared_pricing = match provider_live_pricing { - Some(quote) if quote.provenance == PricingProvenance::UserOverride => { - let pricing = provider_identity + if matches!( + endpoint_metering_for_billing_surface(billing_surface), + EndpointMetering::ExactSubscription | EndpointMetering::LocalNoBill + ) { + return TurnCostAudit::unpriced(UnpricedReason::NotMoneyMetered); + } + + let frozen_pricing = match provider_live_pricing { + Some(quote) => { + let binding = provider_identity .zip(endpoint_fingerprint) - .zip(u64::try_from(recorded_at.timestamp()).ok()) - .and_then(|((identity, fingerprint), at)| { - quote.pricing_for_route(provider, identity, model, fingerprint, at) - }); - let Some(pricing) = pricing else { + .zip(u64::try_from(recorded_at.timestamp()).ok()); + let Some(((identity, fingerprint), at)) = binding else { + return TurnCostAudit::unpriced(UnpricedReason::UnverifiedLivePricing); + }; + let Some(pricing) = quote.pricing_for_route(provider, identity, model, fingerprint, at) + else { return TurnCostAudit::unpriced(UnpricedReason::UnverifiedLivePricing); }; - Some(pricing) + let secondary = + quote.secondary_pricing_for_route(provider, identity, model, fingerprint, at); + Some((pricing, secondary)) } - _ => None, + None => None, }; + let declared = frozen_pricing + .as_ref() + .is_some_and(|(pricing, _)| pricing.provenance == PricingProvenance::UserOverride); let reviewed_custom_metered = reviewed_custom_route_is_metered(provider, endpoint_fingerprint); let reviewed_provider_live = reviewed_provider_live_route_is_metered(provider, provider_identity, endpoint_fingerprint); @@ -1951,9 +2051,7 @@ pub(crate) fn audit_turn_cost_for_route_on_endpoint_for_identity_at( return TurnCostAudit::unpriced(UnpricedReason::NotMoneyMetered); } EndpointMetering::Unknown - if billing_surface.is_some() - && !reviewed_custom_metered - && declared_pricing.is_none() => + if billing_surface.is_some() && !reviewed_custom_metered && !declared => { return TurnCostAudit::unpriced(UnpricedReason::UnknownBillingBasis); } @@ -1962,21 +2060,14 @@ pub(crate) fn audit_turn_cost_for_route_on_endpoint_for_identity_at( if !usage_cache_partition_is_consistent(usage) { return TurnCostAudit::unpriced(UnpricedReason::InconsistentUsage); } - if let Some(pricing) = declared_pricing { - return audit_offering_pricing(pricing, usage); + if declared && let Some((pricing, secondary)) = frozen_pricing.clone() { + return audit_pricing_pair(pricing, secondary, usage); } if provider == ProviderKind::Stepfun { return match pricing_for_billing_surface(provider, model, billing_surface) { // Each model keeps its documented cache-write policy; unpublished // write rates fail closed instead of borrowing another model's rate. - Some(pricing) => match cost_estimate_with_pricing_checked(pricing, usage) { - Ok(estimate) => { - TurnCostAudit::priced(estimate, PricingProvenance::ProviderDocs, true, false) - } - Err(classes) => { - TurnCostAudit::missing_classes(PricingProvenance::ProviderDocs, classes) - } - }, + Some(pricing) => hand_priced_audit(Some(pricing), usage), // The surface classified as per-token but no rates exist for it, or // no surface was established at all. None => TurnCostAudit::unpriced(match billing_surface { @@ -2001,45 +2092,17 @@ pub(crate) fn audit_turn_cost_for_route_on_endpoint_for_identity_at( if billing_surface.is_none() { return TurnCostAudit::unpriced(UnpricedReason::UnestablishedEndpoint); } + if let Some((pricing, secondary)) = frozen_pricing { + return audit_pricing_pair(pricing, secondary, usage); + } if reviewed_provider_live { - let Some(provider_identity) = provider_identity.map(str::trim).filter(|id| !id.is_empty()) - else { - return TurnCostAudit::unpriced(UnpricedReason::UnknownBillingBasis); - }; - let Some(endpoint_fingerprint) = endpoint_fingerprint else { - return TurnCostAudit::unpriced(UnpricedReason::UnknownBillingBasis); - }; - let Some(dispatched_at_unix) = u64::try_from(recorded_at.timestamp()).ok() else { - return TurnCostAudit::unpriced(UnpricedReason::UnverifiedLivePricing); - }; - let pricing = match provider_live_pricing { - Some(quote) => { - let Some(pricing) = quote.pricing_for_route( - provider, - provider_identity, - model, - endpoint_fingerprint, - dispatched_at_unix, - ) else { - return TurnCostAudit::unpriced(UnpricedReason::UnverifiedLivePricing); - }; - pricing - } - None if provider == ProviderKind::Openrouter => { - // An offline/startup OpenRouter dispatch has no mutable live - // quote to freeze. Audit it only against the immutable bundled - // snapshot (and provider-owned hand rows, if one is added), so - // a refresh that lands after dispatch cannot retro-price it. - return audit_openrouter_immutable_pricing(model, usage, recorded_at); - } - None => { - // Baseten has no reviewed immutable price card. Its compatible - // custom route therefore requires the exact frozen live quote. - return TurnCostAudit::unpriced(UnpricedReason::UnverifiedLivePricing); - } + return if provider == ProviderKind::Openrouter { + audit_openrouter_immutable_pricing(model, usage, recorded_at) + } else { + TurnCostAudit::unpriced(UnpricedReason::UnverifiedLivePricing) }; - return audit_offering_pricing(pricing, usage); } + if provider == ProviderKind::Openrouter && provider_identity.is_some() { // A persisted built-in OpenRouter receipt that is missing the exact // official identity/endpoint binding (or its frozen quote) must not @@ -2049,21 +2112,6 @@ pub(crate) fn audit_turn_cost_for_route_on_endpoint_for_identity_at( if provider == ProviderKind::Custom { return TurnCostAudit::unpriced(UnpricedReason::UnknownBillingBasis); } - let frozen_cloud_pricing = match provider_live_pricing { - Some(quote) if quote.provenance == PricingProvenance::CloudFacts => { - let pricing = provider_identity - .zip(endpoint_fingerprint) - .zip(u64::try_from(recorded_at.timestamp()).ok()) - .and_then(|((identity, fingerprint), dispatched)| { - quote.pricing_for_route(provider, identity, model, fingerprint, dispatched) - }); - let Some(pricing) = pricing else { - return TurnCostAudit::unpriced(UnpricedReason::UnverifiedLivePricing); - }; - Some(pricing) - } - _ => None, - }; audit_turn_cost_for_provider_on_endpoint_for_identity_at( provider, provider_identity, @@ -2072,20 +2120,41 @@ pub(crate) fn audit_turn_cost_for_route_on_endpoint_for_identity_at( usage, recorded_at, false, - frozen_cloud_pricing, + None, ) } fn audit_offering_pricing(pricing: OfferingPricing, usage: &Usage) -> TurnCostAudit { - let classes = token_usage_for_pricing(usage); - let unpriced = pricing.unpriced_used_classes(&classes); - if !unpriced.is_empty() { - return TurnCostAudit::missing_classes(pricing.provenance, unpriced); + if pricing.rate_withheld { + return TurnCostAudit::unpriced(UnpricedReason::NoPricingRow); } - let Some(amount) = pricing.estimate_cost(&classes) else { + let classes = token_usage_for_pricing(usage); + let quote = match pricing.quote_cost(&classes) { + Ok(quote) => quote, + Err(PricingQuoteError::MissingRate(class)) => { + let mut missing = pricing.unpriced_used_classes(&classes); + if !missing.contains(&class) { + missing.push(class); + } + return TurnCostAudit::missing_classes(pricing.provenance, missing); + } + Err(PricingQuoteError::MissingUsage(_)) => { + return TurnCostAudit::unpriced(UnpricedReason::MissingUsageBreakdown); + } + Err(PricingQuoteError::UnsupportedTier) => { + return TurnCostAudit::unpriced(UnpricedReason::UnrepresentedTier); + } + Err(PricingQuoteError::InvalidUsage) => { + return TurnCostAudit::unpriced(UnpricedReason::InconsistentUsage); + } + Err(PricingQuoteError::InvalidRate) => { + return TurnCostAudit::unpriced(UnpricedReason::InvalidPricingRow); + } + }; + let Ok(amount) = quote.amount.parse::() else { return TurnCostAudit::unpriced(UnpricedReason::InvalidPricingRow); }; - let (estimate, usd, cny) = match pricing.currency { + let (estimate, usd, cny) = match quote.currency { Currency::Usd => (CostEstimate::usd_only(amount), true, false), Currency::Cny => ( CostEstimate { @@ -2097,7 +2166,13 @@ fn audit_offering_pricing(pricing: OfferingPricing, usage: &Usage) -> TurnCostAu ), Currency::Other(_) => return TurnCostAudit::unpriced(UnpricedReason::UnsupportedCurrency), }; - TurnCostAudit::priced(estimate, pricing.provenance, usd, cny) + let mut audit = TurnCostAudit::priced(estimate, pricing.provenance, usd, cny); + if usd { + audit.exact_usd = Some(quote.amount); + } else { + audit.exact_cny = Some(quote.amount); + } + audit } /// Price an exact official OpenRouter route without consulting mutable live @@ -2112,7 +2187,6 @@ fn audit_openrouter_immutable_pricing( else { return TurnCostAudit::unpriced(UnpricedReason::NoPricingRow); }; - let classes = token_usage_for_pricing(usage); if let Some(offering) = crate::provider_lake::bundled_catalog_offering_for_model( ProviderKind::Openrouter, &canonical_model, @@ -2120,32 +2194,8 @@ fn audit_openrouter_immutable_pricing( if let Some(audit) = refused_catalog_pricing_audit(&offering, usage) { return audit; } - if let Some(pricing) = effective_offering_pricing( - ProviderKind::Openrouter, - &canonical_model, - &offering, - &classes, - ) { - let unpriced_classes = pricing.unpriced_used_classes(&classes); - if !unpriced_classes.is_empty() { - return TurnCostAudit::missing_classes(pricing.provenance, unpriced_classes); - } - let Some(estimate) = catalog_cost_estimate_for_route( - ProviderKind::Openrouter, - &canonical_model, - &offering, - usage, - ) else { - return TurnCostAudit::unpriced(UnpricedReason::UnsupportedCurrency); - }; - let (usd_priced, cny_priced) = match pricing.currency { - Currency::Usd => (true, false), - Currency::Cny => (false, true), - Currency::Other(_) => { - return TurnCostAudit::unpriced(UnpricedReason::UnsupportedCurrency); - } - }; - return TurnCostAudit::priced(estimate, pricing.provenance, usd_priced, cny_priced); + if let Some(pricing) = OfferingPricing::from_catalog_offering(&offering) { + return audit_offering_pricing(pricing, usage); } } @@ -2478,6 +2528,14 @@ fn effective_offering_pricing( if classes.cache_write > 0 && pricing.cache_write_per_million.is_none() { pricing.cache_write_per_million = pricing.input_per_million; } + for tier in &mut pricing.context_tiers { + if classes.cache_read > 0 && tier.cost.cache_read.is_none() { + tier.cost.cache_read = tier.cost.input; + } + if classes.cache_write > 0 && tier.cost.cache_write.is_none() { + tier.cost.cache_write = tier.cost.input; + } + } } Some(pricing) } @@ -2485,6 +2543,7 @@ fn effective_offering_pricing( /// Estimate usage only from the exact provider offering. Missing prices for a /// used token class fail closed, except on the two documented first-party /// routes where cache tokens are explicitly billed at the input rate. +#[cfg(test)] fn catalog_cost_estimate_for_route( provider: ProviderKind, model: &str, @@ -2519,10 +2578,29 @@ fn catalog_cost_estimate_for_route( /// `reasoning_tokens` under `output_tokens_details` while `output_tokens` is /// the total, and Chat Completions nests it under `completion_tokens_details` /// while `completion_tokens` is the total. Adding it charged reasoning turns -/// twice for the same tokens (up to 2x on reasoning-heavy turns). It stays on -/// `Usage` as informational telemetry (`/usage`, hooks, sub-agent metadata). +/// twice for the same tokens (up to 2x on reasoning-heavy turns). It uses +/// its own class when a rate is published, replacing that subset of output. #[must_use] pub fn token_usage_for_pricing(usage: &Usage) -> TokenUsage { + let mut classes = request_token_usage_for_pricing(usage); + classes.request_usages = usage + .request_usages + .iter() + .take(1025) + .map(|request| { + let mut classes = request_token_usage_for_pricing(request); + if !request.request_usages.is_empty() { + classes + .request_usages + .push(request_token_usage_for_pricing(request)); + } + classes + }) + .collect(); + classes +} + +fn request_token_usage_for_pricing(usage: &Usage) -> TokenUsage { // `input_tokens` is the authoritative total. Even malformed provider // telemetry must never produce token classes whose sum exceeds it. The // audit path rejects contradictory partitions; this bounded projection @@ -2541,12 +2619,31 @@ pub fn token_usage_for_pricing(usage: &Usage) -> TokenUsage { let input = non_cached_reported.saturating_add(uncategorized_input); // Reasoning tokens are already inside `output_tokens`; see the doc comment. let output = usage.output_tokens; + // Subset telemetry is clamped like the cache partition above: malformed + // provider counts that exceed their parent total must never fail the + // whole estimate closed or double-count. + let reasoning = usage + .reasoning_tokens + .map(|tokens| u64::from(tokens.min(output))); + let output_audio = usage.output_audio_tokens.map(|tokens| { + let remaining = u64::from(output).saturating_sub(reasoning.unwrap_or(0)); + u64::from(tokens).min(remaining) + }); + let input_audio = usage + .input_audio_tokens + .map(|tokens| u64::from(tokens).min(u64::from(input))); TokenUsage { input: u64::from(input), output: u64::from(output), cache_read: u64::from(cache_read), cache_write: u64::from(cache_write), + + reasoning, + input_audio, + output_audio, + + request_usages: Vec::new(), } } @@ -2557,20 +2654,18 @@ fn usage_cache_partition_is_consistent(usage: &Usage) -> bool { reported <= u64::from(usage.input_tokens) } -fn calculate_turn_cost_from_usage_with_pricing(pricing: CurrencyPricing, usage: &Usage) -> f64 { - let usage = token_usage_for_pricing(usage); - let hit_cost = (usage.cache_read as f64 / 1_000_000.0) * pricing.input_cache_hit_per_million; - let miss_cost = (usage.input as f64 / 1_000_000.0) * pricing.input_cache_miss_per_million; - // An unpublished write policy is only reachable here for usage with zero - // cache-write tokens; `cost_estimate_with_pricing_checked` rejects the rest - // before any money is computed. - let write_rate = pricing - .cache_write - .rate(pricing.input_cache_miss_per_million) - .unwrap_or(0.0); - let write_cost = (usage.cache_write as f64 / 1_000_000.0) * write_rate; - let output_cost = (usage.output as f64 / 1_000_000.0) * pricing.output_per_million; - hit_cost + miss_cost + write_cost + output_cost +fn currency_offering_pricing(pricing: CurrencyPricing, currency: Currency) -> OfferingPricing { + OfferingPricing { + currency, + input_per_million: Some(pricing.input_cache_miss_per_million), + output_per_million: Some(pricing.output_per_million), + cache_read_per_million: Some(pricing.input_cache_hit_per_million), + cache_write_per_million: pricing + .cache_write + .rate(pricing.input_cache_miss_per_million), + provenance: PricingProvenance::ProviderDocs, + ..Default::default() + } } /// Estimate how much money was saved by serving `cache_hit_tokens` from the @@ -4118,12 +4213,21 @@ mod tests { #[test] fn recorded_time_provider_cost_keeps_catalog_cache_write_tier() { + let request = Usage { + input_tokens: 100_000, + output_tokens: 0, + prompt_cache_hit_tokens: Some(0), + prompt_cache_miss_tokens: Some(0), + prompt_cache_write_tokens: Some(100_000), + ..Default::default() + }; let usage = Usage { input_tokens: 1_000_000, output_tokens: 0, prompt_cache_hit_tokens: Some(0), prompt_cache_miss_tokens: Some(0), prompt_cache_write_tokens: Some(1_000_000), + request_usages: vec![request; 10], ..Default::default() }; @@ -4391,6 +4495,12 @@ mod tests { output: 100, cache_read: 250, cache_write: 50, + + reasoning: Some(50), + input_audio: None, + output_audio: None, + + request_usages: Vec::new(), } ); @@ -4526,6 +4636,12 @@ mod tests { output: 100, cache_read: 250, cache_write: 0, + + reasoning: None, + input_audio: None, + output_audio: None, + + request_usages: Vec::new(), } ); } @@ -5112,12 +5228,25 @@ mod tests { } fn official_route_audit(provider: ProviderKind, model: &str, usage: &Usage) -> TurnCostAudit { - audit_turn_cost_for_route_at( + let base = provider.provider().default_base_url(); + let at = Utc::now(); + let fingerprint = codewhale_config::catalog::base_url_fingerprint(base); + let quote = crate::provider_catalog_live::fresh_dispatch_pricing_quote_at( provider, + provider.as_str(), model, - billing_surface_for_route(provider, Some(provider.provider().default_base_url())), + base, + at.timestamp() as u64, + ); + audit_turn_cost_for_route_on_endpoint_for_identity_at( + provider, + Some(provider.as_str()), + model, + billing_surface_for_route(provider, Some(base)), + Some(&fingerprint), + quote.as_ref(), usage, - Utc::now(), + at, ) } @@ -5253,7 +5382,7 @@ mod tests { audit.unpriced_reason, Some(UnpricedReason::UnverifiedLivePricing) ); - assert_eq!(audit.provenance, Some(PricingProvenance::ModelsDevBundled)); + assert_eq!(audit.provenance, Some(PricingProvenance::ModelsDevLive)); assert_eq!(audit.live_pricing_defect, None); let estimate = audit.estimate.expect("priced"); assert!( @@ -5293,7 +5422,16 @@ mod tests { ); let usage = million_input_usage(); - let audit = official_route_audit(ProviderKind::OpencodeZen, "deepseek-v4-flash", &usage); + let fingerprint = codewhale_config::catalog::base_url_fingerprint( + ProviderKind::OpencodeZen.provider().default_base_url(), + ); + let audit = audit_turn_cost_for_provider_on_endpoint_at( + ProviderKind::OpencodeZen, + "deepseek-v4-flash", + Some(&fingerprint), + &usage, + now, + ); crate::provider_lake::clear_live_snapshot(); assert!(audit.is_priced(), "{audit:?}"); @@ -5339,16 +5477,10 @@ mod tests { ); let usage = million_input_usage(); - let audit = audit_turn_cost_for_route_on_endpoint_at( + let audit = official_route_audit( ProviderKind::Fireworks, "accounts/fireworks/models/kimi-k3", - billing_surface_for_route( - ProviderKind::Fireworks, - Some(crate::config::DEFAULT_FIREWORKS_BASE_URL), - ), - Some(&fingerprint), &usage, - now, ); crate::provider_lake::clear_live_snapshot(); @@ -5397,13 +5529,9 @@ mod tests { crate::provider_lake::LiveSource::PerProvider, ); - let audit = audit_turn_cost_for_route_on_endpoint_at( + let audit = audit_turn_cost_for_provider_on_endpoint_at( ProviderKind::Fireworks, "accounts/fireworks/models/future-price-only", - billing_surface_for_route( - ProviderKind::Fireworks, - Some(crate::config::DEFAULT_FIREWORKS_BASE_URL), - ), Some(&fingerprint), &million_input_usage(), dispatched_at, @@ -5466,7 +5594,7 @@ mod tests { crate::provider_lake::clear_live_snapshot(); assert!(audit.is_priced(), "{audit:?}"); - assert_eq!(audit.provenance, Some(PricingProvenance::ModelsDevBundled)); + assert_eq!(audit.provenance, Some(PricingProvenance::ModelsDevLive)); assert_eq!(audit.live_pricing_defect, None); let estimate = audit.estimate.expect("priced"); assert!( diff --git a/crates/tui/src/pricing/default_coverage_unpriced.json b/crates/tui/src/pricing/default_coverage_unpriced.json index 987494d634..0e09cf3770 100644 --- a/crates/tui/src/pricing/default_coverage_unpriced.json +++ b/crates/tui/src/pricing/default_coverage_unpriced.json @@ -1,7 +1,7 @@ [ { "reason": "no_pricing_row", - "review": "Source reviewed at 36f11b82; fixture recorded time is 2026-09-08T12:00Z with 1000 input/100 output and no cache usage or live/signed/override price state. Exact default tuples below have no usable provider-owned bundled/hand row (pricing.rs:1639,1674,2282; provider_lake.rs:993). OpenRouter official identity with no frozen quote uses immutable bundled fallback (pricing.rs:2011,2089); its DeepSeek Pro row is costless. Zen resolves gpt-5.6 to wire gpt-5.6-sol (route/offering.rs:312), while its hand fallback prices only DeepSeek Flash (pricing.rs:2426). Wanjie is not the direct-DeepSeek retired-alias/hand-price owner. No numeric rates or model-owner rates are inferred. ZenMux review: metered OpenAI-compatible aggregator; its deepseek/deepseek-v4.1-flash default has no bundled/hand pricing row and resolves costless until the live catalog prices it. ModelScope review (ba62522a20 omitted this row): same shape — metered default with no bundled/hand row in pricing.rs.", + "review": "Source reviewed at 36f11b82; fixture recorded time is 2026-09-08T12:00Z with 1000 input/100 output and no cache usage or live/signed/override price state. Exact default tuples below have no usable provider-owned bundled/hand row (pricing.rs:1639,1674,2282; provider_lake.rs:993). OpenRouter and Together defaults retired from this group: the refreshed bundled seed now prices deepseek/deepseek-v4-pro and deepseek-ai/DeepSeek-V4-Pro. Zen resolves gpt-5.6 to wire gpt-5.6-sol (route/offering.rs:312), while its hand fallback prices only DeepSeek Flash (pricing.rs:2426). Wanjie is not the direct-DeepSeek retired-alias/hand-price owner. No numeric rates or model-owner rates are inferred. ZenMux review: metered OpenAI-compatible aggregator; its deepseek/deepseek-v4.1-flash default has no bundled/hand pricing row and resolves costless until the live catalog prices it. ModelScope review (ba62522a20 omitted this row): same shape — metered default with no bundled/hand row in pricing.rs.", "routes": [ ["atlascloud", "deepseek-ai/deepseek-v4-flash", "https://api.atlascloud.ai/v1", "chat_completions", "metered"], ["codewhale", "deepseek/deepseek-v4-pro", "https://api.codewhale.net/v1", "chat_completions", "metered"], @@ -15,14 +15,12 @@ ["nvidia-nim", "deepseek-ai/deepseek-v4-pro", "https://integrate.api.nvidia.com/v1", "chat_completions", "metered"], ["opencode-zen", "gpt-5.6-sol", "https://opencode.ai/zen/v1", "responses", "metered"], ["openmodel", "deepseek-v4-flash", "https://api.openmodel.ai", "anthropic_messages", "metered"], - ["openrouter", "deepseek/deepseek-v4-pro", "https://openrouter.ai/api/v1", "chat_completions", "metered"], ["orcarouter", "deepseek/deepseek-v4-pro", "https://api.orcarouter.ai/v1", "chat_completions", "metered"], ["qianfan", "ernie-4.0-turbo-8k", "https://api.baiduqianfan.ai/v1", "chat_completions", "metered"], ["sakana", "fugu", "https://api.sakana.ai/v1", "chat_completions", "metered"], ["siliconflow", "deepseek-ai/DeepSeek-V4-Pro", "https://api.siliconflow.com/v1", "chat_completions", "metered"], ["siliconflow-CN", "deepseek-ai/DeepSeek-V4-Pro", "https://api.siliconflow.cn/v1", "chat_completions", "metered"], ["telecomjs", "deepseek-v4-pro", "https://aigw.telecomjs.com/v1", "chat_completions", "metered"], - ["together", "deepseek-ai/DeepSeek-V4-Pro", "https://api.together.xyz/v1", "chat_completions", "metered"], ["wanjie-ark", "deepseek-reasoner", "https://maas-openapi.wanjiedata.com/api/v1", "chat_completions", "metered"], ["zenmux", "deepseek/deepseek-v4.1-flash", "https://zenmux.ai/api/v1", "chat_completions", "metered"] ] diff --git a/crates/tui/src/provider_catalog_live.rs b/crates/tui/src/provider_catalog_live.rs index 6f0ad798ec..136954495e 100644 --- a/crates/tui/src/provider_catalog_live.rs +++ b/crates/tui/src/provider_catalog_live.rs @@ -90,6 +90,7 @@ pub struct ProviderLivePricingQuote { pub(crate) currency: Currency, pub(crate) provenance: PricingProvenance, pub(crate) cloud_facts: Option, + pub(crate) pricing_schedule: Option, pub(crate) input_per_million: Option, pub(crate) output_per_million: Option, pub(crate) cache_read_per_million: Option, @@ -120,6 +121,8 @@ struct ProviderLivePricingQuoteWire { #[serde(default, skip_serializing_if = "Option::is_none")] cloud_facts: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pricing_schedule: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] input_per_million: Option, #[serde(default, skip_serializing_if = "Option::is_none")] output_per_million: Option, @@ -147,6 +150,7 @@ impl TryFrom<&ProviderLivePricingQuote> for ProviderLivePricingQuoteWire { currency: quote.currency.clone(), provenance: quote.provenance.clone(), cloud_facts: quote.cloud_facts.clone(), + pricing_schedule: quote.pricing_schedule.clone(), input_per_million: quote.input_per_million.clone(), output_per_million: quote.output_per_million.clone(), cache_read_per_million: quote.cache_read_per_million.clone(), @@ -190,6 +194,7 @@ impl<'de> Deserialize<'de> for ProviderLivePricingQuote { currency: wire.currency, provenance: wire.provenance, cloud_facts: wire.cloud_facts, + pricing_schedule: wire.pricing_schedule, input_per_million: wire.input_per_million, output_per_million: wire.output_per_million, cache_read_per_million: wire.cache_read_per_million, @@ -227,6 +232,35 @@ impl ProviderLivePricingQuote { .any(Option::is_some) } + pub(crate) fn carries_pricing_decision(&self) -> bool { + self.carries_rates() + || self + .pricing_schedule + .as_deref() + .filter(|schedule| schedule.len() <= 32_768) + .and_then(|schedule| { + serde_json::from_str::<(OfferingPricing, Option)>(schedule) + .ok() + }) + .is_some_and(|(pricing, _)| pricing.has_any_price() || pricing.rate_withheld) + } + + pub(crate) fn secondary_pricing_for_route( + &self, + provider: ProviderKind, + identity: &str, + model: &str, + fingerprint: &str, + at: u64, + ) -> Option { + self.pricing_for_route(provider, identity, model, fingerprint, at)?; + serde_json::from_str::<(OfferingPricing, Option)>( + self.pricing_schedule.as_deref()?, + ) + .ok()? + .1 + } + fn is_structurally_valid(&self) -> bool { self.pricing_for_route( self.provider, @@ -254,6 +288,7 @@ impl ProviderLivePricingQuote { cache_read_per_million: &Option, cache_write_per_million: &Option, cloud_facts: Option<&CloudFactsPricingSource>, + pricing_schedule: Option<&str>, ) -> Option { let payload = serde_json::to_vec(&( "codewhale-provider-live-pricing-quote-v1", @@ -280,6 +315,12 @@ impl ProviderLivePricingQuote { } None => payload, }; + let payload = match pricing_schedule { + Some(schedule) => { + serde_json::to_vec(&("codewhale-pricing-schedule-v2", payload, schedule)).ok()? + } + None => payload, + }; Some(format!("sha256:{}", crate::hashing::sha256_hex(payload))) } @@ -303,6 +344,11 @@ impl ProviderLivePricingQuote { let output_per_million = Self::canonical_rate(pricing.output_per_million); let cache_read_per_million = Self::canonical_rate(pricing.cache_read_per_million); let cache_write_per_million = Self::canonical_rate(pricing.cache_write_per_million); + let mut owned = pricing.clone(); + owned.provider = provider_identity.into(); + owned.wire_model_id = wire_model.into(); + let pricing_schedule = + Some(serde_json::to_string(&(owned, Option::::None)).ok()?); let catalog_revision = Self::revision_for( provider, provider_identity, @@ -316,6 +362,7 @@ impl ProviderLivePricingQuote { &cache_read_per_million, &cache_write_per_million, None, + pricing_schedule.as_deref(), )?; Some(Self { provider, @@ -327,6 +374,7 @@ impl ProviderLivePricingQuote { currency: pricing.currency.clone(), provenance: pricing.provenance.clone(), cloud_facts: None, + pricing_schedule, input_per_million, output_per_million, cache_read_per_million, @@ -375,14 +423,32 @@ impl ProviderLivePricingQuote { } match (&self.provenance, &self.cloud_facts) { (PricingProvenance::UserOverride, None) => {} - (PricingProvenance::ProviderLive, None) + ( + PricingProvenance::ModelsDevBundled + | PricingProvenance::ProviderDocs + | PricingProvenance::CodewhaleCatalog, + None, + ) if published_pricing_scope(provider, provider_identity, endpoint_fingerprint) => {} + (PricingProvenance::ModelsDevLive, None) if dispatched_at_unix.saturating_sub(self.catalog_fetched_at) < DEFAULT_PROVIDER_CATALOG_TTL_SECS - && reviewed_provider_live_scope( + && published_pricing_scope( provider, provider_identity, endpoint_fingerprint, ) => {} + (PricingProvenance::ProviderLive, None) + if dispatched_at_unix.saturating_sub(self.catalog_fetched_at) + < DEFAULT_PROVIDER_CATALOG_TTL_SECS + && (reviewed_provider_live_scope( + provider, + provider_identity, + endpoint_fingerprint, + ) || published_pricing_scope( + provider, + provider_identity, + endpoint_fingerprint, + )) => {} (PricingProvenance::CloudFacts, Some(source)) if source.facts_version > 0 && !source.key_id.is_empty() @@ -424,6 +490,7 @@ impl ProviderLivePricingQuote { && cost.cache_read.is_none() && cost.cache_write.is_none() && self.provenance != PricingProvenance::UserOverride + && self.pricing_schedule.is_none() { return None; } @@ -440,10 +507,51 @@ impl ProviderLivePricingQuote { &self.cache_read_per_million, &self.cache_write_per_million, self.cloud_facts.as_ref(), + self.pricing_schedule.as_deref(), )?; if self.catalog_revision != expected_revision { return None; } + if let Some(schedule) = self.pricing_schedule.as_deref() { + if schedule.len() > 32_768 { + return None; + } + let (pricing, secondary): (OfferingPricing, Option) = + serde_json::from_str(schedule).ok()?; + if pricing.provider != self.provider_identity + || pricing.wire_model_id != self.wire_model + || pricing.provenance != self.provenance + || pricing.currency != self.currency + || pricing.input_per_million != cost.input + || pricing.output_per_million != cost.output + || pricing.cache_read_per_million != cost.cache_read + || pricing.cache_write_per_million != cost.cache_write + || !codewhale_config::pricing::catalog_cost_is_valid(&pricing.cost_structure()) + || (matches!( + self.provenance, + PricingProvenance::ModelsDevLive | PricingProvenance::ProviderLive + ) && pricing.fetched_at.or(pricing.effective_at) + != Some(self.catalog_fetched_at)) + || (self.provenance == PricingProvenance::ProviderLive + && pricing.endpoint_fingerprint.as_deref() + != Some(self.endpoint_fingerprint.as_str())) + || (self.provenance != PricingProvenance::ModelsDevBundled + && pricing + .expires_at + .is_some_and(|expires| dispatched_at_unix >= expires)) + || secondary.as_ref().is_some_and(|row| { + row.provider != self.provider_identity + || row.wire_model_id != self.wire_model + || row.currency != Currency::Cny + || row.provenance != self.provenance + || self.provenance != PricingProvenance::ProviderDocs + || !codewhale_config::pricing::catalog_cost_is_valid(&row.cost_structure()) + }) + { + return None; + } + return Some(pricing); + } Some(OfferingPricing { provider: self.provider_identity.clone(), wire_model_id: self.wire_model.clone(), @@ -456,6 +564,8 @@ impl ProviderLivePricingQuote { provenance: self.provenance.clone(), effective_at: Some(self.catalog_fetched_at), endpoint_fingerprint: Some(self.endpoint_fingerprint.clone()), + + ..Default::default() }) } } @@ -1321,6 +1431,14 @@ fn reviewed_provider_live_scope( } } +fn published_pricing_scope(provider: ProviderKind, identity: &str, fingerprint: &str) -> bool { + !matches!( + provider, + ProviderKind::Custom | ProviderKind::OpenaiCodex | ProviderKind::OpencodeGo + ) && identity == provider.as_str() + && fingerprint == base_url_fingerprint(provider.provider().default_base_url()) +} + #[must_use] pub(crate) fn fresh_provider_live_pricing_quote_at( provider: ProviderKind, @@ -1361,7 +1479,7 @@ pub(crate) fn fresh_provider_live_pricing_quote_at( || pricing.wire_model_id.trim() != wire_model || pricing.currency != Currency::Usd || pricing.provenance != PricingProvenance::ProviderLive - || pricing.effective_at != Some(entry.fetched_at) + || pricing.fetched_at.or(pricing.effective_at) != Some(entry.fetched_at) || pricing.endpoint_fingerprint.as_deref() != Some(endpoint_fingerprint) || pricing.input_per_million.is_none() || pricing.output_per_million.is_none() @@ -1425,9 +1543,28 @@ pub(crate) fn configured_dispatch_pricing_quote_at( output_per_million: cost.output, cache_read_per_million: cost.cache_read, cache_write_per_million: cost.cache_write, + reasoning_per_million: cost.reasoning, + input_audio_per_million: cost.input_audio, + output_audio_per_million: cost.output_audio, + context_tiers: cost.context_tiers(), + rate_withheld: declared.cost.is_some() + && !OfferingPricing { + input_per_million: cost.input, + output_per_million: cost.output, + cache_read_per_million: cost.cache_read, + cache_write_per_million: cost.cache_write, + reasoning_per_million: cost.reasoning, + input_audio_per_million: cost.input_audio, + output_audio_per_million: cost.output_audio, + context_tiers: cost.context_tiers(), + ..Default::default() + } + .has_any_price(), provenance: PricingProvenance::UserOverride, effective_at: None, endpoint_fingerprint: Some(base_url_fingerprint(base_url)), + + ..Default::default() }; // Freeze even an unpriced declaration: missing rates must not fall through // to a same-named bundled or subsequently refreshed price. @@ -1451,8 +1588,17 @@ pub(crate) fn declared_or_catalog_quote( catalog: impl FnOnce() -> Option, ) -> Option { match declared { - Some(quote) if quote.carries_rates() => Some(quote), - declared => catalog().or(declared), + Some(quote) if quote.carries_pricing_decision() => Some(quote), + declared => catalog() + .filter(|quote| { + matches!( + quote.provenance, + PricingProvenance::ProviderLive + | PricingProvenance::ModelsDevLive + | PricingProvenance::CloudFacts + ) + }) + .or(declared), } } @@ -1473,6 +1619,140 @@ pub(crate) fn fresh_dispatch_pricing_quote_at( ) { return Some(quote); } + if crate::provider_lake::live_catalog_origin(provider, wire_model) + == Some(crate::provider_lake::LiveSource::ModelsDev) + && crate::provider_lake::catalog_offering_for_model_identity( + provider, + Some(provider_identity), + wire_model, + ) + .is_some_and(|row| { + matches!( + row.source, + codewhale_config::catalog::CatalogSource::Live { .. } + ) + }) + { + return None; + } + if !published_pricing_scope(provider, provider_identity, &endpoint_fingerprint) { + if let Some(quote) = fresh_cloud_dispatch_pricing_quote_at( + provider, + provider_identity, + wire_model, + base_url, + dispatched_at_unix, + ) { + return Some(quote); + } + return None; + } + let at = chrono::DateTime::from_timestamp(i64::try_from(dispatched_at_unix).ok()?, 0)?; + let hand = crate::pricing::provider_owned_hand_quote_rows(provider, wire_model, at); + let needs_schedule = matches!( + provider, + ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic + ); + let catalog = crate::provider_lake::catalog_offering_for_route( + provider, + provider_identity, + base_url, + wire_model, + ) + .filter(|offering| offering.wire_model_id == wire_model) + .map(|mut offering| { + if crate::provider_lake::live_catalog_origin(provider, wire_model) + == Some(crate::provider_lake::LiveSource::ModelsDev) + && let codewhale_config::catalog::CatalogSource::Live { fetched_at, .. } = + offering.pricing_source() + { + offering.cost_source = Some(codewhale_config::catalog::CatalogSource::ModelsDevLive { + fetched_at: *fetched_at, + }); + } + offering + }) + .and_then(|offering| OfferingPricing::from_catalog_offering_at(&offering, dispatched_at_unix)) + .filter(|pricing| { + pricing + .live_pricing_defect( + Some(&endpoint_fingerprint), + Some(dispatched_at_unix), + DEFAULT_PROVIDER_CATALOG_TTL_SECS, + ) + .is_none() + }); + let catalog_is_live = catalog.as_ref().is_some_and(|pricing| { + matches!( + pricing.provenance, + PricingProvenance::ModelsDevLive | PricingProvenance::ProviderLive + ) + }); + let (pricing, mut secondary) = if needs_schedule && hand.is_some() { + hand? + } else if catalog_is_live { + catalog.map(|pricing| (pricing, None)).or(hand)? + } else if hand.is_some() { + hand? + } else if let Some(quote) = fresh_cloud_dispatch_pricing_quote_at( + provider, + provider_identity, + wire_model, + base_url, + dispatched_at_unix, + ) { + return Some(quote); + } else { + catalog.map(|pricing| (pricing, None))? + }; + let mut quote = ProviderLivePricingQuote::from_pricing( + provider, + provider_identity, + wire_model, + &endpoint_fingerprint, + pricing.fetched_at.unwrap_or(dispatched_at_unix), + &pricing, + )?; + if let Some(row) = secondary.as_mut() { + row.provider = provider_identity.into(); + row.wire_model_id = wire_model.into(); + let (primary, _): (OfferingPricing, Option) = + serde_json::from_str(quote.pricing_schedule.as_deref()?).ok()?; + quote.pricing_schedule = Some(serde_json::to_string(&(primary, secondary)).ok()?); + quote.catalog_revision = ProviderLivePricingQuote::revision_for( + quote.provider, + "e.provider_identity, + "e.wire_model, + "e.endpoint_fingerprint, + quote.catalog_fetched_at, + "e.currency, + "e.provenance, + "e.input_per_million, + "e.output_per_million, + "e.cache_read_per_million, + "e.cache_write_per_million, + None, + quote.pricing_schedule.as_deref(), + )?; + } + quote.pricing_for_route( + provider, + provider_identity, + wire_model, + &endpoint_fingerprint, + dispatched_at_unix, + )?; + Some(quote) +} + +fn fresh_cloud_dispatch_pricing_quote_at( + provider: ProviderKind, + provider_identity: &str, + wire_model: &str, + base_url: &str, + dispatched_at_unix: u64, +) -> Option { + let endpoint_fingerprint = base_url_fingerprint(base_url); if !cloud_pricing_scope(provider, provider_identity, base_url) { return None; } @@ -1535,6 +1815,7 @@ pub(crate) fn fresh_dispatch_pricing_quote_at( "e.cache_read_per_million, "e.cache_write_per_million, quote.cloud_facts.as_ref(), + quote.pricing_schedule.as_deref(), )?; quote.pricing_for_route( provider, diff --git a/crates/tui/src/provider_lake.rs b/crates/tui/src/provider_lake.rs index d6def4de2e..90d81ae851 100644 --- a/crates/tui/src/provider_lake.rs +++ b/crates/tui/src/provider_lake.rs @@ -1321,9 +1321,16 @@ pub(crate) fn configured_catalog_offering_for_route( base_url: &str, model: &str, ) -> Option { + let lower = catalog_offering_for_route(provider, identity, base_url, model); configured_model_for_route(config, provider, identity, base_url, model) - .map(|row| row.to_catalog_offering()) - .or_else(|| catalog_offering_for_route(provider, identity, base_url, model)) + .map(|row| { + let mut offering = row.to_catalog_offering(); + if let Some(lower) = lower.as_ref() { + offering.complete_from(lower, Some(base_url)); + } + offering + }) + .or(lower) } pub(crate) fn configured_catalog_models_for_route( diff --git a/crates/tui/src/reasoning_preference.rs b/crates/tui/src/reasoning_preference.rs index cdac2dfcc2..2e57f1a9ca 100644 --- a/crates/tui/src/reasoning_preference.rs +++ b/crates/tui/src/reasoning_preference.rs @@ -283,8 +283,14 @@ impl ReasoningEffort { other => other, }; } - if let Some(values) = Self::catalog_effort_values(provider, wire_model) { - return Self::clamp_to_catalog_efforts(normalized, provider, wire_model, &values); + if let Some(offering) = Self::offering_for_reasoning(provider, base_url, wire_model) + && let Some(values) = Self::catalog_effort_values_for_offering(&offering) + { + return Self::clamp_to_catalog_efforts( + normalized, + Self::catalog_default_for_offering(&offering), + &values, + ); } match normalized { Self::Low | Self::Medium => Self::High, @@ -292,8 +298,32 @@ impl ReasoningEffort { } } - pub fn catalog_default(provider: ProviderKind, wire_model: &str) -> Option { - let offering = crate::provider_lake::catalog_offering_for_model(provider, wire_model)?; + pub(crate) fn offering_for_reasoning( + provider: ProviderKind, + base_url: &str, + wire_model: &str, + ) -> Option { + crate::provider_lake::catalog_offering_for_route( + provider, + provider.as_str(), + base_url, + wire_model, + ) + .or_else(|| crate::provider_lake::catalog_offering_for_model(provider, wire_model)) + } + + pub fn catalog_default( + provider: ProviderKind, + base_url: &str, + wire_model: &str, + ) -> Option { + let offering = Self::offering_for_reasoning(provider, base_url, wire_model)?; + Self::catalog_default_for_offering(&offering) + } + + fn catalog_default_for_offering( + offering: &codewhale_config::catalog::CatalogOffering, + ) -> Option { offering.reasoning_options.iter().find_map(|option| { option .get("type") @@ -306,8 +336,18 @@ impl ReasoningEffort { }) } - pub fn catalog_effort_values(provider: ProviderKind, wire_model: &str) -> Option> { - let offering = crate::provider_lake::catalog_offering_for_model(provider, wire_model)?; + pub fn catalog_effort_values( + provider: ProviderKind, + base_url: &str, + wire_model: &str, + ) -> Option> { + let offering = Self::offering_for_reasoning(provider, base_url, wire_model)?; + Self::catalog_effort_values_for_offering(&offering) + } + + pub(crate) fn catalog_effort_values_for_offering( + offering: &codewhale_config::catalog::CatalogOffering, + ) -> Option> { let mut efforts = Vec::new(); let mut has_toggle = false; for option in &offering.reasoning_options { @@ -348,30 +388,25 @@ impl ReasoningEffort { "xhigh" => Some(Self::XHigh), "ultra" | "ultracode" => Some(Self::Ultra), "max" | "maximum" => Some(Self::Max), - "auto" | "automatic" | "adaptive" => Some(Self::Auto), + "auto" | "automatic" | "adaptive" | "default" => Some(Self::Auto), _ => None, } } - fn clamp_to_catalog_efforts( - normalized: Self, - provider: ProviderKind, - wire_model: &str, - values: &[Self], - ) -> Self { + fn clamp_to_catalog_efforts(normalized: Self, default: Option, values: &[Self]) -> Self { if matches!(normalized, Self::Auto) || values.contains(&normalized) { return normalized; } let aliased = match normalized { Self::Minimal if values.contains(&Self::Low) => Self::Low, Self::Max | Self::Ultra if values.contains(&Self::XHigh) => Self::XHigh, - Self::Off => Self::catalog_default(provider, wire_model).unwrap_or(Self::High), + Self::Off => default.unwrap_or(Self::High), other => other, }; if values.contains(&aliased) { aliased } else { - Self::catalog_default(provider, wire_model).unwrap_or(Self::High) + default.unwrap_or(Self::High) } } diff --git a/crates/tui/src/rlm/bridge.rs b/crates/tui/src/rlm/bridge.rs index 6035db9146..463e237c28 100644 --- a/crates/tui/src/rlm/bridge.rs +++ b/crates/tui/src/rlm/bridge.rs @@ -135,14 +135,14 @@ impl RlmUsageAccumulator { let completed = if let Some(Some(slot)) = state.records.get_mut(reservation.index) && !slot.completed { - super::add_usage_with_prompt_cache(&mut slot.record.usage.usage, usage); + crate::core::turn::add_usage_to(&mut slot.record.usage.usage, usage); slot.completed = true; true } else { false }; if completed { - super::add_usage_with_prompt_cache(&mut state.usage, usage); + crate::core::turn::add_usage_to(&mut state.usage, usage); } } @@ -797,7 +797,9 @@ mod tests { bridge.usage.cancel(missing_reservation, true).await; let snapshot = bridge.usage_snapshot().await; - assert_eq!(snapshot.usage, reported); + let mut expected_reported = reported.clone(); + expected_reported.request_usages = vec![reported.clone()]; + assert_eq!(snapshot.usage, expected_reported); assert_eq!(snapshot.records.len(), 1); assert_eq!(snapshot.drop_records.len(), 1); assert_eq!(snapshot.dropped_records, 1); @@ -882,9 +884,11 @@ mod tests { } let snapshot = bridge.usage_snapshot().await; - assert_eq!(snapshot.usage, usage); + let mut expected_usage = usage.clone(); + expected_usage.request_usages = vec![usage.clone()]; + assert_eq!(snapshot.usage, expected_usage); assert_eq!(snapshot.records.len(), 1); - assert_eq!(snapshot.records[0].usage.usage, usage); + assert_eq!(snapshot.records[0].usage.usage, expected_usage); let rejected_fragments: Vec<_> = snapshot .nested_events .iter() diff --git a/crates/tui/src/rlm/mod.rs b/crates/tui/src/rlm/mod.rs index 02c17e82c8..d87418dd79 100644 --- a/crates/tui/src/rlm/mod.rs +++ b/crates/tui/src/rlm/mod.rs @@ -11,6 +11,7 @@ //! or caller. The persistent `rlm` action surface is caller-session scoped; //! `share_session=true` explicitly refuses. No HTTP sidecar is created. +#[cfg(test)] use codewhale_models::Usage; pub mod bridge; @@ -22,44 +23,6 @@ pub(crate) use bridge::RlmBridge; pub use prompt::rlm_system_prompt; pub use turn::{RlmTermination, RlmTurnResult}; -fn add_usage_with_prompt_cache(total: &mut Usage, delta: &Usage) { - total.input_tokens = total.input_tokens.saturating_add(delta.input_tokens); - total.output_tokens = total.output_tokens.saturating_add(delta.output_tokens); - total.prompt_cache_hit_tokens = - add_optional_usage(total.prompt_cache_hit_tokens, delta.prompt_cache_hit_tokens); - total.prompt_cache_miss_tokens = add_optional_usage( - total.prompt_cache_miss_tokens, - delta.prompt_cache_miss_tokens, - ); - total.prompt_cache_write_tokens = add_optional_usage( - total.prompt_cache_write_tokens, - delta.prompt_cache_write_tokens, - ); - total.reasoning_tokens = add_optional_usage(total.reasoning_tokens, delta.reasoning_tokens); - total.reasoning_replay_tokens = - add_optional_usage(total.reasoning_replay_tokens, delta.reasoning_replay_tokens); - if let Some(delta_server) = delta.server_tool_use.as_ref() { - let total_server = total.server_tool_use.get_or_insert_default(); - total_server.code_execution_requests = add_optional_usage( - total_server.code_execution_requests, - delta_server.code_execution_requests, - ); - total_server.tool_search_requests = add_optional_usage( - total_server.tool_search_requests, - delta_server.tool_search_requests, - ); - } -} - -fn add_optional_usage(total: Option, delta: Option) -> Option { - match (total, delta) { - (Some(total), Some(delta)) => Some(total.saturating_add(delta)), - (None, Some(delta)) => Some(delta), - (Some(total), None) => Some(total), - (None, None) => None, - } -} - #[cfg(test)] mod tests { use super::*; @@ -87,7 +50,7 @@ mod tests { ..Usage::default() }; - add_usage_with_prompt_cache(&mut total, &delta); + crate::core::turn::add_usage_to(&mut total, &delta); assert_eq!(total.input_tokens, 150); assert_eq!(total.output_tokens, 15); diff --git a/crates/tui/src/rlm/turn.rs b/crates/tui/src/rlm/turn.rs index 59b03bf549..f8d47f0930 100644 --- a/crates/tui/src/rlm/turn.rs +++ b/crates/tui/src/rlm/turn.rs @@ -467,9 +467,14 @@ mod tests { let error = result.error.expect("truncation must fail the RLM turn"); assert!(error.contains("incomplete"), "{error}"); assert!(error.contains("max_tokens"), "{error}"); - assert_eq!(result.usage, usage, "billed usage must still be charged"); + let mut expected_usage = usage.clone(); + expected_usage.request_usages = vec![usage.clone()]; + assert_eq!( + result.usage, expected_usage, + "billed usage must still be charged" + ); assert_eq!(result.routed_usage.len(), 1); - assert_eq!(result.routed_usage[0].usage.usage, usage); + assert_eq!(result.routed_usage[0].usage.usage, expected_usage); assert_eq!(result.routed_usage_dropped_records, 0); assert_eq!(mock.call_count(), 1, "truncation must not retry"); assert!( @@ -693,7 +698,9 @@ mod tests { .unwrap() .contains("wall-clock deadline") ); - assert_eq!(result.usage, text_response(partial).usage); + let mut expected_usage = text_response(partial).usage; + expected_usage.request_usages = vec![text_response(partial).usage]; + assert_eq!(result.usage, expected_usage); assert_eq!(client.0.call_count(), 1); let snapshot = usage.snapshot().await; assert_eq!(snapshot.records.len(), 1, "keep completed usage"); diff --git a/crates/tui/src/route_budget.rs b/crates/tui/src/route_budget.rs index 2c0b70701e..7f84a3f07e 100644 --- a/crates/tui/src/route_budget.rs +++ b/crates/tui/src/route_budget.rs @@ -1028,7 +1028,6 @@ mod tests { ] { for model in [ "deepseek-v4.1-flash-expires-on-0910", - "deepseek-v4.1-flash", "deepseek-v4-flash-vendor", ] { assert_eq!(provider_capability(provider, model).max_output, None); @@ -1043,6 +1042,11 @@ mod tests { } } + assert_eq!( + output_ceiling_source(ProviderKind::Deepseek, "deepseek-v4.1-flash"), + OutputCeilingSource::Documented(384_000) + ); + // Exact route output facts supply a ceiling only for that request. let limits = RouteLimits { context_tokens: Some(128_000), diff --git a/crates/tui/src/route_preferences.rs b/crates/tui/src/route_preferences.rs index b5bd4c011d..fe022ef7cf 100644 --- a/crates/tui/src/route_preferences.rs +++ b/crates/tui/src/route_preferences.rs @@ -321,6 +321,25 @@ pub fn selected_route(path: &Path) -> Result<(String, String, codewhale_config:: Ok((provider, config.default_model(), source)) } +pub fn resolve_model( + path: &Path, + provider_identity: &str, + base_url: &str, + model: Option<&str>, +) -> Result { + crate::models_dev_live::maybe_load_persisted_cache(); + let store = codewhale_config::ConfigStore::load(Some(path.to_path_buf()))?; + let mut config = saved_config(&store)?; + let identity = config + .resolve_provider_selection_identity(provider_identity) + .map_err(anyhow::Error::msg)?; + config.set_provider_base_url_override(&identity, Some(base_url.into()))?; + crate::provider_catalog_live::maybe_load_persisted_cache_for_config(&config); + let route = crate::route_runtime::resolve_runtime_route_for_identity(&config, &identity, model) + .map_err(anyhow::Error::msg)?; + Ok(route.candidate) +} + /// Save one explicit route preference after atomically adopting legacy choices. pub fn set(path: &Path, key: &str, value: &str) -> Result<()> { persistence::mutate_config_document(path, |doc| set_document(path, doc, key, value)) diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index d24727be98..f8e1cb1700 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -1643,7 +1643,9 @@ pub(crate) fn open_runtime_threads_for_host( // Publish the same exact endpoint-scoped catalog as interactive startup // before the server admits turns. A cached model list alone does not make // its capabilities available to route resolution. + crate::models_dev_live::maybe_load_persisted_cache(); crate::provider_catalog_live::maybe_load_persisted_cache_for_config(config); + crate::models_dev_live::spawn_background_refresh(); Ok((manager, workshop_activation)) } @@ -10348,6 +10350,8 @@ struct ProviderModelEntry { reasoning_effort: codewhale_config::route::CapabilityState, reasoning_effort_levels: Vec, reasoning_effort_source: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + facts: Option, } #[derive(Debug, Clone, Serialize)] @@ -10631,7 +10635,43 @@ fn provider_model_entry_for_api( reasoning_effort: CapabilityState::Unknown, reasoning_effort_levels: Vec::new(), reasoning_effort_source: None, + facts: None, }; + let base = config.base_url_for_route(identity); + let offering = crate::provider_lake::configured_catalog_offering_for_route( + config, + provider, + identity.key.as_str(), + &base, + &entry.id, + ); + let resolved = + crate::route_runtime::resolve_runtime_route_for_identity(config, identity, Some(&entry.id)) + .ok(); + if let Some(offering) = offering.as_ref() { + let pricing = codewhale_config::pricing::OfferingPricing::from_catalog_offering(&offering); + entry.facts = Some(serde_json::json!({ + "wire_model_id": offering.wire_model_id, + "canonical_model_id": offering.canonical_model, + "name": offering.name, + "family": offering.family, + "status": offering.status, + "advertised_limits": offering.limit, + "effective_limits": resolved.as_ref().map(|route| route.candidate.limits()), + "effective_context": resolved.as_ref().map(|route| route.context_window), + "capabilities": resolved.as_ref().map(|route| route.candidate.capabilities()), + "reasoning": offering.reasoning, + "reasoning_options": offering.reasoning_options, + "modalities": offering.modalities, + "temperature": offering.temperature, + "source": offering.source, + "fact_sources": offering.fact_sources, + "pricing_status": pricing.as_ref().map(|pricing| pricing.status_label_at(codewhale_config::catalog::now_unix())), + "pricing": pricing, + "upstream_last_updated": offering.last_updated, + "account_access": "unverified", + })); + } // A provider kind and a familiar model name do not establish the // capabilities of a different endpoint or named compatible route. if provider == ProviderKind::Custom || config.provider_uses_custom_endpoint(identity) { @@ -10665,10 +10705,13 @@ fn provider_model_entry_for_api( if metadata.reasoning == Some(false) { entry.reasoning_effort = CapabilityState::Unsupported; } - } else if let Some(efforts) = ReasoningEffort::catalog_effort_values(provider, &entry.id) { + } else if let Some(efforts) = offering + .as_ref() + .and_then(ReasoningEffort::catalog_effort_values_for_offering) + { entry.reasoning_effort_levels = efforts .into_iter() - .filter(|effort| *effort != ReasoningEffort::Off) + .filter(|effort| !matches!(effort, ReasoningEffort::Off | ReasoningEffort::Auto)) .map(|effort| effort.as_setting().to_string()) .collect(); entry.reasoning_effort_source = Some("catalog"); diff --git a/crates/tui/src/runtime_api/context.rs b/crates/tui/src/runtime_api/context.rs index eba5099bb4..59cacdd627 100644 --- a/crates/tui/src/runtime_api/context.rs +++ b/crates/tui/src/runtime_api/context.rs @@ -10,9 +10,9 @@ use super::{ApiError, RuntimeApiState, map_thread_err}; /// is the last provider-counted prompt size when one exists. /// /// All numeric fields are nullable: a route that cannot express a bounded -/// window (unknown model, no catalog or configured limits) reports `null` -/// rather than an invented number, and `live: false` marks responses where -/// the engine could not be loaded and only the static route window resolved. +/// window reports `null`. Static windows retain their resolution source, +/// including a named unverified fallback. `live: false` marks responses where +/// the engine could not be loaded and only the stored route resolved. #[derive(Debug, Serialize)] pub(super) struct ThreadContextResponse { thread_id: String, @@ -23,6 +23,7 @@ pub(super) struct ThreadContextResponse { /// route limits); `false` when only the store-recorded route resolved. live: bool, window_tokens: Option, + window_source: Option<&'static str>, input_tokens: Option, billed_input_tokens: Option, output_cap_tokens: Option, @@ -53,6 +54,7 @@ pub(super) async fn get_thread_context( model_provider_id: snapshot.model_provider_id, live: true, window_tokens: Some(snapshot.window_tokens), + window_source: Some("engine_active_route"), input_tokens: Some(snapshot.input_tokens), billed_input_tokens: snapshot.billed_input_tokens, output_cap_tokens: Some(snapshot.output_cap_tokens), @@ -66,17 +68,29 @@ pub(super) async fn get_thread_context( // Engine unavailable or the route cannot bound a window: still answer // with whatever the thread record and the static route catalog can prove. - let provider = thread + let config = state.config.read().clone(); + let route = thread .model_provider .as_deref() - .and_then(crate::config::ProviderKind::parse); - let window_tokens = provider.map(|provider| { - u64::from(crate::route_budget::route_context_window_tokens( - provider, - &thread.model, - None, - )) - }); + .and_then(|provider| { + config + .resolve_persisted_provider_identity( + Some(provider), + thread.model_provider_id.as_deref(), + ) + .ok() + }) + .and_then(|identity| { + crate::route_runtime::resolve_runtime_route_for_identity( + &config, + &identity, + Some(&thread.model), + ) + .ok() + }); + let window_tokens = route + .as_ref() + .map(|route| u64::from(route.context_window.tokens)); Ok(Json(ThreadContextResponse { thread_id, model: thread.model, @@ -84,6 +98,9 @@ pub(super) async fn get_thread_context( model_provider_id: thread.model_provider_id, live: false, window_tokens, + window_source: route + .as_ref() + .map(|route| route.context_window.source.label()), input_tokens: None, billed_input_tokens: None, output_cap_tokens: None, diff --git a/crates/tui/src/runtime_api/tests.rs b/crates/tui/src/runtime_api/tests.rs index 64a1046550..e32db42f60 100644 --- a/crates/tui/src/runtime_api/tests.rs +++ b/crates/tui/src/runtime_api/tests.rs @@ -12855,6 +12855,7 @@ fn provider_model_catalog_paginates_all_six_hundred_rows_without_truncation() { reasoning_effort: codewhale_config::route::CapabilityState::Unknown, reasoning_effort_levels: Vec::new(), reasoning_effort_source: None, + facts: None, output_token_limit: codewhale_config::route::CapabilityState::Unknown, id: format!("openrouter/model-{index:03}"), image_input: codewhale_config::route::CapabilityState::Unknown, @@ -12911,6 +12912,7 @@ fn provider_model_catalog_applies_filter_before_cursor_and_rejects_cross_scope_r reasoning_effort: codewhale_config::route::CapabilityState::Unknown, reasoning_effort_levels: Vec::new(), reasoning_effort_source: None, + facts: None, output_token_limit: codewhale_config::route::CapabilityState::Unknown, id: id.to_string(), image_input: codewhale_config::route::CapabilityState::Unknown, @@ -12969,6 +12971,7 @@ fn provider_model_cursor_rejects_catalog_change_between_pages() { reasoning_effort: codewhale_config::route::CapabilityState::Unknown, reasoning_effort_levels: Vec::new(), reasoning_effort_source: None, + facts: None, output_token_limit: codewhale_config::route::CapabilityState::Unknown, id: id.to_string(), image_input: codewhale_config::route::CapabilityState::Unknown, @@ -12992,6 +12995,7 @@ fn provider_model_cursor_rejects_catalog_change_between_pages() { reasoning_effort: codewhale_config::route::CapabilityState::Unknown, reasoning_effort_levels: Vec::new(), reasoning_effort_source: None, + facts: None, output_token_limit: codewhale_config::route::CapabilityState::Unknown, id: "alpha".to_string(), image_input: codewhale_config::route::CapabilityState::Unknown, @@ -13039,6 +13043,7 @@ fn provider_model_cursor_round_trips_multibyte_filter_at_the_allowed_limit() { reasoning_effort: codewhale_config::route::CapabilityState::Unknown, reasoning_effort_levels: Vec::new(), reasoning_effort_source: None, + facts: None, output_token_limit: codewhale_config::route::CapabilityState::Unknown, id: format!("{filter}{suffix}"), image_input: codewhale_config::route::CapabilityState::Unknown, @@ -19883,6 +19888,7 @@ fn runtime_image_named_catalog_cursor_binds_identity_endpoint_and_catalog() -> R reasoning_effort: codewhale_config::route::CapabilityState::Unknown, reasoning_effort_levels: Vec::new(), reasoning_effort_source: None, + facts: None, id: id.into(), output_token_limit: codewhale_config::route::CapabilityState::Unknown, image_input: codewhale_config::route::CapabilityState::Supported, diff --git a/crates/tui/src/scorecard.rs b/crates/tui/src/scorecard.rs index b9ff2efb13..01d1cb33e7 100644 --- a/crates/tui/src/scorecard.rs +++ b/crates/tui/src/scorecard.rs @@ -1324,8 +1324,11 @@ mod tests { let card = Scorecard::from_turns(&turns); + // 1M total input crosses the 272K context tier, so every input class + // (miss, cache read, cache write) bills at the tier input rate of + // $60/M on this documented no-cache-discount route: 1M * 60 = $60. assert!(!card.per_turn[0].cost_unpriced); - assert!((card.per_turn[0].cost_usd - 30.0).abs() < f64::EPSILON); + assert!((card.per_turn[0].cost_usd - 60.0).abs() < f64::EPSILON); assert!(card.per_turn[1].cost_unpriced); assert!(!card.metrics.cost_complete); } diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 641f31d10b..72670e5047 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -7196,7 +7196,9 @@ impl App { } else if let Some(effort) = inferred { ReasoningEffort::from_setting(effort) .normalize_for_route(provider, base_url, wire_model) - } else if let Some(default) = ReasoningEffort::catalog_default(provider, wire_model) { + } else if let Some(default) = + ReasoningEffort::catalog_default(provider, base_url, wire_model) + { default } else { self.reasoning_effort diff --git a/crates/tui/src/tui/app/init.rs b/crates/tui/src/tui/app/init.rs index ec2c9e57f3..5a82930c8f 100644 --- a/crates/tui/src/tui/app/init.rs +++ b/crates/tui/src/tui/app/init.rs @@ -500,10 +500,14 @@ impl App { reasoning_effort = reasoning_effort.normalize_for_route(provider, &active_route_base_url, &model); } else if !auto_model && !reasoning_effort_explicit { - if let Some(default) = ReasoningEffort::catalog_default(provider, &model) { + if let Some(default) = + ReasoningEffort::catalog_default(provider, &active_route_base_url, &model) + { reasoning_effort = default; } - } else if !auto_model && ReasoningEffort::catalog_effort_values(provider, &model).is_some() + } else if !auto_model + && ReasoningEffort::catalog_effort_values(provider, &active_route_base_url, &model) + .is_some() { reasoning_effort = reasoning_effort.normalize_for_route(provider, &active_route_base_url, &model); diff --git a/crates/tui/src/tui/model_picker.rs b/crates/tui/src/tui/model_picker.rs index 49d0d28a01..eb180abe30 100644 --- a/crates/tui/src/tui/model_picker.rs +++ b/crates/tui/src/tui/model_picker.rs @@ -33,9 +33,7 @@ use crate::model_profile::{ resolved_capability_profile_with_overrides, }; use crate::models_dev_live::{self, ModelsDevFreshness}; -use crate::provider_lake::{ - catalog_offering_for_model, catalog_offering_for_model_identity, configured_providers, -}; +use crate::provider_lake::{catalog_offering_for_model_identity, configured_providers}; use crate::reasoning_preference::ReasoningEffort; use crate::settings::PinnedModel; use crate::tui::app::{App, StatusToastLevel}; @@ -3548,7 +3546,10 @@ fn effective_picker_metadata_with_codex( .as_ref() .expect("provider metadata admission checked"); let identity = admitted.key.as_str(); - let context_override = config.context_window_for_provider_config(admitted); + let context_override = config + .model_context_windows_for(admitted) + .and_then(|windows| windows.get(id).copied()) + .or_else(|| config.context_window_for_provider_config(admitted)); let base_url = config.base_url_for_route(admitted); if let Some(declared) = crate::provider_lake::configured_model_for_route(config, provider, identity, &base_url, id) @@ -3715,7 +3716,15 @@ fn effective_picker_metadata_with_codex( vision, pricing, display_name: codex_metadata.and_then(|model| model.display_name.clone()), - reasoning_unknown: false, + reasoning_unknown: if provider == ProviderKind::OpenaiCodex { + codex_metadata.is_none_or(|metadata| metadata.reasoning.is_none()) + } else { + offering + .as_ref() + .map_or(profile.reasoning == SupportState::Unknown, |offering| { + offering.reasoning.is_none() + }) + }, declared_input_price: None, source: card.map(|card| card.source), } @@ -4590,7 +4599,9 @@ fn route_picker_efforts( // effort tiers. Keep the existing wire-compatible route ladder. return CODEX_PICKER_EFFORTS.to_vec(); } - if let Some(catalog_efforts) = catalog_picker_efforts(provider, wire_model) { + if let Some(offering) = ReasoningEffort::offering_for_reasoning(provider, base_url, wire_model) + && let Some(catalog_efforts) = catalog_picker_efforts(&offering) + { return catalog_efforts; } if matches!(provider, crate::config::ProviderKind::Deepseek) { @@ -4608,10 +4619,8 @@ fn route_picker_efforts( /// skipped. Returns `None` when the catalog has no usable effort list so the /// caller can keep the provider default rather than inventing tiers. fn catalog_picker_efforts( - provider: ProviderKind, - wire_model: &str, + offering: &codewhale_config::catalog::CatalogOffering, ) -> Option> { - let offering = catalog_offering_for_model(provider, wire_model)?; let mut efforts = Vec::new(); let mut saw_effort_list = false; let mut has_toggle = false; @@ -4625,8 +4634,6 @@ fn catalog_picker_efforts( has_toggle = true; continue; } - // Prefer explicit effort lists; also accept thinking-mode lists whose - // values map onto our tiers (adaptive→auto, disabled→off, always_on→max). if option_type != "effort" && option_type != "thinking" { continue; } @@ -4648,14 +4655,9 @@ fn catalog_picker_efforts( if !saw_effort_list || efforts.is_empty() { return None; } - // A Models.dev `toggle` beside the ladder means reasoning can also be - // switched off (#6396). if has_toggle && !efforts.contains(&ReasoningEffort::Off) { efforts.insert(0, ReasoningEffort::Off); } - // Always offer Auto when the catalog published discrete tiers so the - // operator can still leave the choice to the route default. Do not invent - // Off unless the catalog said so — some models are always-on. if !efforts.contains(&ReasoningEffort::Auto) { efforts.insert(0, ReasoningEffort::Auto); } @@ -4665,7 +4667,7 @@ fn catalog_picker_efforts( fn catalog_effort_value(raw: &str) -> Option { match raw.trim().to_ascii_lowercase().as_str() { "off" | "disabled" | "false" => Some(ReasoningEffort::Off), - "none" => Some(ReasoningEffort::Off), // Muse "none" maps to Off in our enum but display as "none" + "none" => Some(ReasoningEffort::Off), "minimal" | "minimum" => Some(ReasoningEffort::Minimal), "low" | "light" => Some(ReasoningEffort::Low), "medium" | "mid" => Some(ReasoningEffort::Medium), @@ -5754,8 +5756,11 @@ mod tests { ); // A real catalog/readiness refresh replaces cached presentation facts. - let mut offering = - catalog_offering_for_model(ProviderKind::Deepseek, "deepseek-v4-pro").unwrap(); + let mut offering = crate::provider_lake::catalog_offering_for_model( + ProviderKind::Deepseek, + "deepseek-v4-pro", + ) + .unwrap(); offering.limit.as_mut().unwrap().context = Some(777_000); crate::provider_lake::set_live_snapshot( codewhale_config::catalog::CatalogSnapshot { diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index a0c572737a..080fcd31fa 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -22853,6 +22853,11 @@ fn child_usage_metadata_carries_cache_write_and_reasoning_end_to_end() { code_execution_requests: Some(2), tool_search_requests: Some(1), }), + + input_audio_tokens: None, + output_audio_tokens: None, + + request_usages: Vec::new(), }; // The shared producer emits every class. diff --git a/docs/CATALOG_REFRESH.md b/docs/CATALOG_REFRESH.md index 5a2cdda1c6..8d2e591987 100644 --- a/docs/CATALOG_REFRESH.md +++ b/docs/CATALOG_REFRESH.md @@ -16,7 +16,7 @@ Related docs: [`PROVIDERS.md`](./PROVIDERS.md), RFC |---|---| | Do users need a special model just to refresh models? | **No.** | | Does Codewhale auto-update the public model catalog? | **Yes, at runtime**, from [Models.dev](https://models.dev/catalog.json), ~24 h TTL. | -| Is the offline bundled seed auto-committed in CI? | **No, but it is generated.** A maintainer runs `seed lock` and `seed render` and opens a PR; CI fails a hand edit (`seed render --check`). | +| Is the offline bundled seed auto-committed in CI? | **No, but it is generated.** A maintainer runs `seed lock` and `seed render` and lands the verified generated slice on main; CI fails a hand edit (`seed render --check`). | | Should an LLM rewrite catalog JSON? | **No.** Ingest is deterministic public JSON. An LLM can *review* a PR, not own the source of truth. | --- @@ -141,10 +141,10 @@ These stay hand-maintained or release-lane work until a scheduled PR lands: | Surface | Why it drifts | |---|---| -| `models_dev.bundled.json` | Offline seed, generated from a reviewed spec and a pinned lock (see below); refreshed by PR, not at runtime | +| `models_dev.bundled.json` | Offline seed, generated from a reviewed spec and a pinned lock (see below); refreshed by a verified maintainer slice, not at runtime | | `provider_descriptors.json` default model IDs | Product choice, not pure catalog dump; constant projections are generated | | `catalog_corrections.json` `reviewed` | Intrinsic/selector/transport compatibility facts with exact source receipts; generated into the same seed | -| Rust pricing policy | Vendor billing windows, CNY conversion, withheld/tiered behavior; pure reference observations live in the reviewed supplement | +| Rust pricing policy | Vendor billing windows, independent USD/CNY rates, withheld/tiered behavior; pure reference observations live in the reviewed supplement | | New `ProviderKind` / wire dialect | Needs code, not only JSON | Runtime live refresh **does not** rewrite those files. Users on a recent @@ -153,6 +153,24 @@ hermetic runs, and first-boot without cache still depend on the seed. --- +The current quote is frozen at dispatch with its exact provider identity, wire +ID, endpoint fingerprint, source and rates. Refreshing the catalog does not +reprice old turns. Live rows carry fetch and expiry clocks separately from an +upstream update or a declared effective date. After 24 hours a live price is +stale; a bundled row is a dated estimate, never proof of account access. + +The shared decimal calculator selects each request's context tier from its +entire input, including cache reads and writes. It prices seven published +classes and subtracts separately charged reasoning/audio subsets from the +ordinary classes. Missing rates or a required usage breakdown stay unpriced. +The result is an exact decimal string; display rounding does not change it. +CNY prices remain separate from USD. These are estimates, not reconciled bills. + +Two default wire IDs missing from the 2026-10-12 upstream snapshot remain as +explicitly dated curated compatibility rows in the seed spec, with their prior +snapshot receipts. They do not establish present account availability or a +renamed wire ID. Other removed non-default rows are no longer seeded. + ## Maintainer tooling (no LLM) ### Validate / dry-run fetch @@ -206,7 +224,7 @@ first live refresh. 3. `python3 scripts/catalog_models_dev.py seed lock` writes the lock. 4. `python3 scripts/catalog_models_dev.py seed render` writes the seed. 5. Check that default wire IDs still match `DEFAULT_*_MODEL`, run the - catalog tests, and open a PR with the report in its body. + catalog checks, and land the verified slice on main with its report in the commit. Optional: use a cheap model to summarize “new / removed / default-risk” in the PR body — never as the author of the JSON. diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index f6a92bdc0f..b6fdb87b4d 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -84,10 +84,11 @@ Sources to keep in sync: - `crates/config/assets/catalog_corrections.json` `reviewed` - intrinsic facts, scoped selector aliases, completion references and pure transport metadata. The existing seed renderer embeds this reviewed supplement in Models.dev. -- `crates/agent/src/lib.rs` - compatibility projection of those shared selector - rows, used by `codewhale model resolve` (and the `[model]` config surface). - `codewhale model list` reads the resolved provider lake instead, so a single - list shows bundled, live and configured rows together. +- `crates/tui/src/provider_lake.rs` and `route_runtime.rs` - resolve current + model facts for both CLI model commands, the picker and Runtime API. Public + catalog facts, provider/account rosters and explicit configuration retain + their separate ownership. `crates/agent/src/lib.rs` keeps the legacy selector + compatibility projection; it does not decide the CLI's current route. - `config.example.toml` and `docs/CONFIGURATION.md` - user-facing config examples and environment variable reference. - `scripts/check-provider-registry.py` - drift check for canonical provider @@ -1119,16 +1120,26 @@ price. Flash ships the published $0.15/$0.50 list. A live call can still ## Static Model Registry -This table describes the static registry, which `codewhale model resolve` (and -the `[model]` config surface) still use to resolve selector aliases. - -`codewhale model resolve` projects the reviewed `selections` in -`crates/config/assets/catalog_corrections.json` through -`crates/agent/src/lib.rs`. There is no independent Rust model roster. These -ordered aliases and flags are compatibility metadata, not account availability -or executable route permission. This differs from live `/models` discovery. -Use `/models` or `codewhale models` to fetch model IDs from the active API -endpoint when the endpoint supports model listing. +This table records legacy selector compatibility. Current `codewhale model +resolve`, `codewhale model list`, the picker and Runtime API use the shared +provider lake and route resolver. `models --update` refreshes the existing +catalog cache; later routes see new metadata, while dispatched requests retain +their admitted limits and price receipt. + +The reviewed `selections` in `crates/config/assets/catalog_corrections.json` +remain compatibility metadata for legacy callers. They do not establish account +availability or executable route permission. Public discovery can contain +metadata-only models; an authenticated provider roster establishes a different +fact. A same-named custom endpoint keeps its own declared limits and prices. + +`codewhale model resolve MODEL --provider PROVIDER` reports the exact wire ID, +canonical ID when declared, limits and implemented capabilities. Runtime +`GET /v1/providers/PROVIDER/models` adds source, fetch/expiry metadata, published +price structure, reasoning options, configured effective limits and the price +status. `account_access: unverified` means these facts have not established the +selected account's access. Context responses distinguish an active engine +window from the static route's resolution source, including any fallback. +See [Catalog refresh](CATALOG_REFRESH.md) for freshness and estimate boundaries. | Provider | Static registry entries | Tool calls | Registry reasoning flag | | --- | --- | --- | --- | diff --git a/docs/features.toml b/docs/features.toml index e8a09567ce..ab24f62072 100644 --- a/docs/features.toml +++ b/docs/features.toml @@ -286,7 +286,7 @@ owner = "crates/tui/src/sandbox" [[feature]] id = "providers" name = "Providers" -summary = "First-class provider routes plus bundled OpenAI-compatible host descriptors." +summary = "Provider routes with endpoint-scoped model facts, explicit limits and sourced price estimates." status = "stable" surfaces = { tui = "stable", api = "stable" } docs = "docs/PROVIDERS.md" diff --git a/scripts/catalog/models_dev_seed.lock.json b/scripts/catalog/models_dev_seed.lock.json index a850fbce9c..78dcde636d 100644 --- a/scripts/catalog/models_dev_seed.lock.json +++ b/scripts/catalog/models_dev_seed.lock.json @@ -1,14 +1,17 @@ { "source": { "url": "https://models.dev/catalog.json", - "fetched_at": "2026-09-26T09:07:10Z", - "sha256": "1e559956cfa16dad77e1fd5f1f38ae533276b397aca15c2b0ebb71d6e50e64a3" + "fetched_at": "2026-10-12T15:41:34Z", + "sha256": "fc7b3ca17b868cd3854b57df7aae683df6e62067acce54e2bdbac49b8665df53" }, "models": { - "deepseek/deepseek-v4-flash": { - "id": "deepseek/deepseek-v4-flash", - "name": "DeepSeek V4 Flash", - "family": "deepseek-flash", + "deepseek/deepseek-v4-pro-0813": { + "id": "deepseek/deepseek-v4-pro-0813", + "name": "DeepSeek V4 Pro 0813", + "description": "DeepSeek V4 Pro snapshot with million-token context and support for thinking and non-thinking modes", + "family": "deepseek-thinking", + "release_date": "2026-08-12", + "last_updated": "2026-08-22", "attachment": false, "reasoning": true, "tool_call": true, @@ -26,13 +29,106 @@ "limit": { "context": 1000000, "output": 384000 - } + }, + "license": "MIT", + "weights": [ + { + "label": "Hugging Face", + "url": "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" + } + ], + "benchmarks": [ + { + "name": "Humanity's Last Exam", + "score": 42.7, + "metric": "score", + "variant": "without tools", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" + }, + { + "name": "Humanity's Last Exam", + "score": 60, + "metric": "score", + "variant": "with tools", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" + }, + { + "name": "Terminal-Bench", + "score": 87.9, + "metric": "score", + "harness": "DeepSeek Harness minimal mode", + "variant": "max effort", + "version": "2.1", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" + }, + { + "name": "NL2Repo", + "score": 61.5, + "metric": "score", + "harness": "DeepSeek Harness minimal mode", + "variant": "max effort", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" + }, + { + "name": "CyberGym", + "score": 83.3, + "metric": "score", + "harness": "DeepSeek Harness minimal mode", + "variant": "max effort", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" + }, + { + "name": "DeepSWE", + "score": 62.7, + "metric": "score", + "harness": "DeepSeek Harness minimal mode", + "variant": "max effort", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" + }, + { + "name": "Toolathlon-Verified", + "score": 74.1, + "metric": "score", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" + }, + { + "name": "Agents' Last Exam", + "score": 25.7, + "metric": "score", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" + }, + { + "name": "AutomationBench", + "score": 31.8, + "metric": "score", + "dataset": "public", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" + }, + { + "name": "DSBench-FullStack", + "score": 71.1, + "metric": "score", + "dataset": "internal", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" + }, + { + "name": "DSBench-Hard", + "score": 67.2, + "metric": "score", + "dataset": "internal", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" + } + ] }, - "deepseek/deepseek-v4-pro": { - "id": "deepseek/deepseek-v4-pro", - "name": "DeepSeek V4 Pro", - "family": "deepseek-thinking", - "attachment": false, + "deepseek/deepseek-v4.1-flash": { + "id": "deepseek/deepseek-v4.1-flash", + "name": "DeepSeek V4.1 Flash", + "description": "DeepSeek V4.1 Flash model for reasoning and agentic coding", + "family": "deepseek-flash", + "knowledge": "2025-05", + "release_date": "2026-09-10", + "last_updated": "2026-09-10", + "attachment": true, "reasoning": true, "tool_call": true, "structured_output": true, @@ -40,7 +136,8 @@ "open_weights": true, "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" @@ -49,12 +146,307 @@ "limit": { "context": 1000000, "output": 384000 - } + }, + "license": "MIT", + "weights": [ + { + "label": "Hugging Face", + "url": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + } + ], + "benchmarks": [ + { + "name": "GPQA Diamond", + "score": 90.9, + "metric": "pass@1", + "variant": "reasoning_effort=100", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "MathArena Apex", + "score": 65.6, + "metric": "pass@1", + "variant": "reasoning_effort=100", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "Humanity's Last Exam", + "score": 36.8, + "metric": "pass@1", + "variant": "reasoning_effort=100", + "dataset": "full set", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "Humanity's Last Exam", + "score": 39.1, + "metric": "pass@1", + "variant": "reasoning_effort=100", + "dataset": "text-only subset", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "Codeforces", + "score": 3471, + "metric": "rating", + "variant": "reasoning_effort=100", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "Terminal-Bench", + "score": 90.6, + "metric": "pass@1", + "harness": "DeepSeek Harness minimal mode", + "variant": "reasoning_effort=100", + "version": "2.1", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "Terminal-Bench", + "score": 30, + "metric": "pass@1", + "harness": "DeepSeek Harness minimal mode", + "variant": "reasoning_effort=100", + "version": "3.0", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "Terminal-Bench", + "score": 31.2, + "metric": "pass@1", + "harness": "DeepSeek Harness minimal mode", + "variant": "reasoning_effort=100", + "version": "4.0", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "DeepSWE", + "score": 74.2, + "metric": "resolved", + "harness": "mini-swe-agent", + "variant": "reasoning_effort=100", + "version": "1.1", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "ProgramBench", + "score": 20.3, + "metric": "almost@1", + "harness": "DeepSeek Harness minimal mode", + "variant": "reasoning_effort=100", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "NL2Repo", + "score": 64, + "metric": "score", + "harness": "DeepSeek Harness minimal mode", + "variant": "reasoning_effort=100", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "CyberGym", + "score": 88.1, + "metric": "pass@1", + "variant": "reasoning_effort=100", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "ExploitGym", + "score": 15.3, + "metric": "pass@1", + "variant": "reasoning_effort=100", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "AutomationBench", + "score": 54.8, + "metric": "pass@1", + "variant": "reasoning_effort=100", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "Agents' Last Exam", + "score": 31.8, + "metric": "pass@1", + "variant": "reasoning_effort=100", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "SEC-Bench Pro", + "score": 62.8, + "metric": "pass@1", + "harness": "Claude Code", + "variant": "reasoning_effort=100", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "Humanity's Last Exam", + "score": 63.9, + "metric": "pass@1", + "variant": "reasoning_effort=100; with tools", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "Chartography", + "score": 78.9, + "metric": "pass@1", + "harness": "Claude Code", + "variant": "reasoning_effort=100; with tools", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "BabyVision", + "score": 89.6, + "metric": "pass@1", + "harness": "Claude Code", + "variant": "reasoning_effort=100; with tools", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "ZeroBench", + "score": 49, + "metric": "pass@5", + "harness": "Claude Code", + "variant": "reasoning_effort=100; with tools", + "dataset": "main", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "DeepSWE", + "score": 69.8, + "metric": "resolved", + "harness": "Claude Code", + "variant": "reasoning_effort=100", + "version": "1.1", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "Terminal-Bench", + "score": 88, + "metric": "pass@1", + "harness": "Claude Code", + "variant": "reasoning_effort=100", + "version": "2.1", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "DeepSWE", + "score": 65.6, + "metric": "resolved", + "harness": "Codex", + "variant": "reasoning_effort=100", + "version": "1.1", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "Terminal-Bench", + "score": 84.1, + "metric": "pass@1", + "harness": "Codex", + "variant": "reasoning_effort=100", + "version": "2.1", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "DeepSWE", + "score": 65.5, + "metric": "resolved", + "harness": "OpenCode", + "variant": "reasoning_effort=100", + "version": "1.1", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "Terminal-Bench", + "score": 85, + "metric": "pass@1", + "harness": "OpenCode", + "variant": "reasoning_effort=100", + "version": "2.1", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "DeepSWE", + "score": 66.2, + "metric": "resolved", + "harness": "Pi", + "variant": "reasoning_effort=100", + "version": "1.1", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "Terminal-Bench", + "score": 86.1, + "metric": "pass@1", + "harness": "Pi", + "variant": "reasoning_effort=100", + "version": "2.1", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "Terminal-Bench", + "score": 90.3, + "metric": "pass@1", + "harness": "mini-swe-agent", + "variant": "reasoning_effort=100", + "version": "2.1", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "DeepSWE", + "score": 72.6, + "metric": "resolved", + "harness": "DeepSeek Harness minimal mode", + "variant": "reasoning_effort=100", + "version": "1.1", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "DeepSWE", + "score": 70.5, + "metric": "resolved", + "harness": "DeepSeek Harness standard mode", + "variant": "reasoning_effort=100", + "version": "1.1", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "Terminal-Bench", + "score": 85.8, + "metric": "pass@1", + "harness": "DeepSeek Harness standard mode", + "variant": "reasoning_effort=100", + "version": "2.1", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "DeepSWE", + "score": 67.6, + "metric": "resolved", + "harness": "DeepSeek Harness PTC mode", + "variant": "reasoning_effort=100", + "version": "1.1", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + }, + { + "name": "Terminal-Bench", + "score": 85.8, + "metric": "pass@1", + "harness": "DeepSeek Harness PTC mode", + "variant": "reasoning_effort=100", + "version": "2.1", + "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + } + ] }, "xiaomi/mimo-v2.6-flash": { "id": "xiaomi/mimo-v2.6-flash", "name": "MiMo-V2.6-Flash", + "description": "MiMo Flash model for multimodal coding agents and long-context automation", "family": "mimo", + "release_date": "2026-09-22", + "last_updated": "2026-09-22", "attachment": true, "reasoning": true, "tool_call": true, @@ -74,12 +466,123 @@ "limit": { "context": 1048576, "output": 131072 - } + }, + "weights": [ + { + "label": "Hugging Face", + "url": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + } + ], + "benchmarks": [ + { + "name": "DeepSWE", + "score": 67.9, + "metric": "score", + "version": "1.1", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "ProgramBench", + "score": 26, + "metric": "score", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "MiMo Code Bench", + "score": 61.2, + "metric": "score", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "AutomationBench", + "score": 52.3, + "metric": "score", + "version": "1.0.6", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "Toolathlon-Verified", + "score": 73.6, + "metric": "score", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "Agents' Last Exam", + "score": 27.6, + "metric": "score", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "Terminal-Bench", + "score": 28.8, + "metric": "score", + "version": "4.0", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "Terminal-Bench", + "score": 87.6, + "metric": "score", + "version": "2.1", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "OSWorld-Verified", + "score": 80.8, + "metric": "score", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "JobBench", + "score": 61.2, + "metric": "score", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "CyberGym", + "score": 95.1, + "metric": "score", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "MiMo Cyber Bench", + "score": 77.2, + "metric": "score", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "ExploitGym", + "score": 6, + "metric": "score", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "ExploitBench", + "score": 25.3, + "metric": "score", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "SEC-Bench Pro", + "score": 47.5, + "metric": "score", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "MiMo VisualCoding", + "score": 71.5, + "metric": "score", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + } + ] }, "xiaomi/mimo-v2.6-pro": { "id": "xiaomi/mimo-v2.6-pro", "name": "MiMo-V2.6-Pro", + "description": "Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution", "family": "mimo", + "release_date": "2026-09-22", + "last_updated": "2026-09-22", "attachment": true, "reasoning": true, "tool_call": true, @@ -99,83 +602,152 @@ "limit": { "context": 1048576, "output": 131072 - } + }, + "weights": [ + { + "label": "Hugging Face", + "url": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Pro-RL" + } + ], + "benchmarks": [ + { + "name": "DeepSWE", + "score": 71.9, + "metric": "score", + "variant": "MiMo-V2.6 Pro comparison column", + "version": "1.1", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "ProgramBench", + "score": 26.5, + "metric": "score", + "variant": "MiMo-V2.6 Pro comparison column", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "MiMo Code Bench", + "score": 63.2, + "metric": "score", + "variant": "MiMo-V2.6 Pro comparison column", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "AutomationBench", + "score": 53.1, + "metric": "score", + "variant": "MiMo-V2.6 Pro comparison column", + "version": "1.0.6", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "Toolathlon-Verified", + "score": 76.9, + "metric": "score", + "variant": "MiMo-V2.6 Pro comparison column", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "GDPval-AA", + "score": 1673, + "metric": "Elo", + "variant": "MiMo-V2.6 Pro comparison column", + "version": "2.1", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "Agents' Last Exam", + "score": 31.6, + "metric": "score", + "variant": "MiMo-V2.6 Pro comparison column", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "Terminal-Bench", + "score": 34.9, + "metric": "score", + "variant": "MiMo-V2.6 Pro comparison column", + "version": "4.0", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "Terminal-Bench", + "score": 89.9, + "metric": "score", + "variant": "MiMo-V2.6 Pro comparison column", + "version": "2.1", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "OSWorld-Verified", + "score": 82, + "metric": "score", + "variant": "MiMo-V2.6 Pro comparison column", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "JobBench", + "score": 62, + "metric": "score", + "variant": "MiMo-V2.6 Pro comparison column", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "CyberGym", + "score": 94, + "metric": "score", + "variant": "MiMo-V2.6 Pro comparison column", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "MiMo Cyber Bench", + "score": 80.2, + "metric": "score", + "variant": "MiMo-V2.6 Pro comparison column", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "ExploitGym", + "score": 17.8, + "metric": "score", + "variant": "MiMo-V2.6 Pro comparison column", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "ExploitBench", + "score": 47.9, + "metric": "score", + "variant": "MiMo-V2.6 Pro comparison column", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "SEC-Bench Pro", + "score": 66.3, + "metric": "score", + "variant": "MiMo-V2.6 Pro comparison column", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + }, + { + "name": "MiMo VisualCoding", + "score": 72.3, + "metric": "score", + "variant": "MiMo-V2.6 Pro comparison column", + "source": "https://huggingface.co/XiaomiMiMo/MiMo-V2.6-Flash-RL" + } + ] } }, "providers": { "alibaba-coding-plan": { - "qwen3.6-flash": { - "id": "qwen3.6-flash", - "name": "Qwen3.6 Flash", - "family": "qwen3.6", - "attachment": true, - "reasoning": true, - "reasoning_options": [ - { - "type": "toggle" - } - ], - "tool_call": true, - "structured_output": true, - "temperature": true, - "open_weights": false, - "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 1000000, - "output": 65536 - }, - "cost": { - "input": 0.1875, - "output": 1.125, - "cache_write": 0.234375 - } - }, - "qwen3.7-max": { - "id": "qwen3.7-max", - "name": "Qwen3.7 Max", - "family": "qwen", - "attachment": false, - "reasoning": true, - "reasoning_options": [ - { - "type": "toggle" - } - ], - "tool_call": true, - "temperature": true, - "open_weights": false, - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 1000000, - "output": 65536 - }, - "cost": { - "input": 2.5, - "output": 7.5, - "cache_read": 0.5, - "cache_write": 3.125 - } - }, "qwen3.7-plus": { "id": "qwen3.7-plus", + "canonical_model_id": "alibaba/qwen3.7-plus", "name": "Qwen3.7 Plus", + "description": "Multimodal Qwen workhorse for long-context agents, visual inputs, and coding", "family": "qwen", + "knowledge": "2025-04", + "release_date": "2026-06-02", + "last_updated": "2026-06-02", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -211,8 +783,13 @@ "alibaba-token-plan": { "deepseek-v4-flash-0731": { "id": "deepseek-v4-flash-0731", + "canonical_model_id": "deepseek/deepseek-v4-flash-0731", "name": "DeepSeek V4 Flash 0731", + "description": "Official DeepSeek V4 Flash release with enhanced agentic capabilities and integrated DSpark speculative decoding", "family": "deepseek-flash", + "knowledge": "2025-05", + "release_date": "2026-07-31", + "last_updated": "2026-07-31", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -255,8 +832,13 @@ }, "deepseek-v4-pro": { "id": "deepseek-v4-pro", + "canonical_model_id": "deepseek/deepseek-v4-pro", "name": "DeepSeek V4 Pro", + "description": "Open MoE flagship with million-token context for coding and long agent runs", "family": "deepseek-thinking", + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -299,8 +881,12 @@ }, "glm-5.2": { "id": "glm-5.2", + "canonical_model_id": "zhipuai/glm-5.2", "name": "GLM-5.2", + "description": "Open flagship GLM for long-horizon coding agents and million-token context work", "family": "glm", + "release_date": "2026-06-13", + "last_updated": "2026-06-13", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -340,8 +926,12 @@ }, "qwen3.6-flash": { "id": "qwen3.6-flash", + "canonical_model_id": "alibaba/qwen3.6-flash", "name": "Qwen3.6 Flash", + "description": "Qwen vision-language model for visual reasoning, documents, and agent tasks", "family": "qwen3.6", + "release_date": "2026-04-27", + "last_updated": "2026-04-27", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -383,8 +973,12 @@ }, "qwen3.7-max": { "id": "qwen3.7-max", + "canonical_model_id": "alibaba/qwen3.7-max", "name": "Qwen3.7 Max", + "description": "Qwen frontier model tuned for agent frameworks, coding assistants, and long tasks", "family": "qwen", + "release_date": "2026-05-21", + "last_updated": "2026-05-21", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -424,8 +1018,13 @@ }, "qwen3.7-plus": { "id": "qwen3.7-plus", + "canonical_model_id": "alibaba/qwen3.7-plus", "name": "Qwen3.7 Plus", + "description": "Multimodal Qwen workhorse for long-context agents, visual inputs, and coding", "family": "qwen", + "knowledge": "2025-04", + "release_date": "2026-06-02", + "last_updated": "2026-06-02", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -467,8 +1066,12 @@ }, "qwen3.8-max": { "id": "qwen3.8-max", + "canonical_model_id": "alibaba/qwen3.8-max", "name": "Qwen3.8 Max", + "description": "2.4-trillion-parameter MoE flagship for coding, professional work, multimodal understanding, and long-horizon agentic workflows", "family": "qwen", + "release_date": "2026-08-03", + "last_updated": "2026-08-03", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -520,8 +1123,12 @@ }, "qwen3.8-max-preview": { "id": "qwen3.8-max-preview", + "canonical_model_id": "alibaba/qwen3.8-max-preview", "name": "Qwen3.8 Max Preview", + "description": "Preview Qwen flagship for million-token multimodal reasoning and long-horizon agentic workflows", "family": "qwen", + "release_date": "2026-07-19", + "last_updated": "2026-07-19", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -565,14 +1172,18 @@ "output": 0, "cache_read": 0, "cache_write": 0 - } + }, + "status": "deprecated" } }, "anthropic": { "claude-fable-5": { "id": "claude-fable-5", "name": "Claude Fable 5", + "description": "Claude model for creative writing, analysis, and controlled agent workflows", "family": "claude-fable", + "release_date": "2026-06-07", + "last_updated": "2026-06-09", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -614,8 +1225,13 @@ }, "claude-haiku-4-5": { "id": "claude-haiku-4-5", + "canonical_model_id": "anthropic/claude-haiku-4-5", "name": "Claude Haiku 4.5 (latest)", + "description": "Fast Claude lane for lightweight agents, office tasks, and responsive chat", "family": "claude-haiku", + "knowledge": "2025-02-28", + "release_date": "2025-10-15", + "last_updated": "2025-10-15", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -652,7 +1268,11 @@ "claude-opus-4-8": { "id": "claude-opus-4-8", "name": "Claude Opus 4.8", + "description": "Top Claude Opus tier for the hardest reasoning, coding, and long-horizon agents", "family": "claude-opus", + "knowledge": "2026-01", + "release_date": "2026-05-28", + "last_updated": "2026-05-28", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -690,12 +1310,37 @@ "output": 25, "cache_read": 0.5, "cache_write": 6.25 + }, + "experimental": { + "modes": { + "fast": { + "cost": { + "input": 10, + "output": 50, + "cache_read": 1, + "cache_write": 12.5 + }, + "provider": { + "body": { + "speed": "fast" + }, + "headers": { + "anthropic-beta": "fast-mode-2026-02-01" + } + } + } + } } }, "claude-opus-5": { "id": "claude-opus-5", + "canonical_model_id": "anthropic/claude-opus-5", "name": "Claude Opus 5", + "description": "Strongest Claude Opus model for coding, agents, and professional work", "family": "claude-opus", + "knowledge": "2026-05", + "release_date": "2026-07-24", + "last_updated": "2026-07-24", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -733,12 +1378,36 @@ "output": 25, "cache_read": 0.5, "cache_write": 6.25 + }, + "experimental": { + "modes": { + "fast": { + "cost": { + "input": 10, + "output": 50, + "cache_read": 1, + "cache_write": 12.5 + }, + "provider": { + "body": { + "speed": "fast" + }, + "headers": { + "anthropic-beta": "fast-mode-2026-02-01" + } + } + } + } } }, "claude-sonnet-4-6": { "id": "claude-sonnet-4-6", "name": "Claude Sonnet 4.6", + "description": "Claude workhorse for coding agents, careful analysis, and production cost control", "family": "claude-sonnet", + "knowledge": "2025-08-31", + "release_date": "2026-02-17", + "last_updated": "2026-03-13", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -784,7 +1453,11 @@ "claude-sonnet-5": { "id": "claude-sonnet-5", "name": "Claude Sonnet 5", + "description": "Everyday Claude agent model for coding, planning, browsing, and general work", "family": "claude-sonnet", + "knowledge": "2026-01-31", + "release_date": "2026-06-29", + "last_updated": "2026-06-30", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -831,8 +1504,12 @@ "arcee": { "trinity-large-thinking": { "id": "trinity-large-thinking", + "canonical_model_id": "arcee-ai/trinity-large-thinking", "name": "Trinity Large Thinking", + "description": "Reasoning-optimized 398B MoE agent model with extended thinking for long-horizon and multi-turn tool use", "family": "trinity", + "release_date": "2026-04-01", + "last_updated": "2026-05-28", "attachment": false, "reasoning": true, "reasoning_options": [], @@ -858,14 +1535,20 @@ "input": 0.25, "output": 0.8, "cache_read": 0.06 - } + }, + "status": "beta" } }, "deepseek": { "deepseek-flash": { "id": "deepseek-flash", + "canonical_model_id": "deepseek/deepseek-v4.1-flash", "name": "DeepSeek V4.1 Flash", + "description": "DeepSeek V4.1 Flash model for reasoning and agentic coding", "family": "deepseek-flash", + "knowledge": "2025-05", + "release_date": "2026-09-10", + "last_updated": "2026-09-10", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -904,13 +1587,19 @@ "cost": { "input": 0.15, "output": 0.6, - "cache_read": 0.003 + "cache_read": 0.003, + "reasoning": 0.6 } }, "deepseek-v4-flash": { "id": "deepseek-v4-flash", + "canonical_model_id": "deepseek/deepseek-v4.1-flash", "name": "DeepSeek V4 Flash", + "description": "DeepSeek V4.1 Flash model for reasoning and agentic coding", "family": "deepseek-flash", + "knowledge": "2025-05", + "release_date": "2026-09-10", + "last_updated": "2026-09-10", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -949,13 +1638,20 @@ "cost": { "input": 0.15, "output": 0.6, - "cache_read": 0.003 - } + "cache_read": 0.003, + "reasoning": 0.6 + }, + "status": "deprecated" }, "deepseek-v4-flash-vision-exp": { "id": "deepseek-v4-flash-vision-exp", + "canonical_model_id": "deepseek/deepseek-v4.1-flash", "name": "DeepSeek V4 Flash Vision Exp", + "description": "DeepSeek V4.1 Flash model for reasoning and agentic coding", "family": "deepseek-flash", + "knowledge": "2025-05", + "release_date": "2026-09-10", + "last_updated": "2026-09-10", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -994,13 +1690,19 @@ "cost": { "input": 0.15, "output": 0.6, - "cache_read": 0.003 - } + "cache_read": 0.003, + "reasoning": 0.6 + }, + "status": "deprecated" }, "deepseek-v4-pro": { "id": "deepseek-v4-pro", + "canonical_model_id": "deepseek/deepseek-v4-pro-0813", "name": "DeepSeek V4 Pro", + "description": "DeepSeek V4 Pro snapshot with million-token context and support for thinking and non-thinking modes", "family": "deepseek-thinking", + "release_date": "2026-08-12", + "last_updated": "2026-08-22", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -1036,62 +1738,22 @@ "output": 393216 }, "cost": { - "input": 0.435, - "output": 0.87, - "cache_read": 0.003625 - } - } - }, - "fireworks-ai": { - "accounts/fireworks/models/deepseek-v4-pro": { - "id": "accounts/fireworks/models/deepseek-v4-pro", - "name": "DeepSeek V4 Pro", - "family": "deepseek-thinking", - "attachment": false, - "reasoning": true, - "reasoning_options": [ - { - "type": "toggle" - }, - { - "type": "effort", - "values": [ - "high", - "max" - ] - } - ], - "interleaved": { - "field": "reasoning_content" - }, - "tool_call": true, - "structured_output": true, - "temperature": true, - "open_weights": true, - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 1000000, - "output": 384000 - }, - "cost": { - "input": 1.2, - "output": 1.2, - "cache_read": 0.6 + "input": 0.66, + "output": 1.98, + "cache_read": 0.022, + "reasoning": 1.98 } } }, "meta": { "muse-spark-1.1": { "id": "muse-spark-1.1", + "canonical_model_id": "meta/muse-spark-1.1", "name": "Muse Spark 1.1", + "description": "Muse Spark is a natively multimodal reasoning model with support for tool-use, visual chain of thought, and multi-agent orchestration.", "family": "muse", + "release_date": "2026-04-08", + "last_updated": "2026-07-09", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -1133,8 +1795,12 @@ }, "muse-spark-1.2": { "id": "muse-spark-1.2", + "canonical_model_id": "meta/muse-spark-1.2", "name": "Muse Spark 1.2", + "description": "Muse Spark 1.2 is a coding-focused update to Muse Spark 1.1 with improvements in code generation, complex debugging, codebase understanding, and end-to-end developer workflows.", "family": "muse", + "release_date": "2026-08-05", + "last_updated": "2026-08-05", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -1177,8 +1843,12 @@ }, "muse-spark-1.2-contributor": { "id": "muse-spark-1.2-contributor", + "canonical_model_id": "meta/muse-spark-1.2", "name": "Muse Spark 1.2 Contributor", + "description": "Muse Spark 1.2 is a coding-focused update to Muse Spark 1.1 with improvements in code generation, complex debugging, codebase understanding, and end-to-end developer workflows.", "family": "muse", + "release_date": "2026-08-05", + "last_updated": "2026-08-05", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -1224,7 +1894,10 @@ "MiniMax-M2.7": { "id": "MiniMax-M2.7", "name": "MiniMax-M2.7", + "description": "Open MiniMax flagship for coding agents, office automation, and complex environments", "family": "minimax", + "release_date": "2026-03-18", + "last_updated": "2026-03-18", "attachment": false, "reasoning": true, "reasoning_options": [], @@ -1253,7 +1926,10 @@ "MiniMax-M2.7-highspeed": { "id": "MiniMax-M2.7-highspeed", "name": "MiniMax-M2.7-highspeed", + "description": "Low-latency M2.7 variant for interactive coding plans and agent loops", "family": "minimax", + "release_date": "2026-03-18", + "last_updated": "2026-03-18", "attachment": false, "reasoning": true, "reasoning_options": [], @@ -1282,7 +1958,10 @@ "MiniMax-M3": { "id": "MiniMax-M3", "name": "MiniMax-M3", + "description": "MiniMax multimodal model for long-context coding, perception, and agent planning", "family": "minimax", + "release_date": "2026-06-01", + "last_updated": "2026-06-25", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -1310,7 +1989,23 @@ "cost": { "input": 0.3, "output": 1.2, - "cache_read": 0.06 + "cache_read": 0.06, + "tiers": [ + { + "input": 0.6, + "output": 2.4, + "cache_read": 0.12, + "tier": { + "type": "context", + "size": 512000 + } + } + ], + "context_over_200k": { + "input": 0.6, + "output": 2.4, + "cache_read": 0.12 + } } } }, @@ -1318,7 +2013,11 @@ "kimi-k2.6": { "id": "kimi-k2.6", "name": "Kimi K2.6", + "description": "Multimodal Kimi workhorse for agent loops, coding tasks, and visual context", "family": "kimi-k2", + "knowledge": "2025-01", + "release_date": "2026-04-21", + "last_updated": "2026-04-21", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -1355,8 +2054,13 @@ }, "kimi-k2.7-code": { "id": "kimi-k2.7-code", + "canonical_model_id": "moonshotai/kimi-k2.7-code", "name": "Kimi K2.7 Code", + "description": "Coding-focused Kimi model, stronger on long-horizon repo work with less overthinking", "family": "kimi-k2", + "knowledge": "2025-01", + "release_date": "2026-06-12", + "last_updated": "2026-06-12", "attachment": true, "reasoning": true, "reasoning_options": [], @@ -1389,8 +2093,13 @@ }, "kimi-k2.7-code-highspeed": { "id": "kimi-k2.7-code-highspeed", + "canonical_model_id": "moonshotai/kimi-k2.7-code", "name": "Kimi K2.7 Code HighSpeed", + "description": "Coding-focused Kimi model, stronger on long-horizon repo work with less overthinking", "family": "kimi-k2", + "knowledge": "2025-01", + "release_date": "2026-06-12", + "last_updated": "2026-06-12", "attachment": true, "reasoning": true, "reasoning_options": [], @@ -1423,14 +2132,15 @@ }, "kimi-k3": { "id": "kimi-k3", + "canonical_model_id": "moonshotai/kimi-k3", "name": "Kimi K3", + "description": "Multimodal Kimi model with 1M context and toggleable max-effort thinking for long-horizon agent work", "family": "kimi-k3", + "release_date": "2026-07-16", + "last_updated": "2026-07-16", "attachment": true, "reasoning": true, "reasoning_options": [ - { - "type": "toggle" - }, { "type": "effort", "values": [ @@ -1464,61 +2174,21 @@ "cost": { "input": 3, "output": 15, - "cache_read": 0.3 + "cache_read": 0.3, + "cache_write": 3 } } }, "novita-ai": { - "deepseek/deepseek-v4-flash": { - "id": "deepseek/deepseek-v4-flash", - "name": "DeepSeek V4 Flash", - "family": "deepseek-flash", - "attachment": false, - "reasoning": true, - "reasoning_options": [ - { - "type": "toggle" - }, - { - "type": "effort", - "values": [ - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - } - ], - "interleaved": { - "field": "reasoning_content" - }, - "tool_call": true, - "structured_output": true, - "temperature": true, - "open_weights": true, - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 1048576, - "output": 393216 - }, - "cost": { - "input": 0.14, - "output": 0.28, - "cache_read": 0.028 - } - }, "deepseek/deepseek-v4-pro": { "id": "deepseek/deepseek-v4-pro", + "canonical_model_id": "deepseek/deepseek-v4-pro", "name": "DeepSeek V4 Pro", + "description": "Open MoE flagship with million-token context for coding and long agent runs", "family": "deepseek-thinking", + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -1529,9 +2199,8 @@ "type": "effort", "values": [ "low", - "medium", "high", - "xhigh" + "max" ] } ], @@ -1565,7 +2234,11 @@ "gpt-5.3-codex": { "id": "gpt-5.3-codex", "name": "GPT-5.3 Codex", + "description": "Coding-optimized GPT model for repository edits, reviews, and agentic software work", "family": "gpt-codex", + "knowledge": "2025-08-31", + "release_date": "2026-02-05", + "last_updated": "2026-02-05", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -1603,12 +2276,32 @@ "input": 1.75, "output": 14, "cache_read": 0.175 + }, + "experimental": { + "modes": { + "fast": { + "cost": { + "input": 3.5, + "output": 28, + "cache_read": 0.35 + }, + "provider": { + "body": { + "service_tier": "priority" + } + } + } + } } }, "gpt-5.5": { "id": "gpt-5.5", "name": "GPT-5.5", + "description": "Default frontier GPT for coding, computer use, research, and knowledge work", "family": "gpt", + "knowledge": "2025-12-01", + "release_date": "2026-04-23", + "last_updated": "2026-04-23", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -1645,13 +2338,49 @@ "cost": { "input": 5, "output": 30, - "cache_read": 0.5 + "cache_read": 0.5, + "tiers": [ + { + "input": 10, + "output": 45, + "cache_read": 1, + "tier": { + "type": "context", + "size": 272000 + } + } + ], + "context_over_200k": { + "input": 10, + "output": 45, + "cache_read": 1 + } + }, + "experimental": { + "modes": { + "fast": { + "cost": { + "input": 12.5, + "output": 75, + "cache_read": 1.25 + }, + "provider": { + "body": { + "service_tier": "priority" + } + } + } + } } }, "gpt-5.5-pro": { "id": "gpt-5.5-pro", "name": "GPT-5.5 Pro", + "description": "Highest-accuracy GPT-5.5 tier for slower, precision-heavy reasoning and coding", "family": "gpt-pro", + "knowledge": "2025-12-01", + "release_date": "2026-04-23", + "last_updated": "2026-04-23", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -1685,13 +2414,32 @@ }, "cost": { "input": 30, - "output": 180 + "output": 180, + "tiers": [ + { + "input": 60, + "output": 270, + "tier": { + "type": "context", + "size": 272000 + } + } + ], + "context_over_200k": { + "input": 60, + "output": 270 + } } }, "gpt-5.6": { "id": "gpt-5.6", + "canonical_model_id": "openai/gpt-5.6-sol", "name": "GPT-5.6", + "description": "Frontier GPT-5.6 model for complex professional work, coding, and agentic workflows", "family": "gpt-sol", + "knowledge": "2026-02-16", + "release_date": "2026-07-09", + "last_updated": "2026-07-09", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -1730,13 +2478,62 @@ "input": 4, "output": 20, "cache_read": 0.4, - "cache_write": 5 + "cache_write": 5, + "tiers": [ + { + "input": 8, + "output": 30, + "cache_read": 0.8, + "cache_write": 10, + "tier": { + "type": "context", + "size": 272000 + } + } + ], + "context_over_200k": { + "input": 8, + "output": 30, + "cache_read": 0.8, + "cache_write": 10 + } + }, + "experimental": { + "modes": { + "fast": { + "cost": { + "input": 8, + "output": 40, + "cache_read": 0.8, + "cache_write": 10 + }, + "provider": { + "body": { + "service_tier": "priority" + } + } + }, + "pro": { + "provider": { + "body": { + "reasoning": { + "mode": "pro" + } + } + } + } + } } }, "gpt-5.6-luna": { "id": "gpt-5.6-luna", + "canonical_model_id": "openai/gpt-5.6-luna", "name": "GPT-5.6 Luna", + "description": "Cost-efficient GPT-5.6 model for fast, high-volume workloads", "family": "gpt-luna", + "knowledge": "2026-02-16", + "release_date": "2026-07-09", + "last_updated": "2026-07-09", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -1775,13 +2572,62 @@ "input": 0.2, "output": 1.2, "cache_read": 0.02, - "cache_write": 0.25 + "cache_write": 0.25, + "tiers": [ + { + "input": 0.4, + "output": 1.8, + "cache_read": 0.04, + "cache_write": 0.5, + "tier": { + "type": "context", + "size": 272000 + } + } + ], + "context_over_200k": { + "input": 0.4, + "output": 1.8, + "cache_read": 0.04, + "cache_write": 0.5 + } + }, + "experimental": { + "modes": { + "fast": { + "cost": { + "input": 0.4, + "output": 2.4, + "cache_read": 0.04, + "cache_write": 0.5 + }, + "provider": { + "body": { + "service_tier": "priority" + } + } + }, + "pro": { + "provider": { + "body": { + "reasoning": { + "mode": "pro" + } + } + } + } + } } }, "gpt-5.6-sol": { "id": "gpt-5.6-sol", + "canonical_model_id": "openai/gpt-5.6-sol", "name": "GPT-5.6 Sol", + "description": "Frontier GPT-5.6 model for complex professional work, coding, and agentic workflows", "family": "gpt-sol", + "knowledge": "2026-02-16", + "release_date": "2026-07-09", + "last_updated": "2026-07-09", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -1820,13 +2666,62 @@ "input": 4, "output": 20, "cache_read": 0.4, - "cache_write": 5 + "cache_write": 5, + "tiers": [ + { + "input": 8, + "output": 30, + "cache_read": 0.8, + "cache_write": 10, + "tier": { + "type": "context", + "size": 272000 + } + } + ], + "context_over_200k": { + "input": 8, + "output": 30, + "cache_read": 0.8, + "cache_write": 10 + } + }, + "experimental": { + "modes": { + "fast": { + "cost": { + "input": 8, + "output": 40, + "cache_read": 0.8, + "cache_write": 10 + }, + "provider": { + "body": { + "service_tier": "priority" + } + } + }, + "pro": { + "provider": { + "body": { + "reasoning": { + "mode": "pro" + } + } + } + } + } } }, "gpt-5.6-terra": { "id": "gpt-5.6-terra", + "canonical_model_id": "openai/gpt-5.6-terra", "name": "GPT-5.6 Terra", + "description": "Balanced GPT-5.6 model for capable, cost-efficient everyday work", "family": "gpt-terra", + "knowledge": "2026-02-16", + "release_date": "2026-07-09", + "last_updated": "2026-07-09", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -1865,15 +2760,64 @@ "input": 2, "output": 12, "cache_read": 0.2, - "cache_write": 2.5 + "cache_write": 2.5, + "tiers": [ + { + "input": 4, + "output": 18, + "cache_read": 0.4, + "cache_write": 5, + "tier": { + "type": "context", + "size": 272000 + } + } + ], + "context_over_200k": { + "input": 4, + "output": 18, + "cache_read": 0.4, + "cache_write": 5 + } + }, + "experimental": { + "modes": { + "fast": { + "cost": { + "input": 4, + "output": 24, + "cache_read": 0.4, + "cache_write": 5 + }, + "provider": { + "body": { + "service_tier": "priority" + } + } + }, + "pro": { + "provider": { + "body": { + "reasoning": { + "mode": "pro" + } + } + } + } + } } } }, "openrouter": { "deepseek/deepseek-v4-flash": { "id": "deepseek/deepseek-v4-flash", + "canonical_model_id": "deepseek/deepseek-v4-flash", "name": "DeepSeek V4 Flash", + "description": "Fast DeepSeek V4 lane for economical reasoning, coding, and long-context work", "family": "deepseek-flash", + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -1905,18 +2849,23 @@ }, "limit": { "context": 1048576, - "output": 384000 + "output": 943718 }, "cost": { - "input": 0.04704, - "output": 0.09408, - "cache_read": 0.009408 + "input": 0.03, + "output": 1.28, + "cache_read": 0.03 } }, "deepseek/deepseek-v4-pro": { "id": "deepseek/deepseek-v4-pro", + "canonical_model_id": "deepseek/deepseek-v4-pro", "name": "DeepSeek V4 Pro", + "description": "Open MoE flagship with million-token context for coding and long agent runs", "family": "deepseek-thinking", + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -1951,14 +2900,17 @@ "output": 384000 }, "cost": { - "input": 0.418818, - "output": 0.837636, - "cache_read": 0.034902 + "input": 0.436044, + "output": 0.872088, + "cache_read": 0.036337 } }, "dots-studio/dots-3-note-preview:free": { "id": "dots-studio/dots-3-note-preview:free", "name": "Dots3-Note Preview (free)", + "description": "Multimodal reasoning model for visual analysis, planning, and tool use", + "release_date": "2026-08-14", + "last_updated": "2026-08-14", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -1990,8 +2942,12 @@ }, "minimax/minimax-m3": { "id": "minimax/minimax-m3", + "canonical_model_id": "minimax/MiniMax-M3", "name": "MiniMax-M3", + "description": "MiniMax multimodal model for long-context coding, perception, and agent planning", "family": "minimax", + "release_date": "2026-06-01", + "last_updated": "2026-06-01", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -2025,8 +2981,12 @@ }, "qwen/qwen3.6-35b-a3b": { "id": "qwen/qwen3.6-35b-a3b", + "canonical_model_id": "alibaba/qwen3.6-35b-a3b", "name": "Qwen3.6 35B-A3B", + "description": "Open multimodal Qwen MoE for local agents that need vision, audio, and code", "family": "qwen", + "release_date": "2026-04-17", + "last_updated": "2026-04-17", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -2050,18 +3010,22 @@ }, "limit": { "context": 262144, - "output": 235929 + "output": 65536 }, "cost": { - "input": 0.15, - "output": 1, - "cache_read": 0.05 + "input": 0.1, + "output": 0.95, + "cache_read": 0.1 } }, "qwen/qwen3.6-flash": { "id": "qwen/qwen3.6-flash", + "canonical_model_id": "alibaba/qwen3.6-flash", "name": "Qwen3.6 Flash", + "description": "Qwen vision-language model for visual reasoning, documents, and agent tasks", "family": "qwen3.6", + "release_date": "2026-04-27", + "last_updated": "2026-04-27", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -2090,13 +3054,34 @@ "cost": { "input": 0.1875, "output": 1.125, - "cache_write": 0.234375 + "cache_write": 0.234375, + "tiers": [ + { + "input": 0.75, + "output": 3, + "cache_write": 0.9375, + "tier": { + "type": "context", + "size": 256000 + } + } + ], + "context_over_200k": { + "input": 0.75, + "output": 3, + "cache_write": 0.9375 + } } }, "qwen/qwen3.6-plus": { "id": "qwen/qwen3.6-plus", + "canonical_model_id": "alibaba/qwen3.6-plus", "name": "Qwen3.6 Plus", + "description": "Earlier Qwen multimodal workhorse for million-token agent and document tasks", "family": "qwen", + "knowledge": "2025-04", + "release_date": "2026-04-02", + "last_updated": "2026-04-02", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -2125,13 +3110,34 @@ "cost": { "input": 0.325, "output": 1.95, - "cache_write": 0.40625 + "cache_write": 0.40625, + "tiers": [ + { + "input": 1.3, + "output": 3.9, + "cache_write": 1.625, + "tier": { + "type": "context", + "size": 256000 + } + } + ], + "context_over_200k": { + "input": 1.3, + "output": 3.9, + "cache_write": 1.625 + } } }, "qwen/qwen3.7-plus": { "id": "qwen/qwen3.7-plus", + "canonical_model_id": "alibaba/qwen3.7-plus", "name": "Qwen3.7 Plus", + "description": "Multimodal Qwen workhorse for long-context agents, visual inputs, and coding", "family": "qwen", + "knowledge": "2025-04", + "release_date": "2026-06-02", + "last_updated": "2026-06-02", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -2160,13 +3166,35 @@ "input": 0.32, "output": 1.28, "cache_read": 0.064, - "cache_write": 0.4 + "cache_write": 0.4, + "tiers": [ + { + "input": 0.96, + "output": 3.84, + "cache_read": 0.192, + "cache_write": 1.2, + "tier": { + "type": "context", + "size": 256000 + } + } + ], + "context_over_200k": { + "input": 0.96, + "output": 3.84, + "cache_read": 0.192, + "cache_write": 1.2 + } } }, "qwen/qwen3.8-flash": { "id": "qwen/qwen3.8-flash", + "canonical_model_id": "alibaba/qwen3.8-flash", "name": "Qwen3.8 Flash", + "description": "Qwen vision-language model for visual reasoning, documents, and agent tasks", "family": "qwen", + "release_date": "2026-08-26", + "last_updated": "2026-08-26", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -2198,14 +3226,17 @@ "cost": { "input": 0.15, "output": 0.47, - "cache_read": 0.016, - "cache_write": 0.2 + "cache_read": 0.016 } }, "z-ai/glm-5.2": { "id": "z-ai/glm-5.2", + "canonical_model_id": "zhipuai/glm-5.2", "name": "GLM-5.2", + "description": "Open flagship GLM for long-horizon coding agents and million-token context work", "family": "glm", + "release_date": "2026-06-13", + "last_updated": "2026-06-13", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -2237,18 +3268,22 @@ }, "limit": { "context": 1048576, - "output": 131072 + "output": 943718 }, "cost": { - "input": 0.6496, - "output": 2.0416, - "cache_read": 0.12064 + "input": 0.06, + "output": 7, + "cache_read": 0.059 } }, "z-ai/glm-5.3": { "id": "z-ai/glm-5.3", + "canonical_model_id": "zhipuai/glm-5.3", "name": "GLM-5.3", + "description": "Flagship GLM model for hybrid reasoning, coding, and agentic engineering", "family": "glm", + "release_date": "2026-08-14", + "last_updated": "2026-08-14", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -2274,19 +3309,23 @@ ] }, "limit": { - "context": 1310720, - "output": 131072 + "context": 1048576, + "output": 943718 }, "cost": { - "input": 0.3794, - "output": 1.1924, - "cache_read": 0.07046 + "input": 0.079, + "output": 4.8, + "cache_read": 0.059 } }, "z-ai/glm-5.3-flash": { "id": "z-ai/glm-5.3-flash", + "canonical_model_id": "zhipuai/glm-5.3-flash", "name": "GLM-5.3-Flash", + "description": "GLM vision model for visual reasoning, documents, and multimodal agents", "family": "glm-flash", + "release_date": "2026-08-26", + "last_updated": "2026-08-26", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -2314,21 +3353,26 @@ ] }, "limit": { - "context": 1310720, - "output": 131072 + "context": 1048576, + "output": 943718 }, "cost": { - "input": 0.04, - "output": 0.5, - "cache_read": 0.015 + "input": 0.0232, + "output": 1.2, + "cache_read": 0.0232 } } }, "siliconflow": { "deepseek-ai/DeepSeek-V4-Flash": { "id": "deepseek-ai/DeepSeek-V4-Flash", + "canonical_model_id": "deepseek/deepseek-v4-flash", "name": "DeepSeek V4 Flash", + "description": "Fast DeepSeek V4 lane for economical reasoning, coding, and long-context work", "family": "deepseek-flash", + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -2365,8 +3409,13 @@ }, "deepseek-ai/DeepSeek-V4-Pro": { "id": "deepseek-ai/DeepSeek-V4-Pro", + "canonical_model_id": "deepseek/deepseek-v4-pro", "name": "DeepSeek V4 Pro", + "description": "Open MoE flagship with million-token context for coding and long agent runs", "family": "deepseek-thinking", + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -2406,6 +3455,10 @@ "step-3.5-flash": { "id": "step-3.5-flash", "name": "Step 3.5 Flash", + "description": "StepFun flash lane for quick multimodal reasoning and coding assistance", + "knowledge": "2025-01", + "release_date": "2026-01-29", + "last_updated": "2026-06-15", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -2445,6 +3498,10 @@ "step-3.5-flash-2603": { "id": "step-3.5-flash-2603", "name": "Step 3.5 Flash 2603", + "description": "StepFun flash model for efficient multimodal reasoning, coding, and tool use", + "knowledge": "2025-01", + "release_date": "2026-04-02", + "last_updated": "2026-04-02", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -2483,7 +3540,12 @@ }, "step-3.7-flash": { "id": "step-3.7-flash", + "canonical_model_id": "stepfun/step-3.7-flash", "name": "Step 3.7 Flash", + "description": "Newer StepFun flash model for faster agents, coding, and multimodal prompts", + "knowledge": "2026-03-01", + "release_date": "2026-05-29", + "last_updated": "2026-06-29", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -2525,7 +3587,11 @@ }, "step-5-preview": { "id": "step-5-preview", + "canonical_model_id": "stepfun/step-5-preview", "name": "Step 5 Preview", + "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", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -2566,56 +3632,14 @@ } } }, - "togetherai": { - "deepseek-ai/DeepSeek-V4-Pro": { - "id": "deepseek-ai/DeepSeek-V4-Pro", - "name": "DeepSeek V4 Pro", - "family": "deepseek", - "attachment": false, - "reasoning": true, - "reasoning_options": [ - { - "type": "toggle" - }, - { - "type": "effort", - "values": [ - "high", - "max" - ] - } - ], - "interleaved": { - "field": "reasoning_content" - }, - "tool_call": true, - "structured_output": true, - "temperature": true, - "open_weights": true, - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 512000, - "output": 384000 - }, - "cost": { - "input": 1.74, - "output": 3.48, - "cache_read": 0.2 - } - } - }, "xai": { "grok-4.3": { "id": "grok-4.3", "name": "Grok 4.3", + "description": "xAI's Grok for chat, coding, agentic tools, and lower hallucination risk", "family": "grok", + "release_date": "2026-04-17", + "last_updated": "2026-04-17", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -2650,13 +3674,33 @@ "cost": { "input": 1.25, "output": 2.5, - "cache_read": 0.2 + "cache_read": 0.2, + "tiers": [ + { + "input": 2.5, + "output": 5, + "cache_read": 0.4, + "tier": { + "type": "context", + "size": 200000 + } + } + ], + "context_over_200k": { + "input": 2.5, + "output": 5, + "cache_read": 0.4 + } } }, "grok-4.5": { "id": "grok-4.5", + "canonical_model_id": "xai/grok-4.5", "name": "Grok 4.5", + "description": "xAI's Grok model for chat, coding, agentic tools, and lower hallucination risk", "family": "grok", + "release_date": "2026-07-08", + "last_updated": "2026-07-08", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -2690,13 +3734,34 @@ "cost": { "input": 2, "output": 6, - "cache_read": 0.3 + "cache_read": 0.3, + "tiers": [ + { + "input": 4, + "output": 12, + "cache_read": 0.6, + "tier": { + "type": "context", + "size": 200000 + } + } + ], + "context_over_200k": { + "input": 4, + "output": 12, + "cache_read": 0.6 + } } }, "grok-4.6": { "id": "grok-4.6", + "canonical_model_id": "xai/grok-4.6", "name": "Grok 4.6", + "description": "xAI's frontier model for long-running agents, coding, knowledge work, and visual projects", "family": "grok", + "knowledge": "2026-02-01", + "release_date": "2026-08-12", + "last_updated": "2026-08-12", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -2731,13 +3796,34 @@ "cost": { "input": 2, "output": 6, - "cache_read": 0.5 + "cache_read": 0.5, + "tiers": [ + { + "input": 4, + "output": 12, + "cache_read": 1, + "tier": { + "type": "context", + "size": 200000 + } + } + ], + "context_over_200k": { + "input": 4, + "output": 12, + "cache_read": 1 + } } }, "grok-4.7": { "id": "grok-4.7", + "canonical_model_id": "xai/grok-4.7", "name": "Grok 4.7", + "description": "xAI's frontier model for long-running agents, coding, knowledge work, and visual projects", "family": "grok", + "knowledge": "2026-05", + "release_date": "2026-09-21", + "last_updated": "2026-09-21", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -2772,7 +3858,23 @@ "cost": { "input": 2, "output": 6, - "cache_read": 0.5 + "cache_read": 0.5, + "tiers": [ + { + "input": 4, + "output": 12, + "cache_read": 1, + "tier": { + "type": "context", + "size": 200000 + } + } + ], + "context_over_200k": { + "input": 4, + "output": 12, + "cache_read": 1 + } } } }, @@ -2780,7 +3882,11 @@ "mimo-v2.5": { "id": "mimo-v2.5", "name": "MiMo-V2.5", + "description": "Open MiMo model for multimodal coding agents and long-context automation", "family": "mimo", + "knowledge": "2024-12", + "release_date": "2026-04-22", + "last_updated": "2026-06-24", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -2818,7 +3924,11 @@ "mimo-v2.5-pro": { "id": "mimo-v2.5-pro", "name": "MiMo-V2.5-Pro", + "description": "Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution", "family": "mimo", + "knowledge": "2024-12", + "release_date": "2026-04-22", + "last_updated": "2026-06-24", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -2853,7 +3963,10 @@ "mimo-v2.6-flash": { "id": "mimo-v2.6-flash", "name": "MiMo-V2.6-Flash", + "description": "MiMo Flash model for multimodal coding agents and long-context automation", "family": "mimo", + "release_date": "2026-09-22", + "last_updated": "2026-09-22", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -2891,7 +4004,10 @@ "mimo-v2.6-pro": { "id": "mimo-v2.6-pro", "name": "MiMo-V2.6-Pro", + "description": "MiMo Pro model for multimodal coding agents and long-context automation", "family": "mimo", + "release_date": "2026-09-22", + "last_updated": "2026-09-22", "attachment": true, "reasoning": true, "reasoning_options": [ @@ -2931,7 +4047,10 @@ "glm-5-turbo": { "id": "glm-5-turbo", "name": "GLM-5-Turbo", + "description": "Faster GLM-5 lane for coding agents that need lower latency", "family": "glm", + "release_date": "2026-03-16", + "last_updated": "2026-03-16", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -2967,8 +4086,12 @@ }, "glm-5.1": { "id": "glm-5.1", + "canonical_model_id": "zhipuai/glm-5.1", "name": "GLM-5.1", + "description": "Strong GLM coding model for agentic engineering, terminals, and repository generation", "family": "glm", + "release_date": "2026-04-07", + "last_updated": "2026-04-07", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -3004,8 +4127,12 @@ }, "glm-5.2": { "id": "glm-5.2", + "canonical_model_id": "zhipuai/glm-5.2", "name": "GLM-5.2", + "description": "Open flagship GLM for long-horizon coding agents and million-token context work", "family": "glm", + "release_date": "2026-06-13", + "last_updated": "2026-06-13", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -3045,8 +4172,12 @@ }, "glm-5.3": { "id": "glm-5.3", + "canonical_model_id": "zhipuai/glm-5.3", "name": "GLM-5.3", + "description": "Flagship GLM model for long-horizon coding, agents, and complex project delivery", "family": "glm", + "release_date": "2026-08-14", + "last_updated": "2026-08-14", "attachment": false, "reasoning": true, "reasoning_options": [ @@ -3087,8 +4218,12 @@ }, "glm-5.3-flash": { "id": "glm-5.3-flash", + "canonical_model_id": "zhipuai/glm-5.3-flash", "name": "GLM-5.3-Flash", + "description": "Native multimodal GLM model for efficient coding and long-horizon agent tasks", "family": "glm-flash", + "release_date": "2026-08-26", + "last_updated": "2026-08-26", "attachment": true, "reasoning": true, "reasoning_options": [ diff --git a/scripts/catalog/models_dev_seed.toml b/scripts/catalog/models_dev_seed.toml index 025d0e80b1..53613bc961 100644 --- a/scripts/catalog/models_dev_seed.toml +++ b/scripts/catalog/models_dev_seed.toml @@ -24,23 +24,21 @@ url = "https://models.dev/catalog.json" [meta] about = "Offline fallback Models.dev-shaped catalog snapshot for Codewhale (#3385, demoted by #4188, generated since #6396)." schema = "Matches crates/config/src/models_dev.rs ModelsDevCatalog ({ models, providers })." +compatibility_fallback = "Legacy Together and Fireworks defaults and previously reviewed curated IDs retain their dated metadata when absent upstream; they do not prove current availability. See each curated reason. Refreshed provider/catalog rows and explicit configuration remain authoritative." role = "NOT a competing source of truth. Preferred metadata is the live Models.dev catalog published into ProviderLake (#4187). This asset is used only when live/cache rows are unavailable (offline startup, failed refresh, or empty cache)." source = "Rows are projected unedited from Models.dev onto the fields models_dev.rs reads; provider headers, id mapping, defaults and canonical joins come from scripts/catalog/models_dev_seed.toml." corrections = "Deliberate holds (withheld prices, clamped limits) are not in this file. They live in crates/config/assets/catalog_corrections.json and apply to this seed and to live refresh alike." default_rows = "Each provider's `default: true` wire id equals that provider's built-in DEFAULT_*_MODEL so RouteResolver::new() and the descriptor stay in agreement when offline." pending_release_metadata = "GLM-5.3 is live on the Z.ai Coding Plan (docs.z.ai/devpack/overview and docs.z.ai/devpack/latest-model, recorded 2026-08-13) and is the default direct Z.ai model (DEFAULT_ZAI_MODEL); explicit GLM-5.2 selections keep their own id. First-party wire id is GLM-5.3; OpenRouter mirror is z-ai/glm-5.3. Capability/limit/dialect values still inherit from GLM-5.2 until Z.ai publishes distinct 5.3 numbers. Pricing stays absent: Coding Plan publishes credit multipliers, not a USD PAYG row we can stand behind. Z.ai may auto-route GLM-5.2/GLM-5.1 requests to GLM-5.3 on their side; Codewhale still sends the selected picker id. Do not send a [1m] suffix. Scope stays first-party Z.ai plus the OpenRouter mirror; add third-party gateway rows only against that gateway's own published roster." -# Canonical `models` entries. The two bare DeepSeek keys are the canonical ids -# every hosted DeepSeek `base_model`, the route layer and pricing name; -# renaming them is a canonical-identity migration, not a seed refresh. +# Canonical entries retain the upstream lab/model identities; provider wire +# ids and configured model selections stay in their own provider rows. [[canonical]] -key = "deepseek-v4-pro" -upstream = "deepseek/deepseek-v4-pro" +key = "deepseek/deepseek-v4-pro-0813" [[canonical]] -key = "deepseek-v4-flash" -upstream = "deepseek/deepseek-v4-flash" +key = "deepseek/deepseek-v4.1-flash" [[canonical]] key = "xiaomi/mimo-v2.6-pro" @@ -56,10 +54,10 @@ npm = "@ai-sdk/openai-compatible" env = ["DEEPSEEK_API_KEY"] default = "deepseek-flash" models = [ - { id = "deepseek-v4-pro", base_model = "deepseek-v4-pro" }, - { id = "deepseek-v4-flash", base_model = "deepseek-v4-flash" }, - { id = "deepseek-v4-flash-vision-exp", base_model = "deepseek-v4-flash" }, - { id = "deepseek-flash", base_model = "deepseek-flash" }, + { id = "deepseek-v4-pro", base_model = "deepseek/deepseek-v4-pro-0813" }, + { id = "deepseek-v4-flash", base_model = "deepseek/deepseek-v4.1-flash" }, + { id = "deepseek-v4-flash-vision-exp", base_model = "deepseek/deepseek-v4.1-flash" }, + { id = "deepseek-flash", base_model = "deepseek/deepseek-v4.1-flash" }, ] [[providers]] @@ -169,8 +167,6 @@ models = [ { id = "qwen3.8-max", from = "alibaba-token-plan" }, { id = "qwen3.8-max-preview", from = "alibaba-token-plan" }, "qwen3.7-plus", - "qwen3.7-max", - "qwen3.6-flash", { id = "deepseek-v4-pro", from = "alibaba-token-plan" }, { id = "deepseek-v4-flash-0731", from = "alibaba-token-plan" }, { id = "glm-5.2", from = "alibaba-token-plan" }, @@ -188,8 +184,6 @@ models = [ { id = "qwen3.8-max", from = "alibaba-token-plan" }, { id = "qwen3.8-max-preview", from = "alibaba-token-plan" }, "qwen3.7-plus", - "qwen3.7-max", - "qwen3.6-flash", { id = "deepseek-v4-pro", from = "alibaba-token-plan" }, { id = "deepseek-v4-flash-0731", from = "alibaba-token-plan" }, { id = "glm-5.2", from = "alibaba-token-plan" }, @@ -249,8 +243,8 @@ npm = "@openrouter/ai-sdk-provider" env = ["OPENROUTER_API_KEY"] default = "deepseek/deepseek-v4-pro" models = [ - { id = "deepseek/deepseek-v4-pro", base_model = "deepseek-v4-pro" }, - { id = "deepseek/deepseek-v4-flash", base_model = "deepseek-v4-flash" }, + { id = "deepseek/deepseek-v4-pro", base_model = "deepseek/deepseek-v4-pro" }, + { id = "deepseek/deepseek-v4-flash", base_model = "deepseek/deepseek-v4-flash" }, "qwen/qwen3.8-flash", "qwen/qwen3.6-flash", "qwen/qwen3.6-plus", @@ -272,7 +266,7 @@ npm = "@ai-sdk/openai-compatible" env = ["TOGETHER_API_KEY"] default = "deepseek-ai/DeepSeek-V4-Pro" models = [ - { id = "deepseek-ai/DeepSeek-V4-Pro", base_model = "deepseek-v4-pro" }, + { id = "deepseek-ai/DeepSeek-V4-Pro", base_model = "deepseek-v4-pro", curated = true }, { id = "deepseek-ai/DeepSeek-V4-Flash", base_model = "deepseek-v4-flash", curated = true }, ] @@ -285,7 +279,7 @@ npm = "@ai-sdk/openai-compatible" env = ["FIREWORKS_API_KEY"] default = "accounts/fireworks/models/deepseek-v4-pro" models = [ - { id = "accounts/fireworks/models/deepseek-v4-pro", base_model = "deepseek-v4-pro" }, + { id = "accounts/fireworks/models/deepseek-v4-pro", base_model = "deepseek-v4-pro", curated = true }, ] [[providers]] @@ -297,8 +291,7 @@ npm = "@ai-sdk/openai-compatible" env = ["NOVITA_API_KEY"] default = "deepseek/deepseek-v4-pro" models = [ - { id = "deepseek/deepseek-v4-pro", base_model = "deepseek-v4-pro" }, - { id = "deepseek/deepseek-v4-flash", base_model = "deepseek-v4-flash" }, + { id = "deepseek/deepseek-v4-pro", base_model = "deepseek/deepseek-v4-pro" }, ] [[providers]] @@ -309,8 +302,8 @@ npm = "@ai-sdk/openai-compatible" env = ["SILICONFLOW_API_KEY"] default = "deepseek-ai/DeepSeek-V4-Pro" models = [ - { id = "deepseek-ai/DeepSeek-V4-Pro", base_model = "deepseek-v4-pro" }, - { id = "deepseek-ai/DeepSeek-V4-Flash", base_model = "deepseek-v4-flash" }, + { id = "deepseek-ai/DeepSeek-V4-Pro", base_model = "deepseek/deepseek-v4-pro" }, + { id = "deepseek-ai/DeepSeek-V4-Flash", base_model = "deepseek/deepseek-v4-flash" }, ] [[providers]] @@ -393,3 +386,43 @@ reasoning = true tool_call = true modalities = { input = ["text"], output = ["text"] } limit = { context = 128000 } + +[[curated]] +provider = "together" +id = "deepseek-ai/DeepSeek-V4-Pro" +reason = "The existing default is absent from the public 2026-10-12 catalog. Retain prior bundled compatibility metadata without changing selected wire IDs or claiming current account access. Prior snapshot: 2026-09-26T09:07:10Z; sha256 1e559956cfa16dad77e1fd5f1f38ae533276b397aca15c2b0ebb71d6e50e64a3. New dated models come from the live catalog." + +[curated.row] +name = "DeepSeek V4 Pro" +family = "deepseek" +attachment = false +reasoning = true +reasoning_options = [{ "type" = "toggle" }, { "type" = "effort", "values" = ["high", "max"] }] +interleaved = { "field" = "reasoning_content" } +tool_call = true +structured_output = true +temperature = true +open_weights = true +modalities = { "input" = ["text"], "output" = ["text"] } +limit = { "context" = 512000, "output" = 384000 } +cost = { "input" = 1.74, "output" = 3.48, "cache_read" = 0.2 } + +[[curated]] +provider = "fireworks" +id = "accounts/fireworks/models/deepseek-v4-pro" +reason = "The existing default is absent from the public 2026-10-12 catalog. Retain prior bundled compatibility metadata without changing selected wire IDs or claiming current account access. Prior snapshot: 2026-09-26T09:07:10Z; sha256 1e559956cfa16dad77e1fd5f1f38ae533276b397aca15c2b0ebb71d6e50e64a3. New dated models come from the live catalog." + +[curated.row] +name = "DeepSeek V4 Pro" +family = "deepseek-thinking" +attachment = false +reasoning = true +reasoning_options = [{ "type" = "toggle" }, { "type" = "effort", "values" = ["high", "max"] }] +interleaved = { "field" = "reasoning_content" } +tool_call = true +structured_output = true +temperature = true +open_weights = true +modalities = { "input" = ["text"], "output" = ["text"] } +limit = { "context" = 1000000, "output" = 384000 } +cost = { "input" = 1.2, "output" = 1.2, "cache_read" = 0.6 } diff --git a/scripts/catalog_models_dev.py b/scripts/catalog_models_dev.py index 8240b04f06..b553127e6c 100755 --- a/scripts/catalog_models_dev.py +++ b/scripts/catalog_models_dev.py @@ -858,8 +858,14 @@ def cmd_seed_lock(args: argparse.Namespace) -> None: def cmd_seed_render(args: argparse.Namespace) -> None: spec = load_seed_spec(Path(args.spec)) lock = read_json_file(Path(args.lock)) - reviewed = read_json_file(Path(args.corrections)).get("reviewed") - rendered = render_seed(spec, lock, reviewed) + corrections = read_json_file(Path(args.corrections)) + providers = {provider["id"] for provider in spec["providers"]} + models = {(provider["id"], model["id"]) for provider in spec["providers"] for model in provider["models"]} + missing = [str(rule.get("provider")) for rule in corrections.get("providers", []) if rule.get("provider") not in providers] + missing.extend(f"{row.get('provider')}/{row.get('id')}" for row in corrections.get("models", []) if (row.get("provider"), row.get("id")) not in models) + if missing: + die("seed corrections name absent targets: " + ", ".join(missing)) + rendered = render_seed(spec, lock, corrections.get("reviewed")) target = Path(args.out) if args.check: current = target.read_text(encoding="utf-8") if target.is_file() else "" diff --git a/scripts/catalog_models_dev_test.py b/scripts/catalog_models_dev_test.py index aeee64873d..8994b90c1e 100755 --- a/scripts/catalog_models_dev_test.py +++ b/scripts/catalog_models_dev_test.py @@ -340,7 +340,8 @@ def test_reviewed_source_is_exactly_embedded_without_mutating_lock(self) -> None data = json.loads(SEED.read_text()) source = json.loads((ROOT / "crates/config/assets/catalog_corrections.json").read_text()) self.assertEqual(data["_reviewed"], source["reviewed"]) - self.assertIn("fetched 2026-09-26", data["_meta"]["upstream"]) + lock = json.loads((ROOT / "scripts/catalog/models_dev_seed.lock.json").read_text()) + self.assertIn(f"fetched {lock['source']['fetched_at']}", data["_meta"]["upstream"]) self.assertEqual(len(data["_reviewed"]["public_models"]), 78) def test_reviewed_malformed_and_duplicate_public_facts_are_refused(self) -> None: diff --git a/web/data/agent-docs.generated.json b/web/data/agent-docs.generated.json index e0c6c32975..95bbcf2165 100644 --- a/web/data/agent-docs.generated.json +++ b/web/data/agent-docs.generated.json @@ -48,8 +48,8 @@ "sources": [ { "path": "docs/PROVIDERS.md", - "sha256": "6d578f231ebdb823023bf233f1163026a72ed636e2c377385e1e83e77efc28c6", - "content": "# Provider Registry\n\n> 阅读简体中文版:[zh_hans/PROVIDERS.md](zh_hans/PROVIDERS.md)\n\nSpeech recognition, synthesis, translation and video have separate execution\npaths. See [media models](MEDIA_MODELS.md) for current support and reviewed\nupstream candidates; a chat catalogue row alone does not add a media backend.\n\nThis registry describes provider behavior that is wired into the current\nCodewhale codebase. It is intentionally conservative: shipped entries are\nlimited to provider IDs, config keys, auth paths, base URLs, model resolution,\nand capability metadata that the code already knows about.\n\nDeepSeek remains the default provider, but every entry in `ProviderKind::ALL`\nis a first-class selectable provider route. `ALL` is the catalog/picker\nsurface — one identity per vendor. Dual-wire dialect kinds (`*Anthropic`, e.g.\n`deepseek-anthropic`) and the Model Studio plan variants stay on the enum for\nserde and `provider_for_kind` but are deliberately **not** catalog rows:\na plan is `mode`/`base_url` and a dialect is `wire = openai|anthropic` on the\nprimary provider config (`crates/config/src/provider_kind.rs:221-226`). Hosted\nroutes, generic OpenAI-compatible endpoints, the OpenAI Codex/ChatGPT route,\nnative Anthropic, and local runtimes all run the same terminal harness against\nthe selected provider/model/base URL.\n\nA host reached over plain Chat Completions is an ordinary named provider,\nnot a `ProviderKind`: enum variants are reserved for distinct *wires*\n(Anthropic Messages, Codex Responses, Google thought signatures). Any such\nhost is a `[providers.]` table with a base URL, a model, and a key env\n(`docs/CONFIGURATION.md`); `/provider` and `/setup` keep a \"paste a Base URL\nand a key\" path for exactly this. Offerings come from live `GET /v1/models`\nplus the Codewhale catalog rather than a compiled roster (#5350, #6289).\n\nKnown-good hosts. These ship as bundled descriptor rows in\n`crates/config/assets/provider_descriptors.json` (compiled in by\n`crates/config/src/descriptors.rs`): each row says how to reach the host — wire,\nbase URL, key env, aliases — while model ids stay live from `GET /v1/models`\nand the Codewhale catalog; the example model is only a bootstrap hint. Verify\nagainst the vendor's own docs before trusting any value here:\n\n| Host | Base URL | Example models | API key env |\n| --- | --- | --- | --- |\n| SenseNova | `https://token.sensenova.cn/v1` | `deepseek-v4-flash` | `SENSENOVA_API_KEY` |\n| Baseten | `https://inference.baseten.co/v1` | `deepseek-ai/DeepSeek-V4-Pro` | `BASETEN_API_KEY` |\n| Groq | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | `GROQ_API_KEY` |\n| Cerebras | `https://api.cerebras.ai/v1` | `llama-3.3-70b` | `CEREBRAS_API_KEY` |\n| Command Code | `https://api.commandcode.ai/provider/v1` | `deepseek/deepseek-v4-flash` | `COMMAND_CODE_API_KEY` |\n| Alibaba Model Studio (DashScope) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `qwen3.8-flash` | `DASHSCOPE_API_KEY` |\n| AICraft | `https://aicraftapi.com/v1` | `claude-4.6-sonnet`; DeepSeek / Claude / Gemini / Qwen / GLM / MiniMax / Doubao families | `AICRAFT_API_KEY` |\n| Tsubasa | `https://api.tsubasa.sh/v1` | `tsubasa-pro`, `tsubasa-fast` (32,768-token context) | `TSUBASA_API_KEY` |\n| Cheaper Inference | `https://api.cheaperinference.com/v1` | `gpt-5.4-mini`, `claude-sonnet-5`, `gemini-3.1-pro` | `CHEAPER_INFERENCE_API_KEY` |\n| Yolo-Auto | `https://yolo-auto.com/v1` | `qwen3.8-flash`; `yolo` / `yolo-small` | `YOLO_AUTO_API_KEY` |\n\nAICraft's roster spans DeepSeek, Anthropic Claude, Google Gemini, Qwen, GLM,\nMiniMax and Doubao ids on its OpenAI-compatible endpoint. The authority is\n`GET https://aicraftapi.com/v1/models` with your key — pick a model from that\nlist, not from this table.\nTsubasa implements only `GET /v1/models` and Chat Completions. Its two public\nmodel ids share a 32,768-token context, smaller than the 128,000 tokens Codewhale assumes\nfor an unknown model, so set it on the route after saving:\n`codewhale config set providers.tsubasa.context_window 32768`.\n\nCheaper Inference is an OpenAI-compatible gateway with one key for models from\nseveral labs. Model ids are bare, such as `gpt-5.4-mini` or `claude-sonnet-5`.\nPricing varies by model and route; consult the provider’s current catalog.\nThe authority is `GET https://api.cheaperinference.com/v1/models` with your key.\nDocs: .\n\nOpenCode Zen and OpenCode Go are first-class provider routes, configured like\nany other provider below; they are not part of this table. In `/provider`,\ntype to filter the list (letters not bound to a row action); `Ctrl+T` probes the\nselected row's `/models` and records reachability only (a 2xx is not\nmodel-ready).\n\nSources to keep in sync:\n\n- `crates/config/assets/provider_descriptors.json` - built-in and compatible-host\n labels, defaults, aliases, key-env lists, config/secret slots, and credential\n guidance. `crates/config/build.rs` generates immutable typed views and the\n existing constant projections from this one data owner.\n- `crates/config/src/provider_kind.rs` and `src/lib.rs` - legacy identity serde,\n config schema, environment precedence and Rust route/auth behavior.\n- `crates/tui/src/config.rs` - captures and verifies exact configured provider\n identities and keeps provider-specific credential and route policy in Rust.\n- `crates/config/assets/catalog_corrections.json` `reviewed` - intrinsic facts,\n scoped selector aliases, completion references and pure transport metadata.\n The existing seed renderer embeds this reviewed supplement in Models.dev.\n- `crates/agent/src/lib.rs` - compatibility projection of those shared selector\n rows, used by `codewhale model resolve` (and the `[model]` config surface).\n `codewhale model list` reads the resolved provider lake instead, so a single\n list shows bundled, live and configured rows together.\n- `config.example.toml` and `docs/CONFIGURATION.md` - user-facing config\n examples and environment variable reference.\n- `scripts/check-provider-registry.py` - drift check for canonical provider\n IDs, live TUI provider IDs, TOML table names, static registry rows, and\n documented defaults.\n\n## Captured Provider Identity\n\nPresentation names, labels, aliases, and historical wire tags come from\n`provider_descriptors.json`. `ProviderKind` remains the Rust-owned intrinsic\ncredential, protocol, and region distinction. A configured route captures both\nits kind and exact table key; a custom table named `openai` stays custom and\ndoes not acquire OpenAI credentials or protocol rules from its name. Case is\nsignificant for custom keys.\n\nDeepSeek China has three preserved representations: `deepseek_c_n` in released\nTUI serde wrappers, `deepseek-cn` as its captured route ID, and `deepseek_cn` as\nits config leaf. Its intrinsic kind is DeepSeek, while its table and endpoint\nstay distinct.\n\nAn old custom route without an additive provider ID can resume only with a\nverified root `base_url` migration into the active `providers.custom` table.\nThat private receipt is bound to the parsed table generation. A table-only,\nprofile-only, conflicting, or later replaced table cannot establish the\nmissing identity; an explicit empty ID is refused. Writes reuse the existing\nlocked config mutation and undo comparison, obtain the fresh migration receipt\nunder that lock, and verify the captured table before changing a leaf.\n\nIn-flight requests retain their captured identity, endpoint, and credential\ngeneration. Health lookup cannot treat an opaque credential reference as ready\nmerely because an earlier request used the same authentication class.\n\n## Provider Selection\n\nWith no saved model, no `default_text_model`, and no `CODEWHALE_MODEL` or\nprovider-specific model variable, a fresh install runs `deepseek-flash` on the\nDeepSeek provider. Precedence is the active provider's configured default, then\nits catalog default — so an explicit `default_text_model` is not silently\noverridden by whichever model the shipped catalog lists first.\n\nRefresh model catalogs without installing a new Codewhale release:\n\n```sh\ncodewhale models --update\ncodewhale models --update --provider openai\ncodewhale models --provider openai --json\n```\n\n`models --update` (also `--refresh`) updates the shared Models.dev metadata\nand calls the existing `/models` endpoint for each configured provider with\nits own credentials. `--provider ID` restricts the refresh to that exact\nprovider, including named custom endpoints. It makes no inference requests\nand never changes the saved provider or model. A command-line API key is\nconfined to the active provider; other routes are reported as skipped for\nthat invocation.\n\nPlain `models` lists the active provider's saved catalog without provider requests.\nFor ChatGPT, it validates the selected registration locally before reading\nthat account's saved roster. Successful refreshes are saved under Codewhale's\ncatalog directory and used by the model/provider pickers. Cache files are\nscoped to provider identity and endpoint; a failed refresh preserves prior\nrows. Text output reports source, last successful fetch time (Unix seconds),\nand freshness. `--update --json` adds per-source receipts and aggregate counts;\npartial failures return a nonzero exit code after writing those receipts.\nOrdinary `models --json` keeps its model-array format. Bundled/configured\nfallbacks are not proof that an account can use every listed model.\n\n### Sign in with ChatGPT\n\nThe `openai-codex` provider ID now uses OpenAI's official open-source\n[Sign in with ChatGPT flow](https://developers.openai.com/siwc/token-sharing-open-source).\nSign in and refresh the selected account's catalog:\n\n```sh\ncodewhale auth chatgpt\ncodewhale models --update --provider openai-codex\ncodewhale --provider openai-codex\n```\n\nSign-in opens your system browser, uses a loopback callback and PKCE, validates\nthe returned identity, and stores the issued registration and renewable tokens\nin Codewhale's protected credential storage. Permission to use your ChatGPT\nplan is separate from identity sign-in. A declined or missing plan grant stops\ninference; choose another provider explicitly if you want another billing path.\n`codewhale auth chatgpt-revoke` signs out of the Codewhale-owned session.\nThis experimental implementation retains one active registration. A picker\nfor multiple saved ChatGPT accounts is not implemented yet.\n\nModel discovery uses your granted bearer token at\n`GET https://api.openai.com/v1/models`; inference uses the public\n`POST https://api.openai.com/v1/responses` endpoint. The model picker keeps\nOpenAI's display labels and order and lists only entries with `visibility: list`.\nThe catalog contains no credentials and is bound to the verified issuer,\nissued client ID, and account subject. Another account or workspace never\ninherits those model choices. Missing, invalid, or older-than-one-day rosters\nare reported explicitly and provide no account entitlement evidence. Run the\nrefresh command after signing in. Neither model discovery nor ordinary listing\nstarts an inference request. Imported Codex CLI tokens and legacy process-token\nvariables cannot authorize this official plan route.\n\nEligible requests consume your ChatGPT plan or credits. ChatGPT Plus's\nfive-hour allowance is shared across apps; each app receives no separate\nallowance. The documented five-hour limit does not apply to Pro. App-specific\nlimits can also apply. Review limits and access in\n[ChatGPT usage settings](https://chatgpt.com/settings/usage). Codewhale does\nnot silently switch to an API key or another provider when a limit is reached.\n\nThis is an OpenAI preview for open-source/local apps. It does not grant access\nto ChatGPT conversation history. Requests stream with `store: false` and\n`stream: true`, while Codewhale retains its own session history and tools.\nOpenAI-hosted image generation, file search, Code Interpreter, native computer\nuse, hosted MCP/connectors, and Responses `tool_search` are unavailable on this\nroute; Codewhale's own tools use supported function/custom tool calls.\nThe ChatGPT preview requests one function call at a time.\nSee [preview limitations](https://developers.openai.com/siwc/token-sharing-open-source/preview-limitations).\nPaid or remotely hosted applications require the\n[commercial partner interest process](https://openai.com/form/sign-in-with-chatgpt-interest/);\nthis local integration is not commercial approval.\n\nThe canonical provider IDs are the entries of `ProviderKind::ALL`\n(`crates/config/src/provider_kind.rs`), in that order:\n\n`deepseek`, `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`,\n`openrouter`, `orcarouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `arcee`,\n`siliconflow-CN`, `moonshot`, `sglang`, `vllm`, `ollama`, `ollama-cloud`, `huggingface`,\n`together`, `qianfan`, `openai-codex`, `anthropic`, `openmodel`, `zai`,\n`stepfun`, `minimax`, `deepinfra`, `sakana`, `longcat`, `opencode-go`,\n`opencode-zen`, `meta`, `xai`, `mistral`, `telecomjs`, `modelstudio-token-plan`, `modelscope`,\n`google`, `edenai`, `zenmux`, `csdn`, `concentrate`, `codewhale`, and `custom`.\n\n`deepseek-anthropic` is *not* on this list — it is a wire dialect of\n`deepseek`, reached with `wire = \"anthropic\"`, not a separate route to select.\n\nUse any of these surfaces to select a provider:\n\n- CLI: `codewhale --provider `\n- TUI: `/provider ` or the provider picker\n- Env: `CODEWHALE_PROVIDER=`; `DEEPSEEK_PROVIDER=` is the legacy alias\n- Config: `provider = \"\"`\n\n`deepseek-cn`, `deepseek_china`, `deepseekcn`, and `deepseek-china` are accepted\nas legacy aliases for `deepseek`. They do not select a different official host;\nDeepSeek uses the same official API host worldwide.\n\n`deepseek_anthropic`, `deepseek-claude`, and `deepseek_claude` select\n`deepseek-anthropic`, the opt-in DeepSeek route that speaks the Anthropic\nMessages API at `https://api.deepseek.com/anthropic`. It keeps the normal\nDeepSeek API key path but uses `x-api-key` plus `anthropic-version: 2023-06-01`\ninstead of Bearer auth. If the key already lives in official DeepSeek Harness\n(`dsh`) at `$DSH_HOME/.credentials.yaml`, grant read-only access with\n`codewhale auth external-consent --provider deepseek --mode read-only`.\nCodewhale never writes that file and only reads `DEEPSEEK_API_KEY`.\n\n`huggingface`, `hugging-face`, `hugging_face`, and `hf` all select the\nHugging Face Inference Providers route. This is the OpenAI-compatible router\npath for chat/inference, not Hub browsing, model-card inspection, uploads, or\nartifact export.\n\n`telecomjs`, `telecom-js`, `telecom_js`, `telecomjs-cn`, and `tokenhub` all\nselect the TelecomJS TokenHub route. Its authenticated `/models` catalog is\nkey-scoped and remains isolated from every other provider's live snapshot.\n\nFresh shared config writes to `~/.codewhale/config.toml`. Existing\n`~/.deepseek/config.toml` files are still read for compatibility.\n\n### Legacy Antigravity tombstone\n\nAntigravity is not a Codewhale provider and cannot be selected or run. Existing\nlegacy Antigravity provider state is recognized only as a non-runnable migration\ntombstone. Run `codewhale auth clear --provider antigravity` to forget only\nCodewhale-owned legacy configuration and consent metadata. This does not sign\nout of, revoke, read, or otherwise alter any official Google or Antigravity\nsession. For Gemini, select the supported `google` provider and supply\n`GEMINI_API_KEY`.\n\n### Wire Protocol Compatibility\n\nProvider selection is explicit. A model string prefix such as\n`deepseek-ai/...`, `deepseek/...`, `qwen/...`, or `arcee-ai/...` is a\nprovider-owned wire ID or catalog namespace hint under the selected provider.\nIt is not a provider switch and must not be treated as proof that the route is\nDeepSeek, OpenRouter, or any other provider.\n\nSet the route with `provider = \"\"`, `CODEWHALE_PROVIDER=`, or\n`codewhale --provider `. Set the request model with `CODEWHALE_MODEL`, a\nprovider-specific model env var, top-level `default_text_model`, or\n`[providers.].model`. Set the endpoint with `CODEWHALE_BASE_URL`, a\nprovider-specific base URL env var, or `[providers.
].base_url`. Set auth\nwith `codewhale auth set --provider `, `[providers.
].api_key`, or\nthe listed provider env vars.\n\n| Provider ID | TOML table | Wire protocol | Auth env vars |\n| --- | --- | --- | --- |\n| `deepseek` | `[providers.deepseek]` | Model-aware: Responses default (`deepseek-flash`); Chat Completions (`deepseek-v4-pro`) | `DEEPSEEK_API_KEY` |\n| `deepseek-anthropic` | `[providers.deepseek_anthropic]` | Anthropic Messages | `DEEPSEEK_API_KEY` |\n| `nvidia-nim` | `[providers.nvidia_nim]` | OpenAI Chat Completions | `NVIDIA_API_KEY`, `NVIDIA_NIM_API_KEY` |\n| `openai` | `[providers.openai]` | OpenAI Chat Completions | `OPENAI_API_KEY` |\n| `atlascloud` | `[providers.atlascloud]` | OpenAI Chat Completions | `ATLASCLOUD_API_KEY` |\n| `wanjie-ark` | `[providers.wanjie_ark]` | OpenAI Chat Completions | `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, `WANJIE_MAAS_API_KEY` |\n| `volcengine` | `[providers.volcengine]` | OpenAI Chat Completions | `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, `ARK_API_KEY` |\n| `openrouter` | `[providers.openrouter]` | OpenAI Chat Completions | `OPENROUTER_API_KEY` |\n| `xiaomi-mimo` | `[providers.xiaomi_mimo]` | OpenAI Chat Completions | `XIAOMI_MIMO_TOKEN_PLAN_API_KEY`, `MIMO_TOKEN_PLAN_API_KEY`, `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, `MIMO_API_KEY` |\n| `novita` | `[providers.novita]` | OpenAI Chat Completions | `NOVITA_API_KEY` |\n| `fireworks` | `[providers.fireworks]` | OpenAI Chat Completions | `FIREWORKS_API_KEY` |\n| `siliconflow` | `[providers.siliconflow]` | OpenAI Chat Completions | `SILICONFLOW_API_KEY` |\n| `arcee` | `[providers.arcee]` | OpenAI Chat Completions | `ARCEE_API_KEY` |\n| `siliconflow-CN` | `[providers.siliconflow_cn]` | OpenAI Chat Completions | `SILICONFLOW_API_KEY` |\n| `moonshot` | `[providers.moonshot]` | OpenAI Chat Completions | `MOONSHOT_API_KEY`, `KIMI_API_KEY` |\n| `sglang` | `[providers.sglang]` | OpenAI Chat Completions | `SGLANG_API_KEY` |\n| `vllm` | `[providers.vllm]` | OpenAI Chat Completions | `VLLM_API_KEY` |\n| `ollama` | `[providers.ollama]` | Local OpenAI-compatible Chat Completions | `OLLAMA_API_KEY` (optional; only for an authenticated local route) |\n| `ollama-cloud` | `[providers.ollama_cloud]` | Hosted OpenAI-compatible Chat Completions | `OLLAMA_CLOUD_API_KEY`, `OLLAMA_API_KEY` |\n| `huggingface` | `[providers.huggingface]` | OpenAI Chat Completions | `HUGGINGFACE_API_KEY`, `HF_TOKEN` |\n| `modelscope` | `[providers.modelscope]` | OpenAI Chat Completions | `MODELSCOPE_API_KEY` |\n| `together` | `[providers.together]` | OpenAI Chat Completions | `TOGETHER_API_KEY` |\n| `qianfan` | `[providers.qianfan]` | OpenAI Chat Completions | `QIANFAN_API_KEY`, `BAIDU_QIANFAN_API_KEY` |\n| `openai-codex` | `[providers.openai_codex]` | OpenAI Responses | Official Sign in with ChatGPT (`codewhale auth chatgpt`) with a validated Codewhale-owned plan grant |\n| `anthropic` | `[providers.anthropic]` | Anthropic Messages | `ANTHROPIC_API_KEY` or `codewhale auth claude` |\n| `openmodel` | `[providers.openmodel]` | Anthropic Messages | `OPENMODEL_API_KEY` |\n| `zai` | `[providers.zai]` | OpenAI Chat Completions | `ZAI_API_KEY`, `Z_AI_API_KEY` |\n| `stepfun` | `[providers.stepfun]` | OpenAI Chat Completions | `STEPFUN_API_KEY`, `STEP_API_KEY` |\n| `minimax` | `[providers.minimax]` | OpenAI Chat Completions | `MINIMAX_API_KEY` |\n| `deepinfra` | `[providers.deepinfra]` | OpenAI Chat Completions | `DEEPINFRA_API_KEY`, `DEEPINFRA_TOKEN` |\n| `sakana` | `[providers.sakana]` | OpenAI Chat Completions | `FUGU_API_KEY`, `SAKANA_API_KEY` |\n| `longcat` | `[providers.longcat]` | OpenAI Chat Completions | `LONGCAT_API_KEY` |\n| `opencode-go` | `[providers.opencode_go]` | OpenAI Chat Completions | `OPENCODE_GO_API_KEY` |\n| `opencode-zen` | `[providers.opencode_zen]` | Model-aware: OpenAI Responses, Anthropic Messages, or OpenAI Chat Completions | `OPENCODE_ZEN_API_KEY`, `OPENCODE_API_KEY` |\n| `meta` | `[providers.meta]` | OpenAI Chat Completions | `META_MODEL_API_KEY`, `MODEL_API_KEY` |\n| `telecomjs` | `[providers.telecomjs]` | OpenAI Chat Completions | `TELECOMJS_API_KEY` |\n| `xai` | `[providers.xai]` | OpenAI Chat Completions | `XAI_API_KEY` |\n| `mistral` | `[providers.mistral]` | OpenAI Chat Completions | `MISTRAL_API_KEY` |\n| `google` | `[providers.google]` | OpenAI Chat Completions (official Gemini OpenAI-compat route; captures and replays thought signatures on tool calls) | `GOOGLE_API_KEY`, `GEMINI_API_KEY` |\n| `edenai` | `[providers.edenai]` | OpenAI Chat Completions | `EDENAI_API_KEY` |\n| `zenmux` | `[providers.zenmux]` | OpenAI Chat Completions | `ZENMUX_API_KEY` |\n| `csdn` | `[providers.csdn]` | OpenAI Chat Completions | `CSDN_API_KEY` |\n| `concentrate` | `[providers.concentrate]` | OpenAI Responses (`/v1/responses`) | `CONCENTRATE_API_KEY` |\n| `codewhale` | `[providers.codewhale]` | Model-aware: OpenAI Chat Completions (`/v1/chat/completions`) or Anthropic Messages (`/v1/messages`), chosen per model by the account catalog | `CODEWHALE_API_KEY` |\n| `modelstudio-token-plan` | `[providers.modelstudio_token_plan]` | OpenAI Chat Completions | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` |\n| `modelstudio-token-plan-anthropic` | `[providers.modelstudio_token_plan_anthropic]` | Anthropic Messages | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` |\n| `modelstudio-coding-plan` | `[providers.modelstudio_coding_plan]` | OpenAI Chat Completions | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` |\n| `modelstudio-coding-plan-anthropic` | `[providers.modelstudio_coding_plan_anthropic]` | Anthropic Messages | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` |\n\nDefault base URLs and models for each route are listed in the shipped provider\ntable below. The wire protocol values above are derived from\n`crates/config/src/provider.rs`: `ChatCompletions` is the default,\n`openai-codex` overrides to `Responses`; `deepseek-anthropic`, `anthropic`, and\n`openmodel` override to `AnthropicMessages`; `opencode-zen` resolves the\nprotocol from the selected model's curated offering; and `deepseek` is\nmodel-aware — the shipped default `deepseek-flash` (and legacy\n`deepseek-v4-flash`) rides the Responses endpoint while `deepseek-v4-pro`\nstays on Chat Completions.\n\n## Auth And Env Rules\n\nFor hosted providers, `codewhale auth set --provider ` saves an API key for\nthat provider. API-key environment variables are fallback inputs after saved\nconfig and keyring credentials; an explicit process-level `--api-key` still\nwins for that launch.\n\nFor base URL and model selection, prefer:\n\n- `CODEWHALE_BASE_URL` / `CODEWHALE_MODEL` for the active provider.\n- Provider-specific base URL/model env vars when listed below.\n- `DEEPSEEK_BASE_URL`, `DEEPSEEK_MODEL`, and `DEEPSEEK_DEFAULT_TEXT_MODEL` as\n legacy aliases.\n\nNon-local `http://` base URLs are rejected unless\n`DEEPSEEK_ALLOW_INSECURE_HTTP=1` is set. Loopback HTTP URLs are allowed for\nself-hosted runtimes.\n\n## Custom DeepSeek-Compatible Endpoints\n\nMost custom DeepSeek-compatible deployments can use an existing provider ID.\nDo not create `[providers.deepseek_custom]`; the provider table names are fixed.\nInstead, choose the closest shipped route and override its endpoint/model:\n\n- DeepSeek-compatible hosted API: keep `provider = \"deepseek\"` and set\n `[providers.deepseek].base_url` plus `[providers.deepseek].model`, or launch\n with `DEEPSEEK_BASE_URL` and `DEEPSEEK_MODEL`.\n- Generic OpenAI-compatible gateway: use `provider = \"openai\"` with\n `[providers.openai].base_url` plus `[providers.openai].model`, or launch with\n `OPENAI_BASE_URL` and `OPENAI_MODEL`.\n- Multiple named OpenAI-compatible gateways, or local routes you want to pin\n from an AgentProfile, can use a custom table such as\n `[providers.lm-studio] kind = \"openai-compatible\"` and select it with\n `provider = \"lm-studio\"` or a profile `provider = \"lm-studio\"`.\n- Local OpenAI-compatible runtimes: use `provider = \"vllm\"`, `\"sglang\"`, or\n `\"ollama\"` with the matching provider-specific base URL/model values.\n\nExample user config for a DeepSeek-compatible host:\n\n```toml\nprovider = \"deepseek\"\n\n[providers.deepseek]\napi_key = \"YOUR_API_KEY\"\nbase_url = \"https://your-provider.example/v1\"\nmodel = \"deepseek-ai/DeepSeek-V4-Pro\"\n```\n\nExample user config for a generic gateway:\n\n```toml\nprovider = \"openai\"\n\n[providers.openai]\napi_key = \"YOUR_GATEWAY_API_KEY\"\nbase_url = \"https://gateway.example/v1\"\nmodel = \"your-deepseek-compatible-model\"\n```\n\nAlibaba Cloud Model Studio (Bailian / DashScope) is a first-class provider as\nof v0.9.4 with two plan profiles: Token Plan (Personal / Team) and Coding Plan.\nBoth plans expose an OpenAI-compatible Chat Completions endpoint and an\nAnthropic-compatible Messages endpoint.\n\n**Token Plan** (Personal and Team share the same AP-Southeast endpoint):\n\n```toml\nprovider = \"modelstudio-token-plan\"\n\n[providers.modelstudio_token_plan]\napi_key = \"YOUR_MODELSTUDIO_API_KEY\"\n# base_url defaults to https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1\nmodel = \"qwen3.8-max\" # or qwen3.8-max-preview | qwen3.7-plus | qwen3.7-max |\n # qwen3.6-flash | deepseek-v4-pro | deepseek-v4-flash-0731 |\n # glm-5.2\n```\n\n**Coding Plan** (separate international endpoint):\n\n```toml\nprovider = \"modelstudio-coding-plan\"\n\n[providers.modelstudio_coding_plan]\napi_key = \"YOUR_MODELSTUDIO_API_KEY\"\n# base_url defaults to https://coding-intl.dashscope.aliyuncs.com/v1\nmodel = \"qwen3.8-max\"\n```\n\n**Anthropic-compatible dialect** — both plans also expose a native Anthropic\nMessages path. Select it with the `-anthropic` provider suffix:\n\n```toml\nprovider = \"modelstudio-token-plan-anthropic\"\n\n[providers.modelstudio_token_plan_anthropic]\napi_key = \"YOUR_MODELSTUDIO_API_KEY\"\n# base_url defaults to https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic\nmodel = \"qwen3.8-max\"\n```\n\nCreate or copy a Model Studio API key from the\n[Bailian console](https://bailian.console.aliyun.com/). The API key is shared\nacross all four provider IDs above; only the base URL and wire protocol differ.\n\n**Thinking / reasoning.** Reasoning surfaces in the TUI's Thinking view on both\ndialects, per Model Studio's\n[deep-thinking docs](https://www.alibabacloud.com/help/en/model-studio/deep-thinking).\n\nOn the OpenAI-compatible routes the top-level controls are **route- and\nmodel-specific**, and Codewhale fails closed: they are sent only when the\nconfigured `base_url` is an official Alibaba Chat Completions host\n(`*.maas.aliyuncs.com/compatible-mode/v1`, including workspace-scoped hosts, or\n`coding-intl.dashscope.aliyuncs.com/v1`). A custom `base_url` on the same\nprovider ID gets `thinking`, `enable_thinking`, `preserve_thinking`, and\n`reasoning_effort` stripped, so an arbitrary OpenAI-compatible gateway is never\nhanded Alibaba's dialect. On a verified host:\n\n- **Hybrid models** (`qwen3.7-*`, `qwen3.6-*`, `deepseek-v4*`, `glm-*`,\n `kimi-k2.6*`) get `enable_thinking`: `false` for `off`, `true` otherwise.\n- **Thinking-only models** — `qwen3.8-max` (catalogued `thinking: always_on`),\n `qwen3.8-max-preview` (effort/budget options, no toggle), and\n `kimi-k2.7-code` — get **no** enable/disable switch at all. Sending one is at\n best ignored.\n- `preserve_thinking` is sent for the models documented to accept it\n (`qwen3.7-max`/`-plus`, `qwen3.6-max-preview`/`-plus`/`-flash`, `kimi-k2.6*`,\n `kimi-k2.7-code`), so the next turn keeps the assistant's trace.\n- `reasoning_effort` is sent only for the two families with a documented ladder\n — `deepseek-v4*` and `glm-5`/`5.1`/`5.2` — mapped to `high` or `max`.\n\nReasoning streams back as `delta.reasoning_content`. It is replayed to the\nprovider on later turns only for the `preserve_thinking` models above and the\nthinking-only models; `deepseek-v3.1`, `deepseek-v3.2`, and `glm-*` history\nstays stripped pending live confirmation that DashScope accepts\n`reasoning_content` in input messages. (`deepseek-v4*` replays regardless — the\nDeepSeek thinking-mode contract requires it on every provider.)\n\nOn the Anthropic-compatible routes, thinking uses the documented\n`{\"type\":\"enabled\",\"budget_tokens\":N}` / `{\"type\":\"disabled\"}` shapes from the\n[Anthropic-compatible Messages API](https://www.alibabacloud.com/help/en/model-studio/anthropic-api-messages),\nwith `budget_tokens` derived from the effort level.\n\nDeepSeek (`deepseek-v4-pro`, `deepseek-v4-flash-0731`) and GLM (`glm-5.2`)\nmodels served by Model Studio are provider-scoped and do not collide with the\nfirst-party DeepSeek or Zhipu/Z.ai routes. Model Studio publishes no `glm-5.3`\nentry, so Codewhale does not offer one on this route.\nPay-as-you-go workspace-id templating is not yet in the built-in provider; use\na custom provider entry for that plan until a follow-up adds it.\n\nPrivate gateways with broken or intercepted certificates should use\n`SSL_CERT_FILE` with a trusted CA bundle. The legacy\n`insecure_skip_tls_verify = true` key is still parsed so `codewhale doctor` can\nreport stale configs, but provider clients reject it instead of skipping TLS\ncertificate verification.\n\nKeep `provider`, `api_key`, and `base_url` in user config or process\nenvironment. Project-local config overlays intentionally cannot set those keys,\nso a repository cannot silently redirect prompts or credentials to another\nendpoint.\n\n## Local Models (DS4, Ollama, vLLM, SGLang)\n\nSelf-hosted OpenAI-compatible runtimes are first-class routes and are keyless\nby default — set an API key only when your server requires one. Start your\nrunner, then point Codewhale at it with `--provider` / `/provider` or a config\ntable.\n\n| Runner | Default base URL | Default model | Base URL override |\n| --- | --- | --- | --- |\n| `ollama` | `http://localhost:11434/v1` | live tag from `GET /v1/models` (pre-refresh: `unknown`) | `OLLAMA_BASE_URL` |\n| `vllm` | `http://localhost:8000/v1` | `deepseek-ai/DeepSeek-V4-Pro` | `VLLM_BASE_URL` |\n| `sglang` | `http://localhost:30000/v1` | `deepseek-ai/DeepSeek-V4-Pro` | `SGLANG_BASE_URL` |\n\n### DS4 (DwarfStar)\n\n[DS4](https://github.com/antirez/ds4/tree/84cc882352757baf628a1776badf7cc54d584e28)\nserves DeepSeek V4 Flash and Pro locally\nthrough an OpenAI-compatible API. Start DS4, then open Codewhale's prefilled,\nkeyless setup form:\n\n```bash\n./ds4-server --ctx 100000 --kv-disk-dir /tmp/ds4-kv --kv-disk-space-mb 8192\ncodewhale\n# In Codewhale: /setup provider ds4\n```\n\nReview the prefilled route and press Enter to save it. The preset budgets a\n100,000-token context to match that starter command and defaults to the Flash\ncompatibility alias. Check the local route explicitly with\n`codewhale doctor --probe-local`.\n\nDS4 loads the actual GGUF when the server starts. Its `deepseek-v4-flash` and\n`deepseek-v4-pro` API ids are compatibility aliases; changing `/model` does\nnot swap the resident model. To run Pro, download the supported Pro weights\nand start `ds4-server -m ...` as described by DS4. Update\n`context_window` whenever the server's `--ctx` value changes.\n\nThe equivalent config is:\n\n```toml\nprovider = \"ds4\"\n\n[providers.ds4]\nkind = \"openai-compatible\"\nbase_url = \"http://127.0.0.1:8000/v1\"\nmodel = \"deepseek-v4-flash\"\nauth_mode = \"none\"\ncontext_window = 100000\n```\n\nCodewhale reuses its existing OpenAI-compatible transport and DeepSeek\nreasoning/tool-call shaping for DS4. It does not invent an API key, confuse an\nAPI alias with the loaded GGUF, or silently switch to a hosted DeepSeek route.\nThe pinned DS4 [agent-client contract](https://github.com/antirez/ds4/blob/84cc882352757baf628a1776badf7cc54d584e28/README.md#agent-client-usage)\ndocuments Chat Completions at `/v1`, DeepSeek thinking replay, streamed usage,\n`max_tokens`, and no strict-tool mode; Codewhale follows those exact route\nfacts instead of inheriting unsupported capabilities from a generic gateway.\nThe primary sources for model-facing behavior are DeepSeek's official\n[thinking-mode](https://api-docs.deepseek.com/guides/thinking_mode),\n[tool-call](https://api-docs.deepseek.com/guides/tool_calls), and\n[Chat Completion](https://api-docs.deepseek.com/api/create-chat-completion)\ncontracts. The pinned\n[DeepSeek Harness adapter](https://github.com/deepseek-ai/deepseek-harness/blob/47f943859bef60e4160492346772ded9b24f765a/packages/llm/llm-deepseek/README.md)\nis only a secondary implementation cross-check; it is not the API contract.\n\n### Ollama\n\n```bash\nollama serve # if not already running\nollama pull # e.g. deepseek-v4-flash, or any tag you prefer\ncodewhale --provider ollama --model \n```\n\nProvider-hinted model names are sent as-is, so `--model qwen3:8b` works with\nany tag Ollama has pulled.\n\n### Ollama Cloud\n\nOllama Cloud is a separate hosted provider. It uses the authenticated\nOpenAI-compatible `/v1/chat/completions` route and defaults to `gpt-oss:120b`:\n\n```toml\nprovider = \"ollama-cloud\"\n\n[providers.ollama_cloud]\nbase_url = \"https://ollama.com/v1\"\nmodel = \"gpt-oss:120b\"\n```\n\nCreate a key in [Ollama account settings](https://ollama.com/settings/keys),\nthen run `codewhale auth set --provider ollama-cloud`. For ambient auth,\n`OLLAMA_CLOUD_API_KEY` wins over Ollama's official `OLLAMA_API_KEY`.\n`OLLAMA_CLOUD_BASE_URL` and `OLLAMA_CLOUD_MODEL` override the Cloud defaults;\narbitrary provider-owned model IDs pass through unchanged. Local `ollama`\nremains a separate, keyless-by-default provider.\n\nCompatibility is read-only and in memory: a released config that selected\n`provider = \"ollama\"` with the exact normalized\n`[providers.ollama] base_url = \"https://ollama.com/v1\"` tuple is treated as\n`ollama-cloud` at runtime. Only that exact tuple may fall back to the legacy\n`ollama` secret slot. Codewhale does not rewrite the config, copy or delete a\nsecret, migrate neighboring paths, or make an explicit `ollama-cloud` route\nconsume the legacy slot.\n\n### vLLM\n\n```bash\nvllm serve --port 8000\n# or: python -m vllm.entrypoints.openai.api_server --model --port 8000\ncodewhale --provider vllm --model \n```\n\nvLLM's OpenAI-compatible server listens on port 8000 by default, matching\nCodewhale's `VLLM_BASE_URL`.\n\n### SGLang\n\n```bash\npython -m sglang.launch_server --model-path --port 30000\ncodewhale --provider sglang --model \n```\n\nSGLang's default port 30000 matches Codewhale's `SGLANG_BASE_URL`.\n\n### Pinning a local route in config\n\n```toml\nprovider = \"ollama\" # or \"vllm\" / \"sglang\"\n\n[providers.ollama]\nmodel = \"qwen3:8b\" # default is deepseek-v4-flash\n# base_url defaults to http://localhost:11434/v1\n```\n\nLocal models that print tool-call JSON without the wire markers: see\n[When a Local Model Prints Tool JSON](#when-a-local-model-prints-tool-json).\n\n## Credential Links\n\nProvider setup surfaces use the same typed credential metadata as onboarding,\n`/provider`, `/links`, setup receipts, and doctor output. A missing URL is\nintentional: local, OAuth-only, and user-defined routes show their supported\nconfiguration path instead of guessing a vendor page.\n\n| Provider ID | Credential or console link |\n| --- | --- |\n| `deepseek`, `deepseek-anthropic` | [DeepSeek API keys](https://platform.deepseek.com/api_keys) |\n| `nvidia-nim` | [NVIDIA NIM API keys](https://build.nvidia.com/settings/api-keys) |\n| `openai` | [OpenAI API keys](https://platform.openai.com/api-keys) |\n| `atlascloud` | [Atlas Cloud API keys](https://atlascloud.ai/docs/en/api-keys) |\n| `wanjie-ark` | [Wanjie MaaS APIKEY docs](https://docs.wanjiedata.com/maas/maas-openapi-v1.html) |\n| `volcengine` | [Volcengine Ark API keys](https://console.volcengine.com/ark/apiKey) |\n| `openrouter` | [OpenRouter keys](https://openrouter.ai/settings/keys) |\n| `xiaomi-mimo` | [Xiaomi MiMo Token Plan](https://platform.xiaomimimo.com/token-plan) |\n| `novita` | [Novita key management](https://novita.ai/en/settings/key-management) |\n| `fireworks` | [Fireworks API keys](https://fireworks.ai/api-keys) |\n| `siliconflow` | [SiliconFlow global API keys](https://cloud.siliconflow.com/account/ak) |\n| `siliconflow-CN` | [SiliconFlow China API keys](https://cloud.siliconflow.cn/account/ak) |\n| `arcee` | [Arcee API key guide](https://docs.arcee.ai/other/create-your-first-api-key) |\n| `moonshot` | [Kimi API platform keys](https://platform.kimi.ai/console/api-keys) or [Kimi Code membership console](https://www.kimi.com/code/console) |\n| `zai` | [Z.ai model API](https://z.ai/model-api) |\n| `stepfun` | [StepFun Open Platform](https://platform.stepfun.ai/) |\n| `minimax`, `minimax-anthropic` | [MiniMax interface keys](https://platform.minimax.io/user-center/basic-information/interface-key) |\n| `huggingface` | [Hugging Face tokens](https://huggingface.co/settings/tokens) |\n| `modelscope` | [ModelScope API Keys](https://modelscope.cn/my/settings/token) |\n| `deepinfra` | [DeepInfra API keys](https://deepinfra.com/dash/api_keys) |\n| `together` | [Together API keys](https://api.together.ai/settings/api-keys) |\n| `qianfan` | [Baidu Cloud access keys](https://console.bce.baidu.com/iam/#/iam/accesslist) |\n| `anthropic` | [Anthropic API keys](https://console.anthropic.com/settings/keys) |\n| `openmodel` | [OpenModel console](https://console.openmodel.ai/) ([authentication guide](https://docs.openmodel.ai/en/docs/getting-started/authentication)) |\n| `openai-codex` | Official Sign in with ChatGPT via `codewhale auth chatgpt` (eligible ChatGPT plan or credits, Codewhale-owned tokens). The `openai` API-key route has separate billing. Legacy imported Codex credentials do not authorize this route. |\n| `sglang`, `vllm` | Local OpenAI-compatible endpoints are keyless by default; configure a key only when the server requires one. |\n| `ollama` | Local Ollama is keyless by default; configure a key only when the local server requires one. |\n| `ollama-cloud` | Create an [Ollama API key](https://ollama.com/settings/keys), save it with `codewhale auth set --provider ollama-cloud`, or set `OLLAMA_CLOUD_API_KEY` / `OLLAMA_API_KEY` in that precedence order. |\n| `sakana` | [Sakana AI API keys](https://console.sakana.ai/api-keys) ([get started](https://console.sakana.ai/get-started)) |\n| `longcat` | [Meituan LongCat platform](https://longcat.chat/platform) |\n| `opencode-go` | [OpenCode Go](https://opencode.ai/docs/go/) |\n| `opencode-zen` | [OpenCode Zen](https://opencode.ai/docs/zen/) |\n| `meta` | [Meta Model API](https://developer.meta.com/ai/) |\n| `telecomjs` | [TelecomJS TokenHub](https://aigw.telecomjs.com/) |\n| `xai` | [xAI Console](https://console.x.ai/) for an API key, Codewhale-owned device login, or explicitly consented read-only Grok CLI credentials. |\n| `mistral` | [Mistral Console (la Plateforme)](https://console.mistral.ai/api-keys) |\n| `google` | [Google AI Studio](https://aistudio.google.com/apikey) — Codewhale uses the official Gemini OpenAI-compatible endpoint and never reads Google OAuth files. |\n| `edenai` | [Eden AI API keys](https://app.edenai.run/settings/api-keys) |\n| `zenmux` | [ZenMux API keys](https://zenmux.ai/platform/pay-as-you-go) |\n| `csdn` | [CSDN 星图 console](https://ai.csdn.net/workbench/api-key) — choose the Coding Plan key type for the `glm_for_coding` plan route; a general key bills metered. Docs: [Coding Plan](https://ai.csdn.net/coding-plan). |\n| `concentrate` | [Concentrate dashboard](https://concentrate.ai/) → API Keys → Create API Key (a Universal API key); docs: [API introduction](https://concentrate.ai/docs/api-reference/introduction). BYOK only — the key stays in the local secret store. |\n| `modelstudio-token-plan`, `modelstudio-token-plan-anthropic`, `modelstudio-coding-plan`, `modelstudio-coding-plan-anthropic` | [Alibaba Cloud Model Studio (Bailian console)](https://bailian.console.aliyun.com/) — create or copy a Model Studio API key. |\n| `codewhale` | [Codewhale account settings](https://app.codewhale.net/settings?section=api) — create an API key with the `models:infer` scope, or run `codewhale account api-keys create --name --use`. |\n| `custom` | Set the named provider's `base_url` and `api_key_env` or `api_key`; no canonical vendor credential page exists. |\n\nFor Kimi, the official [quickstart](https://platform.kimi.ai/docs/overview)\ndirects users to sign in, open **API Keys**, create and copy a key, and keep it\nsecret. Codewhale links straight to that console and accepts the copied key.\nIt never probes or impersonates `kimi_cli`/`kimi_code_cli`; first-class Kimi\nOAuth remains blocked on a vendor-registered Codewhale identity.\n\n### Subscription sign-in accounts (ChatGPT, xAI)\n\nCodewhale keeps one sign-in of its own per subscription provider. It is\nseparate from the browser, the ChatGPT or Grok apps, and the Codex or Grok\nCLIs, so those can be signed in to a different account. A successful login\nreplaces the previous owned grant; there is no picker for multiple saved\nChatGPT accounts.\n\n- **See which account is signed in.** `codewhale auth status --provider\n openai-codex` (or `--provider xai`) and `codewhale auth list` show an account\n label when the ID token contains an email. ChatGPT plan and workspace\n details appear only when those claims are present; a workspace label uses\n the first characters of its account ID. The label is display metadata, not\n proof of plan entitlement. A valid sign-in may have no email label. Status\n reads local credential state without contacting the issuer: an expired\n access token with a stored refresh token remains structurally usable, while\n missing or invalid storage, or an expired token without a refresh token,\n is reported unusable. The provider picker shows the label of the sign-in\n the route would use (for xAI this includes a consented Grok CLI import).\n Successful login reports the account when known and whether it replaced\n an earlier sign-in, in the TUI's selected language. No token is printed.\n- **Reauthorize ChatGPT.** Run `/auth chatgpt` inside Codewhale to update the\n running session, or `codewhale auth chatgpt` from a shell and restart open\n Codewhale sessions. Ordinary login reuses the selected account/workspace's\n issued client ID and checks that the returned account is the same. The\n browser opens with `prompt=login`; `CODEWHALE_CHATGPT_OAUTH_NO_PROMPT=1`\n omits that parameter if the issuer refuses it.\n- **Replace the ChatGPT account or workspace.** From a shell, run\n `CODEWHALE_CHATGPT_NEW_ACCOUNT=1 codewhale auth chatgpt`, then refresh its\n model catalog and restart open Codewhale sessions. If the browser holds the\n wrong account, sign out there or open the printed URL in a private window.\n Only a fully validated new grant replaces the active owned sign-in.\n- **Switch xAI accounts.** Run `/auth xai-device` inside Codewhale, or\n `codewhale auth xai-device` from a shell and restart open sessions. The\n device page approves with whichever account the browser holds; sign out\n there or use a private window to approve another account. The login names\n the account it replaced, or says it is the same account as before.\n- **Usage limits.** ChatGPT plan requests can fail with\n `subscription_sharing_usage_limit_exceeded` or\n `subscription_sharing_usage_unavailable`. These errors, and subscription\n quota errors from other providers, name the account that made the request\n when its label is known and offer recovery guidance. Codewhale does not\n retry these terminal errors or silently choose another billing route. An\n xAI route set to OAuth that fell back to an API key gets no sign-in\n guidance, since no sign-in was used.\n- **ChatGPT credential authority.** The official ChatGPT plan route uses\n only its selected, verified Codewhale-owned grant.\n `OPENAI_CODEX_ACCESS_TOKEN`, `CODEX_ACCESS_TOKEN`, and consented Codex CLI\n tokens cannot override or authorize this route.\n\n### External CLI credential consent\n\nCredential files owned by another CLI are disabled by default. Without an\nexplicit grant, provider discovery, setup, routing, `auth status`, and doctor\ndo not stat, read, refresh, contact an identity provider for, or rewrite Codex,\nGrok, Kimi, or future external credential files.\n\nCodewhale currently supports exact-path, provider-scoped **read-only** grants\nfor the Codex CLI and Grok CLI. Codex grants remain available for legacy\ncredential inspection only; they cannot authorize the official ChatGPT plan\nroute. Use `codewhale auth chatgpt` for that route.\n\n```bash\n# Legacy Codex credential inspection; does not authorize ChatGPT plan requests.\ncodex login\ncodewhale auth external-consent --provider openai-codex --mode read-only\n\ngrok login\ncodewhale auth external-consent --provider xai --mode read-only\n\ncodewhale auth status --provider openai-codex\ncodewhale auth external-revoke --provider openai-codex\n```\n\nPass `--path /absolute/path/to/auth.json` when the external CLI uses a custom\nlocation. Consent persists the provider, external owner, exact absolute path,\nand consent schema version. Later environment-variable changes do not redirect\nthat authority to a different file. Read-only grants never refresh, contact an\nidentity/discovery service, or rewrite the external file. Supported routes such\nas xAI may use the external token after explicit selection. The official ChatGPT\nplan route ignores imported Codex tokens and requires its own verified grant.\nAn expired external token fails with login guidance. Doctor reports structural\nconsent/config state without\nopening credential files and is always non-mutating.\n\n`managed` is reserved for a future provider-specific preservation adapter.\nv0.9.1 rejects it before file or network I/O because no reviewed adapter can\nyet preserve every unknown external schema field safely. Codewhale-started xAI\ndevice login instead atomically activates a Codewhale-owned generation named\n`$CODEWHALE_HOME/credentials/xai-auth-.json`, stores only that\nvalidated basename in config, and revokes any Grok-file grant. Superseded\ngenerations are cleaned only after the new config pointer commits.\nKimi remains API-key-only; external consent for Kimi is rejected.\n\nThe official DeepSeek Harness (`dsh`) is a third read-only credential owner:\n`codewhale auth external-consent --provider deepseek --mode read-only` grants\nexact-path read access to `DEEPSEEK_API_KEY` in `$DSH_HOME/.credentials.yaml`\n(or `~/.dsh/.credentials.yaml`), which Codewhale never writes, refreshes, or\nloads into the process environment. This is separate from the DSH *harness*\nintegration (`codewhale integrations dsh …`, see\n[INTEGRATIONS_DSH.md](INTEGRATIONS_DSH.md)), which never touches credentials\nin either direction: it pins Codewhale's route identity into a `--patch`\noverlay and lets DSH resolve its own keys.\n\n## Shipped Providers\n\n| Provider ID | TOML table | Auth env | Base URL env and default | Default or static models | Notes |\n| --- | --- | --- | --- | --- | --- |\n| `deepseek` | `[providers.deepseek]` | `DEEPSEEK_API_KEY` | `CODEWHALE_BASE_URL` / `DEEPSEEK_BASE_URL`; default `https://api.deepseek.com/beta` | `deepseek-flash` (shipped default; V4.1 Flash, unversioned id), `deepseek-v4-pro`, `deepseek-v4-flash`, experimental `deepseek-v4-flash-vision-exp`; vision aliases `flash-vision`, `deepseek-v4flashvisionexp`; compatibility aliases `deepseek-chat`, `deepseek-reasoner` | First-class default. The live Pro backend is labeled `DeepSeek-V4-Pro-0813`; the callable API ID remains `deepseek-v4-pro`. Beta URL enables strict tool mode, chat prefix completion, and FIM completion. The documented V4 routes can use provider-native web search through a separate bounded Responses request; compatible custom endpoints do not inherit that capability. Set `https://api.deepseek.com` or `/v1` explicitly to opt out of beta-only features. The shipped default `deepseek-flash` speaks the Responses API (DeepSeek's documented path for Codex-style integration, since the 2026-07-31 Flash production update); the Chat-only controls (the `thinking` toggle and strict-tool `/beta` routing) apply to Chat Completions routes, and `deepseek-v4-pro` stays on Chat Completions until its announced Responses rollout. Reasoning effort follows DeepSeek's documented requested-to-actual mapping: `minimal`/`low` land on `low`, `medium`/`xhigh` on `high`, and `max`/`ultra` on `max`; `off` disables thinking (`thinking: {\"type\":\"disabled\"}` on Chat, `reasoning.effort: \"none\"` on Responses). The experimental vision ID was observed in the authenticated `/models` roster on 2026-08-21 and is advertised as image-input capable on the direct Chat Completions route only. Its limits, reasoning, and tool-call flags provisionally inherit Flash; pricing remains unknown, and no funded image round trip was made during this release work. |\n| `deepseek-anthropic` | `[providers.deepseek_anthropic]` | `DEEPSEEK_API_KEY` | `DEEPSEEK_ANTHROPIC_BASE_URL`; default `https://api.deepseek.com/anthropic` | `deepseek-v4-pro`, `deepseek-v4-flash`; compatibility aliases `deepseek-chat`, `deepseek-reasoner` | Opt-in DeepSeek route for the Anthropic Messages wire protocol. Uses `/v1/messages`, `x-api-key`, and `anthropic-version: 2023-06-01`. Keep `provider = \"deepseek\"` for the default Chat Completions path. |\n| `nvidia-nim` | `[providers.nvidia_nim]` | `NVIDIA_API_KEY`, `NVIDIA_NIM_API_KEY` | `NVIDIA_NIM_BASE_URL`, `NIM_BASE_URL`, `NVIDIA_BASE_URL`; default `https://integrate.api.nvidia.com/v1` | `deepseek-ai/deepseek-v4-pro`, `deepseek-ai/deepseek-v4-flash` | Hosted DeepSeek V4 through NVIDIA NIM. `NVIDIA_NIM_MODEL` is accepted by the TUI config path. |\n| `openai` | `[providers.openai]` | `OPENAI_API_KEY` | `OPENAI_BASE_URL`; default `https://api.openai.com/v1` | `gpt-5.6` (default), `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` | Generic OpenAI-compatible route whose built-in endpoint and fallback catalog are native to OpenAI. The [GPT-5.6 family](https://developers.openai.com/api/docs/models/gpt-5.6-sol) uses OpenAI's documented 1.05M context, 128K max output, and reasoning levels. Custom gateways remain free to select an explicit gateway-owned model. `OPENAI_MODEL` is accepted. |\n| `atlascloud` | `[providers.atlascloud]` | `ATLASCLOUD_API_KEY` | `ATLASCLOUD_BASE_URL`; default `https://api.atlascloud.ai/v1` | Default `deepseek-ai/deepseek-v4-flash`; explicit `vendor/model-id` values pass through when AtlasCloud is selected | OpenAI-compatible hosted route. `ATLASCLOUD_MODEL` is accepted by the TUI config path, the static `ModelRegistry` keeps DeepSeek V4 fallback rows, and provider-hinted CLI model IDs are sent to AtlasCloud exactly as requested. Use Atlas Cloud's own catalog or Coding Plan page for the current provider-owned model list and pricing. |\n| `wanjie-ark` | `[providers.wanjie_ark]` | `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, `WANJIE_MAAS_API_KEY` | `WANJIE_ARK_BASE_URL`, `WANJIE_BASE_URL`, `WANJIE_MAAS_BASE_URL`; default `https://maas-openapi.wanjiedata.com/api/v1` | `deepseek-reasoner` | OpenAI-compatible hosted route. `WANJIE_ARK_MODEL`, `WANJIE_MODEL`, and `WANJIE_MAAS_MODEL` are accepted. |\n| `volcengine` | `[providers.volcengine]` | `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, `ARK_API_KEY` | `VOLCENGINE_BASE_URL`, `VOLCENGINE_ARK_BASE_URL`, `ARK_BASE_URL`; default `https://ark.cn-beijing.volces.com/api/coding/v3` | `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash` | Volcengine/Volcano Engine Ark OpenAI-compatible coding endpoint. `VOLCENGINE_MODEL` and `VOLCENGINE_ARK_MODEL` are accepted. |\n| `openrouter` | `[providers.openrouter]` | `OPENROUTER_API_KEY` | `OPENROUTER_BASE_URL`; default `https://openrouter.ai/api/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`; recent large IDs include `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`, `qwen/qwen3.6-flash`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`, `qwen/qwen3.6-plus`, `google/gemma-4-31b-it`, `z-ai/glm-5.1`, `z-ai/glm-5.2`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6` | Additive open-model routing layer. It does not replace DeepSeek; it lets users route supported model IDs through OpenRouter when they choose it. |\n| `orcarouter` | `[providers.orcarouter]` | `ORCAROUTER_API_KEY` | `ORCAROUTER_BASE_URL`; default `https://api.orcarouter.ai/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`; router alias `orcarouter/auto`; recent large IDs mirror the OpenRouter namespaced catalog | [OrcaRouter](https://www.orcarouter.ai) OpenAI-compatible aggregation gateway. Shares the namespaced `vendor/model` wire-model format and DeepSeek model set with OpenRouter, so the OpenRouter base-URL and model-normalization rules apply. `ORCAROUTER_MODEL` is accepted. Provider aliases: `orcarouter`, `orca_router`, `orca`. |\n| `xiaomi-mimo` | `[providers.xiaomi_mimo]` | `XIAOMI_MIMO_TOKEN_PLAN_API_KEY`, `MIMO_TOKEN_PLAN_API_KEY`, `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, `MIMO_API_KEY` | `XIAOMI_MIMO_BASE_URL`, `MIMO_BASE_URL`, `XIAOMI_MIMO_MODE`, `MIMO_MODE`; default `https://token-plan-sgp.xiaomimimo.com/v1` | Chat: `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, `mimo-v2.5`; speech/TTS: `mimo-v2.5-tts`, `mimo-v2.5-tts-voicedesign`, `mimo-v2.5-tts-voiceclone`, `mimo-v2-tts` | Xiaomi MiMo OpenAI-compatible chat completions route. `mimo-v2.5-pro` and `mimo-v2.5` can use the documented provider-native web-search plugin; ultraspeed, speech/TTS, and custom-compatible routes do not inherit it. Token Plan keys (`tp-...`) use `api-key` auth and the token-plan endpoint by default; pay-as-you-go mode uses standard API keys (`sk-...`) and `https://api.xiaomimimo.com/v1`. It sends `max_completion_tokens` and uses MiMo's `thinking` field for reasoning control. Token Plan cost/usage is credit/quota based; Codewhale shows it as unknown until Xiaomi exposes a reliable balance API. `codewhale speech` / `tts` uses the TTS models. |\n| `novita` | `[providers.novita]` | `NOVITA_API_KEY` | `NOVITA_BASE_URL`; default `https://api.novita.ai/openai/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | OpenAI-compatible hosted route for DeepSeek model IDs. Use config or `CODEWHALE_MODEL` / `DEEPSEEK_MODEL` for model overrides. |\n| `fireworks` | `[providers.fireworks]` | `FIREWORKS_API_KEY` | `FIREWORKS_BASE_URL`; default `https://api.fireworks.ai/inference/v1` | `accounts/fireworks/models/deepseek-v4-pro` | OpenAI-compatible hosted route. Use config or `CODEWHALE_MODEL` / `DEEPSEEK_MODEL` for model overrides. |\n| `siliconflow` | `[providers.siliconflow]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.com/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | OpenAI-compatible hosted route. Official docs use the `.com` endpoint. `SILICONFLOW_MODEL` is accepted. Reasoning aliases `deepseek-reasoner` and `deepseek-r1` map to Pro; `deepseek-chat` and `deepseek-v3` map to Flash. |\n| `siliconflow-CN` | `[providers.siliconflow_cn]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.cn/v1` | Uses the SiliconFlow model set | China regional SiliconFlow route. Falls back to `[providers.siliconflow]` for api_key / base_url / model when unset. Select it with `provider = \"siliconflow-CN\"` or `CODEWHALE_PROVIDER=siliconflow-CN`. |\n| `arcee` | `[providers.arcee]` | `ARCEE_API_KEY` | `ARCEE_BASE_URL`; default `https://api.arcee.ai/api/v1` | `trinity-large-thinking`, `trinity-large-preview` | Arcee AI direct OpenAI-compatible route, tracked as 256K-context BF16 serving. `ARCEE_MODEL` is accepted. OpenRouter's `arcee-ai/trinity-large-thinking` remains the OpenRouter namespaced model ID; direct Arcee uses the bare `trinity-large-thinking` ID. |\n| `moonshot` | `[providers.moonshot]` | `MOONSHOT_API_KEY`, `KIMI_API_KEY` | `MOONSHOT_BASE_URL`, `KIMI_BASE_URL`; default `https://api.moonshot.ai/v1` | Direct Moonshot: `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.7-code-highspeed`, `kimi-k2.6`; Kimi Code membership: `k3`, `kimi-for-coding`, `kimi-for-coding-highspeed` at `https://api.kimi.com/coding/v1` | Moonshot/Kimi route. Exact direct `kimi-k3` routes use the documented Formula web-search tool/fiber loop; direct `kimi-k2.6` retains the built-in `$web_search` contract, and exact Kimi Code membership routes use their structured `/search` service. Adjacent paths, K2.7 direct models, and cross-product model IDs do not inherit native search. `kimi` and `kimi-k2` aliases select `kimi-k2.7-code`; `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, and `KIMI_MODEL` are accepted. Kimi thinking streams through `reasoning_content`; Codewhale keeps it in Thinking cells and replays it for thinking/tool-call continuity. For direct K3, use exact `base_url = \"https://api.moonshot.ai/v1\"` and `model = \"kimi-k3\"`; it is always-thinking and receives top-level `reasoning_effort = \"low\" | \"high\" | \"max\"` (`off` normalizes to `low`), uses only `max_completion_tokens`, and omits `temperature`/`top_p` per the [K3 quickstart](https://platform.kimi.ai/docs/guide/kimi-k3-quickstart). For Kimi Code K3, use a key from the [Kimi Code console](https://www.kimi.com/code/console), exact `base_url = \"https://api.kimi.com/coding/v1\"`, and bare `model = \"k3\"`; `off` becomes enabled `low`, while normal dispatched `auto` selects and sends a concrete Codewhale tier. Only an omitted reasoning setting leaves the provider default in control. That membership route defaults safely to 262,144 context tokens; the [Kimi Code model-tier table](https://www.kimi.com/code/docs/en/kimi-code/models.html) grants Allegretto and higher plans up to 1M, which those plans may express as `context_window = 1048576`. `k3[1m]` is Claude Code-only and Codewhale rejects it. `kimi-for-coding` remains the valid K2.7 membership route, and `kimi-for-coding-highspeed` is its own high-speed roster entry (262,144 context); membership ids are rejected on the direct platform endpoint, and `kimi-k3` stays rejected on the membership endpoint. Billing is decided by the endpoint the route resolves to, judged once against the two exact product endpoints: direct Moonshot (`https://api.moonshot.ai/v1` or the default) bills metered with dollar estimates, the exact Kimi Code membership endpoint bills as Kimi Code quota and never shows dollar estimates, and anything else — a gateway host, a neighboring Kimi-hosted path — reports `cost: unknown` rather than borrowing either product. An imported Kimi Code token with no `base_url` in its table still resolves to the membership endpoint, so it bills as Kimi Code quota and never accrues dollars. A completed turn, parent or sub-agent, is billed from the immutable endpoint receipt its own client was built with, never from a later config re-read: `MOONSHOT_BASE_URL`/`KIMI_BASE_URL` are merged into the *active* provider's table only, and an in-turn provider switch can move the ambient config off the route that actually ran. Legacy `auth_mode = \"kimi_oauth\"` fails to API-key guidance without probing Kimi CLI files. Codewhale does not impersonate `kimi_cli` or `kimi_code_cli`. **China-region keys:** contributor field evidence (@vFONGv, PR #5229, verified on Windows 10) reports that a China-region Moonshot key must be paired with `base_url = \"https://api.moonshot.cn/v1\"`; left on the default international host (`https://api.moonshot.ai/v1`) it fails authentication. We have no China-region key to verify this ourselves, so it is recorded as a user report rather than a tested route. Note also that editing `base_url` alone does not take effect until `codewhale auth set` is re-run for that provider. |\n| `google` | `[providers.google]` | `GOOGLE_API_KEY`, `GEMINI_API_KEY` | `GOOGLE_BASE_URL`, `GEMINI_BASE_URL`; default `https://generativelanguage.googleapis.com/v1beta/openai/` | `gemini-3.1-pro-preview` (default); `/model` also lists `gemini-3-pro-preview`, `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-2.5-pro`, `gemini-2.5-flash` | Google Gemini as its own backend on the official OpenAI-compatible Chat Completions route. Thinking models capture `extra_content.google.thought_signature` on tool calls and replay it with the assistant tool-call messages; replaying a tool call whose signature was not captured fails closed with an actionable error instead of letting the tool loop break. `gemini-2.5-flash-lite` ships thinking off and degrades with a warning instead. Reasoning effort maps onto the documented `google.thinking_config.thinking_level` (`low`/`high`). The dialect binds to the exact official base URL: a `google` row pointed at another gateway gets plain OpenAI semantics and no signature requirements. Codewhale never reads Google OAuth files; only an AI Studio API key is used. Not live-tested against the real endpoint in this environment. |\n| `zai` | `[providers.zai]` | `ZAI_API_KEY`, `Z_AI_API_KEY`, `ZHIPU_API_KEY`, `GLM_API_KEY` | `ZAI_BASE_URL`, `Z_AI_BASE_URL`; default `https://api.z.ai/api/coding/paas/v4`; general APIs `https://api.z.ai/api/paas/v4` and `https://open.bigmodel.cn/api/paas/v4` | `GLM-5.3` default; `/model` also lists `GLM-5.3-Flash`, `GLM-5.2`, `GLM-5.1`, and `GLM-5-Turbo` | Z.AI GLM Coding Plan route. All three first-party Chat routes (both api.z.ai products and BigModel's general platform endpoint) share one request dialect, so reasoning controls — the GLM-5.2 thinking toggle, tiered effort, and the forced-thinking GLM-5.3 rewrite that sends `off` as `enabled` + `reasoning_effort: \"low\"` — apply on BigModel too; neighboring paths such as `/preview` stay fail-closed. The two general API products expose structured provider-native web search (`search-prime` globally, `search_std` in China); Coding Plan and compatible custom endpoints do not inherit it. `GLM-5.3` is the default and a first-class picker row (`model = \"GLM-5.3\"` or `ZAI_MODEL=GLM-5.3`); `GLM-5.3-Flash` is the 1M multimodal fast sibling (`model = \"GLM-5.3-Flash\"`). An explicit `GLM-5.2` selection keeps its own id. Limits and reasoning options for 5.3 are inherited from `GLM-5.2` until Z.ai publishes distinct 5.3 metadata; 5.3 carries no price. Flash ships the published $0.15/$0.50 list. A live call can still 429 with entitlement code 1311 on accounts that are not provisioned for 5.3. |\n| `stepfun` | `[providers.stepfun]` | `STEPFUN_API_KEY`, `STEP_API_KEY` | `STEPFUN_BASE_URL`, `STEP_BASE_URL`; default `https://api.stepfun.ai/v1`; Coding Plan endpoint `https://api.stepfun.ai/step_plan/v1` | `step-3.7-flash` | StepFun / StepFlash direct OpenAI-compatible route. `/provider` setup asks which billing route the key belongs to — pay-as-you-go or Step Plan — validates the key against the chosen endpoint, and writes the answer to `[providers.stepfun].base_url` only. A base URL that is neither recognized route is left alone and the question is skipped. You can also set `[providers.stepfun].base_url` or `STEP_BASE_URL` to the Coding Plan URL by hand. Offline accounting labels recognized routes as `stepfun-payg` or `stepfun-plan` without persisting the raw endpoint, and only the standard PAYG route receives token pricing. `STEPFUN_MODEL` and `STEP_MODEL` are accepted. |\n| `minimax` | `[providers.minimax]` | `MINIMAX_API_KEY` | `MINIMAX_BASE_URL`; default `https://api.minimax.io/v1`; China `https://api.minimaxi.com/v1` | `MiniMax-M3`, `MiniMax-M2.7`, `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `MiniMax-M2.1`, `MiniMax-M2.1-highspeed`, `MiniMax-M2` | MiniMax direct OpenAI-compatible route. Codewhale sends `reasoning_split = true` so MiniMax thinking arrives separately from answer text. Both MiniMax dialects sell pay-as-you-go and Token Plan over the same endpoints and the same key, so billing is classified from the credential *product*, never from the endpoint or from a default. `mode = \"token-plan\"` in `[providers.minimax]`/`[providers.minimax_anthropic]`, or a Token Plan key shaped `sk-cp…`, bills as MiniMax Token Plan quota with no dollar estimates; an explicit pay-as-you-go mode (`pay-as-you-go`/`payg`/`metered`) wins over key shape. The key's product prefix is only visible when the key is in config, bound by `api_key_env`, or exported as `MINIMAX_API_KEY` on an official endpoint — a key saved through `codewhale auth set` (secret store / OS keyring) is deliberately not read to classify billing. With no explicit mode and no visible product marker the route reports `cost: unknown` rather than assuming pay-as-you-go, so a Token Plan account is never charged invented dollars. Custom/gateway endpoints also fail closed with `cost: unknown`. Official M3 input modalities are text, image, and video; M2.7 is text-only. |\n| `minimax-anthropic` | `[providers.minimax_anthropic]` | `MINIMAX_API_KEY` | `MINIMAX_ANTHROPIC_BASE_URL`; default `https://api.minimax.io/anthropic`; China `https://api.minimaxi.com/anthropic` | `MiniMax-M3`, `MiniMax-M2.7` | MiniMax direct Anthropic-compatible Messages route. Keep the `/anthropic` suffix because Codewhale appends `/v1/messages`; the route uses `x-api-key`. M3 supports adaptive or disabled thinking. M2.7 always keeps thinking enabled. |\n| `sglang` | `[providers.sglang]` | Optional `SGLANG_API_KEY` | `SGLANG_BASE_URL`; default `http://localhost:30000/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Self-hosted OpenAI-compatible route. Localhost deployments commonly omit auth. `SGLANG_MODEL` is accepted. |\n| `vllm` | `[providers.vllm]` | Optional `VLLM_API_KEY` | `VLLM_BASE_URL`; default `http://localhost:8000/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Self-hosted vLLM OpenAI-compatible route. Localhost deployments commonly omit auth. `VLLM_MODEL` is accepted. |\n| `ollama` | `[providers.ollama]` | Local optional `OLLAMA_API_KEY` | `OLLAMA_BASE_URL`; default `http://localhost:11434/v1` | live tag from the local catalog; pre-refresh placeholder `unknown`; provider-hinted custom tags pass through | Local Ollama is keyless by default. `OLLAMA_MODEL` is accepted. The header must not paint a hosted id the local daemon did not list. |\n| `ollama-cloud` | `[providers.ollama_cloud]` | `OLLAMA_CLOUD_API_KEY`, then `OLLAMA_API_KEY` | `OLLAMA_CLOUD_BASE_URL`; default `https://ollama.com/v1` | `gpt-oss:120b`; arbitrary provider-owned IDs pass through | Hosted OpenAI-compatible `/v1/chat/completions` route. Save credentials under `ollama-cloud`; the exact released `ollama` + Cloud URL tuple has bounded read-only in-memory compatibility with its legacy table and secret slot. `OLLAMA_CLOUD_MODEL` is accepted. |\n| `huggingface` | `[providers.huggingface]` | `HUGGINGFACE_API_KEY`, `HF_TOKEN` | `HUGGINGFACE_BASE_URL`, `HF_BASE_URL`; default `https://router.huggingface.co/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Hugging Face Inference Providers OpenAI-compatible router route. Accepted aliases: `huggingface`, `hugging-face`, `hugging_face`, `hf`. Org-prefixed model IDs pass through. `HUGGINGFACE_MODEL` and `HF_MODEL` are accepted. Hub browsing/export are separate future features. |\n| `modelscope` | `[providers.modelscope]` | `MODELSCOPE_API_KEY` | `MODELSCOPE_BASE_URL`; default `https://api-inference.modelscope.cn/v1` | `Qwen/Qwen3.5-397B-A17B` (default), `Qwen/Qwen3.5-122B-A10B`, `Qwen/Qwen3.5-27B`, `Qwen/Qwen3.5-35B-A3B`, `Qwen/Qwen3.8-27B`, `Qwen/Qwen3.8-Flash-Next`, `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Pro-0813`, `deepseek-ai/DeepSeek-V4.1-Flash`, `ZhipuAI/GLM-4.7-Flash`, `ZhipuAI/GLM-5.2` | ModelScope OpenAI-compatible inference route. Org-prefixed model IDs pass through. `MODELSCOPE_MODEL` is accepted. |\n| `deepinfra` | `[providers.deepinfra]` | `DEEPINFRA_API_KEY`, `DEEPINFRA_TOKEN` | `DEEPINFRA_BASE_URL`; default `https://api.deepinfra.com/v1/openai` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | DeepInfra OpenAI-compatible route. Drop-in replacement for OpenAI SDK. |\n| `together` | `[providers.together]` | `TOGETHER_API_KEY` | `TOGETHER_BASE_URL`; default `https://api.together.xyz/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash`, `thinkingmachines/inkling` | Together AI OpenAI-compatible route. `TOGETHER_MODEL` is accepted. Model aliases `deepseek-v4-pro` and `deepseek-v4-flash` normalize to Together's org-prefixed IDs; `inkling` and `together-inkling` normalize to Together's published lowercase Inkling wire ID. Inkling uses the exact `none`/`minimal`/`low`/`medium`/`high`/`max` reasoning vocabulary from Thinking Machines' [official model repository](https://huggingface.co/thinkingmachines/Inkling). Together's [launch post](https://www.together.ai/blog/together-ai-brings-thinking-machines-labs-new-model-inkling-on-day-0) currently says Inkling is live with 1M context, while its [model detail page](https://www.together.ai/models/inkling) says coming soon with 256K context and publishes no price. Until Together's active `/models` endpoint and the Models.dev catalog resolve that conflict, Inkling is not seeded into Codewhale's offline picker and no route-specific context or cost is inferred. |\n| `qianfan` | `[providers.qianfan]` | `QIANFAN_API_KEY`, `BAIDU_QIANFAN_API_KEY` | `QIANFAN_BASE_URL`, `BAIDU_QIANFAN_BASE_URL`; default `https://api.baiduqianfan.ai/v1` | `ernie-4.0-turbo-8k`; provider-scoped custom Qianfan service/model IDs pass through | Baidu Qianfan OpenAI-compatible route. Requests use Bearer auth and Chat Completions payloads. `QIANFAN_MODEL` and `BAIDU_QIANFAN_MODEL` are accepted; aliases `baidu-qianfan`, `baidu_qianfan`, and `baidu` resolve to this provider. Tool/function calling is model-scoped in Qianfan docs, so Codewhale preserves the selected wire model and leaves live capability proof to follow-up route/capability work. |\n| `openai-codex` | `[providers.openai_codex]` | Official Sign in with ChatGPT (`codewhale auth chatgpt` / `/provider setup openai-codex`) | Official `https://api.openai.com/v1` | Selected account catalog; configured model IDs remain explicit selections | **Experimental.** Public Responses endpoint (`/v1/responses`) with a validated `chatgpt.tokens.use.direct` grant. Dynamic OSS registration issues a client ID for the selected account/workspace; Codewhale protects and renews its own tokens. The account catalog supplies selectable models; public model lists and legacy Codex tokens do not establish plan permission. No silent billing fallback. See [Sign in with ChatGPT](#sign-in-with-chatgpt) for setup, usage limits, and preview boundaries. |\n| `anthropic` | `[providers.anthropic]` | `ANTHROPIC_API_KEY` | `ANTHROPIC_BASE_URL`; default `https://api.anthropic.com` | `claude-opus-4-8`, `claude-sonnet-4-6` (default), `claude-haiku-4-5` | Native Anthropic Messages API route (`/v1/messages`, `x-api-key` + `anthropic-version: 2023-06-01`) — not OpenAI-compatible. Prompt caching via `cache_control` breakpoints, adaptive thinking + `output_config.effort`, signed thinking blocks replayed verbatim, cache telemetry normalized per #2961. `ANTHROPIC_MODEL` is accepted. |\n| `openmodel` | `[providers.openmodel]` | `OPENMODEL_API_KEY` | `OPENMODEL_BASE_URL`; default `https://api.openmodel.ai` | `deepseek-v4-flash`; provider-scoped custom model IDs pass through | OpenModel Anthropic-compatible Messages route. Uses `/v1/messages`, Bearer auth, and `anthropic-version: 2023-06-01`; OpenModel selects DeepSeek, DashScope, Xiaomi, Claude, and other routes by model id. `OPENMODEL_MODEL` is accepted. |\n| `sakana` | `[providers.sakana]` | `FUGU_API_KEY`, `SAKANA_API_KEY` | `SAKANA_BASE_URL`; default `https://api.sakana.ai/v1` | `fugu` (default), `fugu-ultra-20260615` | Sakana AI Fugu OpenAI-compatible route. Standard Chat Completions wire protocol; streaming supported. `fugu-ultra-20260615` is the heavy/reasoning variant. Env var aliases: `FUGU_API_KEY` (primary), `SAKANA_API_KEY`; provider aliases: `sakana-ai`, `sakana_ai`, `fugu`. |\n| `longcat` | `[providers.longcat]` | `LONGCAT_API_KEY` | `LONGCAT_BASE_URL`; default `https://api.longcat.chat/openai/v1` | `LongCat-2.0` (default) | Meituan LongCat curated model gateway. OpenAI-compatible Chat Completions wire protocol. Sign up at https://longcat.chat/platform for an API key. Provider aliases: `long-cat`, `meituan-longcat`, `meituan`. |\n| `opencode-go` | `[providers.opencode_go]` | `OPENCODE_GO_API_KEY` | `OPENCODE_GO_BASE_URL`; default `https://opencode.ai/zen/go/v1` | `deepseek-v4-pro` (default), `grok-4.5`, `glm-5.2`, `glm-5.1`, `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.6`, `deepseek-v4-flash`, `mimo-v2.5`, `mimo-v2.5-pro` | [OpenCode Go](https://opencode.ai/docs/go/) subscription route using OpenAI-compatible Chat Completions. `OPENCODE_GO_MODEL` is accepted. Codewhale uses bare wire IDs; familiar `opencode-go/` input aliases normalize to the bare ID. Go models documented only on the Anthropic `/messages` endpoint are deliberately not advertised by this route until Codewhale supports per-model wire selection. Billing surfaces show the Go allowance instead of token-price estimates. |\n| `opencode-zen` | `[providers.opencode_zen]` | `OPENCODE_ZEN_API_KEY`, fallback `OPENCODE_API_KEY` | `OPENCODE_ZEN_BASE_URL`; default `https://opencode.ai/zen/v1` | `gpt-5.6` (default); current GPT, Claude, Qwen, DeepSeek, MiniMax, GLM, Kimi, Grok, Muse Spark, and free-model IDs | [OpenCode Zen](https://opencode.ai/docs/zen/) model-aware gateway. `OPENCODE_ZEN_MODEL` is accepted, and official `opencode/` selectors normalize to bare wire IDs. Each model's wire comes from the curated snapshot, then from the AI SDK package its [Models.dev](https://models.dev) row names: GPT, Grok, and Muse Spark rows use `/responses`; Claude and most Qwen rows use `/messages`; DeepSeek, MiniMax, GLM, Kimi, `qwen3.8-max`, and the free rows use `/chat/completions`. Responses and Chat Completions authenticate with Bearer `Authorization`, while Anthropic Messages uses `x-api-key`; none of these routes use ChatGPT/Codex OAuth guidance or headers. Gemini fails closed because its model-specific Google wire protocol is not implemented; Models.dev rows marked `deprecated` and models no loaded catalog lists also fail closed. See [OpenCode Zen protocol catalog](#opencode-zen-protocol-catalog). |\n| `meta` | `[providers.meta]` | `META_MODEL_API_KEY`, `MODEL_API_KEY` | `META_MODEL_API_BASE_URL`, `MODEL_API_BASE_URL`; default `https://api.meta.ai/v1` | `muse-spark-1.2` (default) | [Meta Model API](https://developer.meta.com/ai/resources/blog/build-with-muse-spark/) public-preview route using OpenAI-compatible Chat Completions. Muse Spark 1.2 keeps its wire ID, tool support, 1M context, 32K output metadata, and `none` through `xhigh` reasoning effort. `META_MODEL_API_MODEL` and `MODEL_API_MODEL` are accepted. Provider aliases: `meta-ai`, `meta_model_api`, `muse`, `muse-spark`. |\n| `telecomjs` | `[providers.telecomjs]` | `TELECOMJS_API_KEY` | `TELECOMJS_BASE_URL`; default `https://aigw.telecomjs.com/v1` | `deepseek-v4-pro` conservative fallback; authenticated `/models` rows when a key is configured | TelecomJS TokenHub OpenAI-compatible Chat Completions route. Live catalogs are isolated by provider and key fingerprint, stale rows survive transient refresh failures, and unsupported reasoning request fields are omitted. `TELECOMJS_MODEL` is accepted. Provider aliases: `telecom-js`, `telecom_js`, `telecomjs-cn`, `tokenhub`. |\n| `mistral` | `[providers.mistral]` | `MISTRAL_API_KEY` | `MISTRAL_BASE_URL`; default `https://api.mistral.ai/v1` | `mistral-code-latest` (default; `codestral-latest` accepted as alias), `mistral-medium-latest` (aliases: `mistral-medium-3-5`), `mistral-small-latest` (aliases: `mistral-small-2603`), `mistral-large-latest` | Mistral AI (la Plateforme) OpenAI-compatible Chat route. On the documented first-party HTTPS `/v1` hosts, Medium and Small send adjustable `reasoning_effort` (`none` or `high` only), parse Mistral's polymorphic thinking/text blocks, and replay stored thinking in that same wire shape. Deprecated native Magistral IDs remain explicit-configuration compatibility routes: they are always-reasoning and never receive the adjustable effort field. Code and Large are non-reasoning. A custom `MISTRAL_BASE_URL` keeps generic Chat semantics unless it is one of the documented first-party hosts. `MISTRAL_MODEL` is accepted. Provider aliases: `mistral-ai`, `mistralai`, `la-plateforme`. |\n| `edenai` | `[providers.edenai]` | `EDENAI_API_KEY` | `EDENAI_BASE_URL`; default `https://api.edenai.run/v3`; EU `https://api.eu.edenai.run/v3` | `deepseek/deepseek-v4-pro` (default); live `/models` catalog of `provider/model` ids | Eden AI OpenAI-compatible aggregation gateway. Catalog rows remain provider-scoped; generic reasoning controls are omitted because supported fields depend on the selected upstream family. `EDENAI_MODEL` is accepted. The default `deepseek/deepseek-v4-pro` is listed on the global catalog only; on the EU endpoint set `EDENAI_MODEL` (or `model`) to a row from the EU `/models` list, for example `qwen/deepseek-v4-pro`. Provider aliases: `eden-ai`, `eden_ai`. |\n| `zenmux` | `[providers.zenmux]` | `ZENMUX_API_KEY` | `ZENMUX_BASE_URL`; default `https://zenmux.ai/api/v1` | `deepseek/deepseek-v4.1-flash` (default); live `/models` catalog of `provider/model` ids (keyless-readable) | ZenMux OpenAI-compatible aggregation gateway (~190 models). Catalog rows remain provider-scoped; generic reasoning controls are omitted because supported fields depend on the selected upstream family. `ZENMUX_MODEL` is accepted. Provider aliases: `zen-mux`, `zen_mux`. |\n| `csdn` | `[providers.csdn]` | `CSDN_API_KEY` | `CSDN_BASE_URL`; default `https://ai.csdn.net/api/model/v1` | `glm_for_coding` (default; the Coding Plan's dedicated model id); other marketplace model ids pass through | CSDN 星图 (Starmap) OpenAI-compatible hosted platform. Coding Plan keys and general marketplace keys share the one endpoint, so billing follows the credential product, never the URL alone: routing `glm_for_coding` — the shipped default — or setting `mode = \"coding_plan\"`/`\"plan\"`/`\"subscription\"` bills as CSDN Coding Plan quota with no dollar estimates; any other model, or an explicit `pay-as-you-go`/`metered` mode, bills metered; an unrecognized mode or an endpoint off `ai.csdn.net/api/model/v1` reports `cost: unknown`. `CSDN_MODEL` is accepted. Provider aliases: `csdn-ai`, `csdn_ai`, `csdn-coding-plan`, `csdn_coding_plan`, `starmap`. |\n| `concentrate` | `[providers.concentrate]` | `CONCENTRATE_API_KEY` | `CONCENTRATE_BASE_URL`; default `https://api.concentrate.ai/v1` | `deepseek-v4-pro` (default; a plain catalog id lets the gateway pick the upstream provider); `provider/model` ids such as `openai/gpt-5.6-sol` pin one upstream; `concentrate/auto` is the gateway's own router; unauthenticated live `/v1/models` catalog | Concentrate OpenAI Responses-compatible gateway (`POST /v1/responses`, bearer Universal API key). Opt-in and BYOK only: your key, your Concentrate bill, zero Codewhale fee, no managed default. Requests carry only documented fields (the system prompt rides as a `system` input item). Contract: [API introduction](https://concentrate.ai/docs/api-reference/introduction), [request parameters](https://concentrate.ai/docs/api-reference/endpoint/request-parameters), [streaming](https://concentrate.ai/docs/api-reference/endpoint/streaming), [errors](https://concentrate.ai/docs/api-reference/endpoint/errors). See [Concentrate Notes](#concentrate-notes). |\n| `codewhale` | `[providers.codewhale]` | `CODEWHALE_API_KEY` | `CODEWHALE_API_BASE`; default `https://api.codewhale.net/v1` | `deepseek/deepseek-v4-pro` (default), `anthropic/claude-sonnet-5`, `openai/gpt-5.6` are offline bootstrap rows only; the authenticated `GET /v1/models` listing of the account's connected providers is the catalog authority | Codewhale API: account-backed model access over the provider keys the customer connected to their Codewhale account. One `cwc_key_…` account API key with the `models:infer` scope authenticates every model. Model ids are `provider/model` exactly as the account catalog returns them, and each row states its protocol (`chat-completions` → `/v1/chat/completions`, `anthropic-messages` → `/v1/messages`, `responses` → `/v1/responses`); every protocol uses `Authorization: Bearer`, never `x-api-key`. `CODEWHALE_API_BASE` must be HTTPS except on loopback. Connect provider keys with `codewhale account keys set `. Provider aliases: `codewhale-api`, `cw-api`, `codewhale-cloud`. |\n| `xai` | `[providers.xai]` | `XAI_API_KEY`, Codewhale-owned device OAuth, or explicit read-only Grok CLI consent | `XAI_BASE_URL`; default `https://api.x.ai/v1` | `grok-4.6` (default), `grok-4.7`, `grok-4.5`, `grok-4.3`, `grok-build`, `grok-composer-2.5-fast`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning` | xAI/Grok OpenAI-compatible Chat Completions route. Grok 4.6 has a 500K context window, text/image input, function calls, structured output, server-side web search, and `low`/`medium`/`high`/`xhigh` reasoning (default `high`). [Grok 4.7](https://docs.x.ai/docs/models/grok-4.7) has the same 500K window, text/image input, function calls, structured output, reasoning ladder and $2.00 / $0.50 cached / $6.00 rates; xAI documents no web-search support for it yet, so Codewhale does not claim it. Grok reasoning cannot be disabled ([reasoning guide](https://docs.x.ai/docs/guides/reasoning)). Its standard rates double when the prompt reaches 200K tokens; the same 2x long-context rule applies to `grok-4.5` (500K context, $2.00 / $0.30 cached / $6.00) and `grok-4.3` (1M context, $1.25 / $0.20 cached / $2.50) per their [model pages](https://docs.x.ai/docs/models/grok-4.5). There is no documented `latest`/`fast` alias and no published numeric output limit. **API-key** (default): Bearer token from console.x.ai via `XAI_API_KEY` / keyring / `api_key`. **OAuth**: `codewhale auth xai-device` uses SSH-friendly device login and Codewhale-owned storage, which may refresh itself. Existing Grok CLI credentials require `codewhale auth external-consent --provider xai --mode read-only`; the granted external file is never refreshed or rewritten. OAuth may return HTTP 403 on some SuperGrok tiers — keep API-key as the reliable fallback. `XAI_MODEL` is accepted. Provider aliases: `x-ai`, `x_ai`, `grok`. |\n| `modelstudio-token-plan` | `[providers.modelstudio_token_plan]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | `MODELSTUDIO_TOKEN_PLAN_BASE_URL`; default `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` | `qwen3.8-max` (default), `qwen3.8-max-preview`, `qwen3.7-plus`, `qwen3.7-max`, `qwen3.6-flash`, `deepseek-v4-pro`, `deepseek-v4-flash-0731`, `glm-5.2` | Alibaba Cloud Model Studio Token Plan OpenAI-compatible Chat Completions route. Token Plan Personal and Team share this endpoint. `qwen3.8-max`, `qwen3.7-plus`, and `qwen3.7-max` can use provider-native web search through the Token Plan Responses Harness; the preview, Coding Plan, and Anthropic routes do not inherit that capability. All listed models are reasoning-capable text/coding models. DeepSeek and GLM entries are provider-scoped and do not collide with first-party routes. `MODELSTUDIO_TOKEN_PLAN_MODEL` is accepted. Provider aliases: `modelstudio-token-plan`, `alibaba-token-plan`, `dashscope-token-plan`. |\n| `modelstudio-token-plan-anthropic` | `[providers.modelstudio_token_plan_anthropic]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | default `https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic` | Same model catalog as `modelstudio-token-plan` | Token Plan Anthropic-compatible Messages route (`/apps/anthropic`). Same API key as the OpenAI dialect. Provider aliases: `modelstudio-token-plan-anthropic`, `alibaba-token-plan-anthropic`. |\n| `modelstudio-coding-plan` | `[providers.modelstudio_coding_plan]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | `MODELSTUDIO_CODING_PLAN_BASE_URL`; default `https://coding-intl.dashscope.aliyuncs.com/v1` | `qwen3.8-max` (default); same catalog as Token Plan | Alibaba Cloud Model Studio Coding Plan OpenAI-compatible Chat Completions route. `MODELSTUDIO_CODING_PLAN_MODEL` is accepted. Provider aliases: `modelstudio-coding-plan`, `alibaba-coding-plan`, `dashscope-coding-plan`. |\n| `modelstudio-coding-plan-anthropic` | `[providers.modelstudio_coding_plan_anthropic]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | default `https://coding-intl.dashscope.aliyuncs.com/apps/anthropic` | Same model catalog as `modelstudio-coding-plan` | Coding Plan Anthropic-compatible Messages route (`/apps/anthropic`). Provider aliases: `modelstudio-coding-plan-anthropic`, `alibaba-coding-plan-anthropic`. |\n\nStepFun's four coding models are available through both its standard API and\n[Step Plan](https://platform.stepfun.ai/docs/en/step-plan/integrations/reasoning-api).\nChoose the billing route in `/provider`, then the model in `/model`; existing\nStep 3.7 selections remain unchanged. [Step 5 Preview](https://platform.stepfun.ai/docs/en/guides/models/step-5-preview)\nhas a 1M context window. Step 5 and Step 3.7 expose low/medium/high reasoning;\nStep 3.5 Flash 2603 exposes low/high; base Step 3.5 uses provider-default reasoning.\n[Published API prices](https://platform.stepfun.ai/docs/en/guides/pricing/details)\napply only to verified PAYG routes. Step Plan displays subscription allowance.\nSpeech, music and image-generation models use separate interfaces and are not\npresented as coding models. Provider video capability metadata does not imply\nthat every Codewhale client can attach video.\n\n\n### OpenCode Zen protocol catalog\n\nZen Responses and Chat Completions requests authenticate with Bearer\n`Authorization`; Zen Anthropic Messages requests use `x-api-key`. None of these\nroutes add ChatGPT/Codex OAuth headers.\n\nEach Zen model's wire comes from two sources, and neither is guessed from a\nmodel-id family (`qwen3.8-flash` is Messages while `qwen3.8-max` is Chat):\n\n1. **The curated snapshot** compiled into Codewhale, verified against the\n [official endpoint table](https://opencode.ai/docs/zen/) and the `opencode`\n provider in [Models.dev](https://models.dev). It is the offline floor, and\n it wins when a catalog row names a different wire for the same id.\n2. **The Models.dev catalog.** Its `opencode` provider is Zen's published\n catalog: each model row names the AI SDK package OpenCode itself uses, which\n Codewhale maps exactly — `@ai-sdk/openai` to Responses, `@ai-sdk/anthropic`\n to Anthropic Messages, and `@ai-sdk/openai-compatible` (the provider\n default) to Chat Completions. A Zen model released after this build routes\n once the catalog lists it, without a Codewhale release. The interactive TUI\n and `codewhale exec` both load the persisted catalog; refresh it with\n `codewhale models --update`.\n\nThe curated snapshot:\n\n- Responses: `gpt-6-astra`, `gpt-6-sol`, `gpt-6-luna`, `gpt-5.6-sol`,\n `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5`, `gpt-5.5-pro`, `gpt-5.4`,\n `gpt-5.4-pro`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.3-codex`,\n `gpt-5.3-codex-spark`, `gpt-5.2`, `gpt-5.2-codex`, `gpt-5.1`,\n `gpt-5.1-codex`, `gpt-5.1-codex-max`, `gpt-5.1-codex-mini`, `gpt-5`,\n `gpt-5-codex`, `gpt-5-nano`, `grok-4.7`, `grok-4.6`, `grok-4.5`,\n `grok-build-0.1`, `muse-spark-1.3`, `muse-spark-1.3-contributor-free`,\n `muse-spark-1.2`, `muse-spark-1.2-contributor`,\n `muse-spark-1.2-contributor-free`.\n- Anthropic Messages: `claude-fable-5-1`, `claude-fable-5`,\n `claude-opus-5-5`, `claude-opus-5`, `claude-opus-4-8`, `claude-opus-4-7`,\n `claude-opus-4-6`, `claude-opus-4-5`, `claude-sonnet-5-5`,\n `claude-sonnet-5`, `claude-sonnet-4-6`, `claude-sonnet-4-5`,\n `claude-sonnet-4`, `claude-haiku-4-5`, `qwen3.8-flash`, `qwen3.7-max`,\n `qwen3.7-plus`, `qwen3.6-plus`, `qwen3.5-plus`.\n- Chat Completions: `deepseek-v4.1-flash`, `deepseek-v4-pro`,\n `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp`, `minimax-m3`,\n `minimax-m2.7`, `minimax-m2.5`, `glm-5.3-flash`, `glm-5.3`, `glm-5.2`,\n `glm-5.1`, `glm-5`, `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.6`, `kimi-k2.5`,\n `qwen3.8-max`, `big-pickle`, `space-bunny-free`,\n `longcat-2.5-preview-free`, `mimo-v2.6-flash-free`, `mimo-v2.5-free`,\n `ling-3.0-flash-fin-free`, `north-mini-code-free`,\n `nemotron-3-ultra-free`, `nemotron-3.5-lightning-free`,\n `deepseek-v4-flash-free`.\n\nThese fail closed locally, with the reason in the error, instead of reaching\nZen: Gemini (`@ai-sdk/google`, Google's model-specific protocol, which Codewhale\ndoes not speak); a catalog row naming any other package; a catalog row Models.dev\nmarks `deprecated`; and a model neither source lists. A miss never falls back to\nanother Zen wire shape, including when a custom Zen base URL is configured.\n\n### Concentrate Notes\n\nConcentrate (`concentrate`) is an OpenAI **Responses**-compatible gateway:\n`POST https://api.concentrate.ai/v1/responses` with `Authorization: Bearer\n`. It is opt-in and **BYOK only**.\n\n- **Model ids pass through.** A plain catalog id (`gpt-5.6-sol`,\n `deepseek-v4-pro`) lets the gateway choose the upstream provider;\n `provider/model` (`openai/gpt-5.6-sol`) pins the upstream provider inside the\n gateway; `concentrate/auto` sends the gateway's own `auto` router. Codewhale's\n bare `--model auto` remains the resolver sentinel (provider default), which is\n why the gateway router has its own explicit spelling. Once streaming begins\n the gateway commits to one provider ([auto routing](https://concentrate.ai/docs/api-reference/endpoint/auto-routing)).\n- **Wire.** Fixed Responses dialect. The request carries only documented\n fields — `model`, `input`, `stream`, `max_output_tokens`, `tools`,\n `tool_choice`, `parallel_tool_calls`, `reasoning.effort` — and the system\n prompt travels as a leading `system` input item because `instructions` is\n not in the gateway's [parameter reference](https://concentrate.ai/docs/api-reference/endpoint/request-parameters).\n Streaming follows the typed `response.*` SSE events and ends on\n `response.completed` without a `[DONE]` sentinel ([streaming](https://concentrate.ai/docs/api-reference/endpoint/streaming)).\n- **Errors.** `{ \"error\", \"message\", \"model\"?, \"retry_after\"? }` with 400\n (bad request / unknown model), 401 (invalid key), 402 (insufficient\n credits), 424 (upstream provider unavailable), 429 (rate limit) —\n surfaced verbatim and classified by message ([errors](https://concentrate.ai/docs/api-reference/endpoint/errors)).\n- **Catalog.** `GET /v1/models` needs no key\n ([list models](https://concentrate.ai/docs/api-reference/endpoint/list-models)); the live rows\n are provider-scoped and unclaimed (no pricing or capability claims).\n- **Commercial boundary.** Concentrate's [Terms of Service](https://concentrate.ai/legal/terms-of-service)\n forbid reselling, white-labeling, or providing the service on a\n service-bureau basis without written consent, and its\n [Acceptable Use Policy](https://concentrate.ai/legal/acceptable-use-policy)\n forbids sharing or sublicensing keys. Codewhale therefore ships **no**\n Codewhale-owned Concentrate key, no stored customer key, no default or\n managed routing, and no markup — activation of any hosted lane is gated on\n written consent, terms, and billing approval (tracked in the Codewhale ops\n evidence checklist `concentrate-gateway-20260829/CHECKLIST.md`). Public\n pricing states \"No platform markup on tokens\".\n- **Self-test without a key.** `scripts/concentrate-selftest.sh` boots a\n local stub that speaks the documented contract and drives the real\n `codewhale exec` path through it, asserting the URL, bearer header, model\n passthrough, streaming, and the completed-turn receipt. No network call\n leaves the machine and no account is required.\n\n### Hugging Face Provider vs MCP vs Hub\n\nCodewhale's `huggingface` provider ID is only the OpenAI-compatible chat\ninference route through Hugging Face Inference Providers. It is selected with\n`/provider huggingface`, `CODEWHALE_PROVIDER=huggingface`, or\n`provider = \"huggingface\"`.\n\nHugging Face MCP is a separate external-tool route. Configure it through the\nMCP config described in `docs/MCP.md`, preferably using the settings-generated\nsnippet from . In the TUI, `/hf mcp status`\nchecks whether the Hugging Face MCP server appears in the resolved MCP config,\n`/hf mcp setup` prints the settings workflow and a placeholder-only shape, and\n`/hf concepts` explains the provider/MCP/Hub distinction.\n\nHub publishing or repository management remains explicit user action through\nHub-native tooling such as `huggingface_hub` or git. The `/hf` helper does not\nupload to Hugging Face and does not perform direct Hugging Face Hub HTTP search.\n\n### Xiaomi MiMo Notes\n\n`xiaomi-mimo` defaults to `mimo-v2.5-pro` for long-context reasoning and coding\nwork. The chat picker also exposes `mimo-v2.5-pro-ultraspeed` and the latest\nOmni model `mimo-v2.5`. Xiaomi MiMo TTS is available through\n`codewhale --provider xiaomi-mimo speech \"text\" --model tts` (or the `tts`\nalias). In Act and Operate, the provider-specific `speech` / `tts` tools are\navailable through deferred discovery when the Xiaomi MiMo route is configured.\n\n`/provider xiaomi-mimo ultraspeed` and `/provider xiaomi-mimo pro-ultraspeed`\nboth select `mimo-v2.5-pro-ultraspeed`. Speech aliases such as `tts`,\n`voice-design`, and `voice-clone` are separate from normal chat defaults.\n\nToken Plan keys default to the Singapore endpoint\n`https://token-plan-sgp.xiaomimimo.com/v1`. If your MiMo account is provisioned\nfor the China region, set `base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"`\nexplicitly in `[providers.xiaomi_mimo]` or set `mode = \"token-plan-cn\"`. Europe\nToken Plan accounts can set\n`base_url = \"https://token-plan-ams.xiaomimimo.com/v1\"` or use\n`mode = \"token-plan-ams\"`; `mode = \"pay-as-you-go\"`\nselects the standard API endpoint and standard MiMo key family. Xiaomi Token\nPlan docs and console expose credit/quota semantics, but Codewhale does not\ncurrently have a documented balance endpoint to poll, so cost display remains\nunknown rather than reusing token-price estimates from another provider.\n\nVoice-design and voice-clone shorthands map to `mimo-v2.5-tts-voicedesign` and\n`mimo-v2.5-tts-voiceclone`. Xiaomi's current\n[image-understanding guide](https://platform.xiaomimimo.com/docs/en-US/usage-guide/multimodal-understanding/image-understanding)\nincludes `mimo-v2.5` for image input. Codewhale exposes image analysis through the\nseparate `[vision_model]` / `image_analyze` path; set that model to\n`mimo-v2.5` when using MiMo for vision.\n\n### OpenRouter-Compatible Base URLs\n\nOpenRouter-compatible gateways should usually stay on the `openrouter`\nprovider with a provider-scoped `base_url` override instead of moving through\nthe generic `openai` route. That keeps OpenRouter-style reasoning, streaming,\ncache usage, and namespaced wire model parsing attached to the selected route:\n\n```toml\nprovider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"sk-...\"\nbase_url = \"https://openrouter-compatible.example/v1\"\nmodel = \"deepseek/deepseek-v4-pro\"\n```\n\nCodewhale preserves the `deepseek/` wire-model prefix under the OpenRouter\nprovider scope; it does not infer a switch to the direct DeepSeek provider from\nthat model string. Cache fields such as `prompt_cache_hit_tokens`,\n`prompt_cache_miss_tokens`, and `prompt_tokens_details.cached_tokens` are\nparsed when the upstream gateway sends them. If a key/account type omits those\nfields, Codewhale treats them as absent for that response rather than as a\ndifferent provider route.\n\nOrcaRouter (`https://api.orcarouter.ai/v1`) is a dedicated named route\n([OrcaRouter](https://www.orcarouter.ai)) that speaks the same OpenAI\nChat Completions wire protocol and serves the same namespaced\n`vendor/model` catalog. It does not need an OpenRouter-compatible\n`base_url` override: select `provider = \"orcarouter\"` and its namespaced\nwire models (for example `deepseek/deepseek-v4-pro` or its own\n`orcarouter/auto` router) pass through verbatim, exactly as they do on the\nOpenRouter provider scope.\n\n#### OrcaRouter credentials: API key or OAuth 2.0 + PKCE\n\nOrcaRouter accepts two credential sources, and both write the same durable\n`sk-orca-…` key to the same `orcarouter` secret-store slot with\n`auth_mode = \"api_key\"`:\n\n- **API key** — `codewhale auth set --provider orcarouter`, `/provider`, or\n `ORCAROUTER_API_KEY`. Use this when you already have a key.\n- **Connect with OrcaRouter** — `codewhale auth orcarouter` (CLI) or\n `/auth orcarouter` (in-session). This runs an OAuth 2.0 authorization-code\n flow with PKCE (S256) on a loopback redirect. The browser is sent to\n `GET https://www.orcarouter.ai/auth` with `callback_url`, `code_challenge`,\n `code_challenge_method=S256`, `state`, `app_name`, and `scope`; the code is\n exchanged at `POST https://www.orcarouter.ai/api/v1/auth/keys` for a\n durable API key. There is no client secret and no pre-registered redirect\n URI; state is compared in constant time before the code is used.\n\nAuthentication and inference use different origins: the consent and exchange\nlive on `https://www.orcarouter.ai`; models and chat live on\n`https://api.orcarouter.ai/v1`. Neither is derived from the other, and\n`/v1/auth/keys` on the API origin is not the exchange route. Self-hosted\ndeployments can override each origin explicitly with `ORCA_AUTH_BASE_URL`\n(auth) and `ORCA_API_BASE_URL`/`ORCAROUTER_BASE_URL` (inference); the explicit\nvalue wins. Remote origins must be HTTPS; plain HTTP is accepted only on\nloopback.\n\nA PKCE-issued key is a durable API key, **not** a refresh token: Codewhale\nstores it, reuses it across restarts, and never sends a refresh grant. Revoke\nthe key on the OrcaRouter console (`/auth orcarouter-revoke` clears the local\ncopy) and sign in again to get a new one. A 401 from the relay marks that\ncredential generation as needing re-authentication rather than silently\nretrying.\n\nThe chat model list is discovered live from `GET https://api.orcarouter.ai/v1/models`\nwith the configured key. OrcaRouter publishes `supported_endpoint_types` on\nevery row, so the chat selector keeps only rows advertising `openai`,\n`anthropic`, `gemini`, or `openai-response`, and drops image-generation,\nvideo, and rerank rows instead of guessing from a model name. A row that\nstates `architecture.input_modalities` with `image` is image-input capable;\nrows that state no architecture are treated as unknown, not as text-only.\n\n### Recent OpenRouter Large Models\n\nOpenRouter completions and static registry rows include the April 2026 onward\nlarge models verified through OpenRouter's model metadata:\n`arcee-ai/trinity-large-thinking`, `qwen/qwen3.6-flash`,\n`qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`,\n`qwen/qwen3.6-plus`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`,\n`xiaomi/mimo-v2.5`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`,\n`z-ai/glm-5.1`, `z-ai/glm-5.2`, `z-ai/glm-5-turbo`, `tencent/hy3-preview`,\n`google/gemma-4-31b-it`, `google/gemma-4-26b-a4b-it`, and\n`nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`.\n`minimax/minimax-m3` was added from OpenRouter's May 31, 2026 listing as a 1M\ncontext multimodal model for coding, tool use, and long-horizon agentic work.\n`GLM-5.3` is now the default direct Z.AI Coding Plan model; `GLM-5.2` /\n`z-ai/glm-5.2` remain available (explicit selections keep their own id),\n`GLM-5.1` / `z-ai/glm-5.1` remain available as the smaller model,\n`GLM-5.3-Flash` / `z-ai/glm-5.3-flash` is the faster/explore sibling of\n`GLM-5.3`, and `GLM-5-Turbo` / `z-ai/glm-5-turbo` remains the faster sibling\nof `GLM-5.2`.\n`GLM-5.3` / `z-ai/glm-5.3` and `GLM-5.3-Flash` / `z-ai/glm-5.3-flash` are\nfirst-class picker ids on the Z.ai and OpenRouter routes (`/model` after\n`/provider zai`, or `model = \"GLM-5.3-Flash\"`).\nLimits and reasoning options for 5.3 are inherited from\n`GLM-5.2` until Z.ai publishes distinct 5.3 metadata, and 5.3 carries no\nprice. Flash ships the published $0.15/$0.50 list. A live call can still\n429 with entitlement code 1311 on accounts that are not provisioned for 5.3.\n\n## Static Model Registry\n\nThis table describes the static registry, which `codewhale model resolve` (and\nthe `[model]` config surface) still use to resolve selector aliases.\n\n`codewhale model resolve` projects the reviewed `selections` in\n`crates/config/assets/catalog_corrections.json` through\n`crates/agent/src/lib.rs`. There is no independent Rust model roster. These\nordered aliases and flags are compatibility metadata, not account availability\nor executable route permission. This differs from live `/models` discovery.\nUse `/models` or `codewhale models` to fetch model IDs from the active API\nendpoint when the endpoint supports model listing.\n\n| Provider | Static registry entries | Tool calls | Registry reasoning flag |\n| --- | --- | --- | --- |\n| `deepseek` | `deepseek-v4-pro`, `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp` | yes | yes |\n| `nvidia-nim` | `deepseek-ai/deepseek-v4-pro`, `deepseek-ai/deepseek-v4-flash` | yes | yes |\n| `openai` | `deepseek-v4-pro`, `deepseek-v4-flash`, `gpt-5.6`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` | yes | yes |\n| `atlascloud` | `deepseek-ai/deepseek-v4-flash`, `deepseek-ai/deepseek-v4-pro` | yes | yes |\n| `wanjie-ark` | `deepseek-reasoner` | yes | yes |\n| `volcengine` | `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash` | yes | yes |\n| `openrouter` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`, `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `minimax/minimax-m2.7`, `xiaomi/mimo-v2.5-pro`, `xiaomi/mimo-v2.5`, `qwen/qwen3.6-flash`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`, `qwen/qwen3.6-plus`, `qwen/qwen3.7-max`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`, `z-ai/glm-5.1`, `z-ai/glm-5.2`, `z-ai/glm-5.3`, `z-ai/glm-5.3-flash`, `z-ai/glm-5-turbo`, `tencent/hy3-preview`, `google/gemma-4-31b-it`, `google/gemma-4-26b-a4b-it`, `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`, `nvidia/nemotron-3-ultra-550b-a55b` | yes | yes |\n| `orcarouter` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`, `orcarouter/auto` | yes | yes |\n| `xiaomi-mimo` | `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, `mimo-v2.5`; speech/TTS IDs are selected through `codewhale speech` / `tts` | yes | yes for chat models; no for speech/TTS models |\n| `novita` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | yes | yes |\n| `fireworks` | `accounts/fireworks/models/deepseek-v4-pro` | yes | yes |\n| `siliconflow` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes |\n| `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` |\n| `moonshot` | `kimi-k2.7-code`, `kimi-k2.6` | yes | yes |\n| `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 |\n| `stepfun` | `step-3.7-flash` (default), `step-5-preview`, `step-3.5-flash`, `step-3.5-flash-2603` | yes | yes |\n| `minimax` | `MiniMax-M3`, `MiniMax-M2.7`, `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `MiniMax-M2.1`, `MiniMax-M2.1-highspeed`, `MiniMax-M2` | yes | yes |\n| `minimax-anthropic` | `MiniMax-M3`, `MiniMax-M2.7` | yes | yes |\n| `sglang` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes |\n| `vllm` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes |\n| `ollama` | live local tag; custom tags pass through when provider hint is `ollama` | yes | no |\n| `ollama-cloud` | `gpt-oss:120b`; arbitrary provider-owned model IDs pass through | yes | yes |\n| `huggingface` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | no |\n| `modelscope` | `Qwen/Qwen3.5-397B-A17B`, `Qwen/Qwen3.5-122B-A10B`, `Qwen/Qwen3.5-27B`, `Qwen/Qwen3.5-35B-A3B`, `Qwen/Qwen3.8-27B`, `Qwen/Qwen3.8-Flash-Next`, `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Pro-0813`, `deepseek-ai/DeepSeek-V4.1-Flash`, `ZhipuAI/GLM-4.7-Flash`, `ZhipuAI/GLM-5.2` | yes | no |\n| `deepinfra` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes |\n| `together` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash`, `thinkingmachines/inkling` | yes | yes |\n| `openai-codex` | `gpt-5.5` | yes | yes |\n| `anthropic` | `claude-opus-5`, `claude-opus-4-8`, `claude-sonnet-5`, `claude-sonnet-4-6`, `claude-fable-5`, `claude-haiku-4-5` | yes | yes except `claude-haiku-4-5` |\n| `openmodel` | `deepseek-v4-flash`; provider-scoped custom model IDs pass through | yes | model-dependent |\n| `sakana` | `fugu`, `fugu-ultra-20260615` | yes | yes for `fugu-ultra-20260615` |\n| `longcat` | `LongCat-2.0` | yes | yes |\n| `opencode-go` | `deepseek-v4-pro`, `grok-4.5`, `glm-5.2`, `glm-5.1`, `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.6`, `deepseek-v4-flash`, `mimo-v2.5`, `mimo-v2.5-pro` | yes | yes |\n| `meta` | `muse-spark-1.2` | yes | yes |\n| `xai` | `grok-4.6`, `grok-4.7`, `grok-4.5`, `grok-4.3`, `grok-build`, `grok-composer-2.5-fast`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning` | yes | yes for `grok-4.6`, `grok-4.7`, `grok-4.5`, `grok-4.3`, `grok-build`, and `grok-4.20-0309-reasoning` |\n| `google` | `gemini-3.1-pro-preview`, `gemini-3-pro-preview`, `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-2.5-pro`, `gemini-2.5-flash` | yes | yes except `gemini-3.5-flash-lite` |\n| `mistral` | `mistral-code-latest`, `mistral-medium-latest`, `mistral-small-latest`, `mistral-large-latest` | yes | yes for Medium and Small (`reasoning_effort` `none` or `high` on exact first-party routes); deprecated native Magistral remains an always-on explicit compatibility ID; no for Code and Large |\n| `modelstudio-token-plan`, `modelstudio-coding-plan` | `qwen3.8-max`, `qwen3.8-max-preview`, `qwen3.7-plus`, `qwen3.7-max`, `qwen3.6-flash`, `deepseek-v4-pro`, `deepseek-v4-flash-0731`, `glm-5.2` | yes | yes |\n| `csdn` | `glm_for_coding`; other marketplace model IDs pass through | yes | yes |\n\nAtlasCloud keeps the same default model as the config layer and adds\nprovider-scoped aliases for the Pro and Flash rows. Other AtlasCloud model IDs\nshould still be selected through `ATLASCLOUD_MODEL`, config, or live model\nlisting when available.\n\n## Capability Metadata\n\n`codewhale doctor --json` exposes the `capability` object. It is static\nmetadata, not a live API probe. Current fields are:\n\n`resolved_provider`, `resolved_model`, `context_window`, `max_output`,\n`thinking_supported`, `cache_telemetry_supported`, and `request_payload_mode`.\n\nWhen configuration cannot be loaded or validated, `doctor --json` exits\nnonzero and prints a bounded, secret-redacted JSON error envelope with\n`status = \"error\"` and `error.kind = \"config_validation\"` instead of emitting\nmisleading route or capability metadata.\n\nMost shipped providers use the Chat Completions request payload mode. Native\nMessages routes, including `minimax-anthropic`, use `/v1/messages`, and\n`openai-codex` uses Responses.\n\nFor OpenAI-compatible gateways or self-hosted runtimes whose real window\ndiffers from the static table, set `[providers.] context_window = N`.\nThe configured value becomes the route-effective context window for prompts,\ncontext-pressure checks, compaction, and output-cap budgeting.\n\n`max_output` is optional and truthful: it is `null` (and omitted from the\ncapability struct on the wire) when the route publishes no output maximum we\ncan stand behind — the Kimi Code membership `kimi-for-coding` family is the\ncanonical example, since the membership catalog owns their limits. An unknown\noutput ceiling is never backfilled with a placeholder, and it applies **no**\ncompatibility clamp to a turn's requested `max_tokens`; only a concrete\nroute/offering maximum narrows the request. A model the catalogue simply has no\nrow for is a different fact — absence is not permission, so an uncatalogued id\nkeeps a conservative ceiling. The \"Max output metadata\" column below reads\n`unknown` wherever no documented maximum exists.\n\nThe exact Kimi Code membership roster contains `k3`, `k3-256k`,\n`kimi-for-coding`, and `kimi-for-coding-highspeed`. The two K3 ids share the\nsame reasoning and fixed-sampling contract; `k3-256k` stays at 262,144 tokens,\nwhile bare `k3` can use an entitled 1M override.\n\n| Provider/model class | Context window | Max output metadata | Thinking support | Cache telemetry | FIM endpoint |\n| --- | --- | --- | --- | --- | --- |\n| DeepSeek V4 (`deepseek-v4-pro`, `deepseek-v4-flash`) | 1,000,000 | 384,000 | yes | yes | DeepSeek beta only |\n| DeepSeek V4 Flash Vision experimental (`deepseek-v4-flash-vision-exp`) | 1,000,000 inherited from Flash | 384,000 inherited from Flash | yes, inherited | yes, inherited | not claimed; Chat Completions route only |\n| DeepSeek compatibility aliases (`deepseek-chat`, `deepseek-reasoner`) | 1,000,000 | 384,000 | yes | yes | DeepSeek beta only |\n| NVIDIA NIM V4 registry models | 1,000,000 | 384,000 | yes | yes | not documented in code |\n| Volcengine Ark V4 model IDs | 1,000,000 | 384,000 | yes | yes | not documented in code |\n| OpenRouter, Novita, Fireworks, SiliconFlow, SGLang, and vLLM V4 model IDs | 1,000,000 | 384,000 | yes | no | not documented in code |\n| Xiaomi MiMo `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, `mimo-v2.5` | 1,000,000 | 131,072 | yes | no | not documented in code |\n| OpenRouter Qwen 3.6 Flash / Plus | 1,000,000 | 65,536 | yes | no | not documented in code |\n| OpenRouter Qwen 3.6 35B / 27B | 262,144 | 262,140 | yes | no | not documented in code |\n| OpenRouter Qwen 3.6 Max Preview | 262,144 | 65,536 | yes | no | not documented in code |\n| OpenAI API `gpt-5.5` | 1,050,000 | 128,000 | yes | no | not documented in code |\n| OpenAI API `gpt-5.6`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` | 1,050,000 | 128,000 | yes | no | not documented in code |\n| Anthropic API `claude-opus-5`, `claude-opus-4-8`, `claude-sonnet-5`, `claude-sonnet-4-6`, `claude-fable-5` | 1,000,000 | 128,000 | yes | yes | not documented in code |\n| Google Gemini API `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-3.1-pro-preview`, `gemini-2.5-pro`, `gemini-2.5-flash` | 1,048,576 | 65,536 | model-dependent | no | not documented in code |\n| Meta Model API `muse-spark-1.2` | 1,000,000 | 32,000 | yes | no | not documented in code |\n| ChatGPT plan route (`openai-codex`) | conservatively budgeted; account listing does not state a window | no output cap sent in preview | model dependent | no | public `/v1/responses`; no context limit is inferred from account eligibility |\n| OpenModel default/custom model IDs | 200,000 fallback unless model metadata or config overrides it | 64,000 fallback | model-dependent | no | route uses Messages payload at `/v1/messages` |\n| Wanjie Ark `reasoner` / `r1` model IDs | 128,000 | unknown (no documented maximum) | yes | no | not documented in code |\n| Direct Arcee API `trinity-large-thinking` | 262,144 | 262,144 | yes | no | not documented in code |\n| Direct Arcee API `trinity-large-preview` | 262,144 | unknown (no documented maximum) | no in doctor capability metadata | no | not documented in code |\n| Direct Moonshot `kimi-k3` | 1,048,576 | 1,048,576 documented maximum; 131,072 provider default | yes | no | exact route uses `max_completion_tokens` and omits fixed sampling fields ([K3 quickstart](https://platform.kimi.ai/docs/guide/kimi-k3-quickstart)) |\n| Kimi Code membership `k3` | 262,144 safe baseline; 1,048,576 with an explicit entitled-plan override | 131,072 conservative default ceiling; membership maximum is not published | yes | no | exact `https://api.kimi.com/coding/v1` route |\n| Kimi Code membership `k3-256k` | 262,144 fixed | 131,072 conservative default ceiling; membership maximum is not published | yes | no | exact `https://api.kimi.com/coding/v1` route |\n| Direct Moonshot/Kimi K2.7/K2.6 (`kimi-k2.7-code`, `kimi-k2.7-code-highspeed`, `kimi-k2.6`) | 262,144 | 32,768 | yes | no | provider-reported bundled catalog |\n| Kimi Code membership `kimi-for-coding`, `kimi-for-coding-highspeed` | 262,144 | unknown — the membership catalog owns these limits and no client-side ceiling is claimed | yes | no | exact `https://api.kimi.com/coding/v1` route |\n| Direct Z.AI `GLM-5.3` (default) | 1,000,000 | 131,072 | yes | no | live on the GLM Coding Plan; limits inherited from `GLM-5.2` until Z.ai publishes distinct 5.3 numbers; no USD price |\n| Direct Z.AI `GLM-5.3-Flash` | 1,000,000 | 131,072 | yes | no | natively multimodal; $0.15/$0.50 list (2026-08-26); faster/explore sibling of `GLM-5.3` |\n| Direct Z.AI `GLM-5.2` | 1,000,000 | 131,072 | yes | no | not documented in code |\n| Direct Z.AI `GLM-5.1` | 202,752 | 131,072 | yes | no | not documented in code |\n| Direct Z.AI `GLM-5-Turbo` | 202,752 | 131,072 | yes | no | faster/explore sub-agent sibling |\n| Direct MiniMax `MiniMax-M3` | 1,000,000 | 524,288 | yes | no | not documented in code |\n| Direct MiniMax M2.x models | 204,800 | unknown until MiniMax output metadata is promoted | yes | no | not documented in code |\n| MiniMax Messages route (`MiniMax-M3`, `MiniMax-M2.7`) | model-specific values above | model-specific values above | yes | no | route uses `/anthropic/v1/messages` |\n| Generic `openai` and AtlasCloud | 128,000 | unknown (no documented maximum) | no in doctor capability metadata | no | not documented in code |\n| Ollama | 8,192 | unknown (no documented maximum) | no | no | not documented in code |\n| Hugging Face Inference Providers V4 model IDs | 131,072 | unknown (no documented maximum) | yes | no | not documented in code |\n| Other recognized DeepSeek model IDs | 128,000 unless the model name carries an explicit `Nk` hint | unknown (no documented maximum) | no unless V4/reasoner logic matches | DeepSeek/NIM only | DeepSeek beta only |\n\nMiniMax M3 uses input-length and service tiers. Codewhale omits\n`service_tier`, so requests use the standard tier and cost estimates select the\ncorrect standard rate from total input usage. Priority rates are listed to keep\nthe official tier structure visible. Prices are USD per million tokens.\n\n| Model / service tier | Input length | Input | Output | Cache read | Cache write |\n| --- | --- | ---: | ---: | ---: | ---: |\n| `MiniMax-M3` standard | up to 512,000 input tokens | $0.30 | $1.20 | $0.06 | not published |\n| `MiniMax-M3` standard | over 512,000 input tokens | $0.60 | $2.40 | $0.12 | not published |\n| `MiniMax-M3` priority | up to 512,000 input tokens | $0.45 | $1.80 | $0.09 | not published |\n| `MiniMax-M3` priority | over 512,000 input tokens | $0.90 | $3.60 | $0.18 | not published |\n| `MiniMax-M2.7` standard | all supported inputs | $0.30 | $1.20 | $0.06 | $0.375 |\n\nThese values come from the [MiniMax pay-as-you-go pricing\nguide](https://platform.minimax.io/docs/guides/pricing-paygo). M3 thinking is\nadaptive or disabled; the OpenAI-compatible API defaults to adaptive and the\nAnthropic-compatible API defaults to disabled. M2.7 thinking cannot be\ndisabled. Codewhale sends explicit controls when the user selects a reasoning\nmode.\n\nTool-call support is tracked separately by the static `ModelRegistry` and by\nthe endpoint's ability to accept OpenAI-compatible `tools` payloads. A custom\nOpenAI-compatible or local endpoint can still reject tool calls even if\nCodewhale can send the schema.\n\n### Hugging Face Inference Providers Notes\n\nThe shipped Hugging Face route targets the OpenAI-compatible Inference Providers\nrouter at `https://router.huggingface.co/v1`. Configure auth with\n`HUGGINGFACE_API_KEY` first, or `HF_TOKEN` as a fallback. Configure the endpoint\nwith `HUGGINGFACE_BASE_URL` first, or `HF_BASE_URL` as a fallback; configure the\nmodel with `HUGGINGFACE_MODEL` first, or `HF_MODEL` as a fallback.\n\nThis route does not imply Hub browsing, model-card metadata, dataset access,\nJobs, uploads, or export. Those remain explicit Model Lab work items so\nprovider auth and artifact movement stay separate.\n\n### When a Local Model Prints Tool JSON\n\nCodewhale only executes tools when the provider returns Chat Completions\n`tool_calls` or streamed `delta.tool_calls`. If a local model prints text such\nas `{\"name\":\"File\",\"arguments\":{\"action\":\"search_content\",...}}` in the\nassistant message, that is ordinary model output, not an executable tool\nrequest.\n\nFor OpenAI-compatible or local runtimes, check:\n\n- The endpoint accepts the `tools` array in `/v1/chat/completions` requests.\n- The selected model or chat template is configured for function/tool calls.\n- The server returns `tool_calls` in the response rather than plain JSON text.\n- The compatibility layer does not strip tools before forwarding the request.\n- If in doubt, test a small `File` `read` or `search_content` action against a\n known tool-calling model before debugging Codewhale's tool registry.\n\nChanging `provider`, `base_url`, or `model` can select a route that supports the\nOpenAI-compatible payload shape, but Codewhale cannot convert arbitrary JSON\ntext into a trusted tool call after the model has emitted it as prose.\n\nDeepSeek will retire `deepseek-chat` and `deepseek-reasoner` on 2026-07-24 at\n15:59 UTC. Codewhale migrates either name to `deepseek-v4-flash` before a\nrequest reaches DeepSeek's first-party OpenAI or Anthropic endpoint. If no\nreasoning tier was configured, `deepseek-chat` also migrates to `off` and\n`deepseek-reasoner` to `high`, preserving their former non-thinking / thinking\nintent; an explicit `reasoning_effort` remains authoritative. The mapping is\ndeliberately not global: Wanjie Ark, aggregators, self-hosted runtimes, and\ncustom endpoints continue to own their model ids.\n\n## Reasoning Effort\n\n`/reasoning ` (and the `reasoning_effort` config key) is translated to\neach provider's wire dialect by the client before the request is sent. `off`\ndisables thinking where the route supports it. Both exact K3 routes map `off`\nto their lowest supported tier, `low`, and the model is never switched to\nsatisfy `off` — but they do so for different reasons:\n\n- **Kimi Code membership K3** (exact `https://api.kimi.com/coding/v1` with\n `model = \"k3\"` or `model = \"k3-256k\"`) — the membership roster declares K3 always-thinking, so `off`\n cannot be honored without changing what the model is. The clamp preserves the\n fixed K3 identity.\n- **Direct Moonshot K3** (exact `https://api.moonshot.ai/v1` with\n `model = \"kimi-k3\"`) — this clamp is *defensive*, not a documented contract.\n The direct platform publishes no `off` state for K3, and Codewhale will not\n assert a fixed-thinking guarantee it cannot verify for a given key's\n entitlement, so the requested `off` is normalized to the lowest tier with the\n live entitlement left unknown.\n\nNormal dispatched\n`auto` uses Codewhale's auto-reasoning selector and sends a concrete tier;\nonly an omitted reasoning setting leaves the provider default in control.\nProviders marked \"omitted\" receive no reasoning fields at all for that tier.\n\n| Provider | `off` | `low`/`medium`/`high` | `max`/`xhigh` |\n| --- | --- | --- | --- |\n| `deepseek`, `deepseek-cn`, `siliconflow`, `siliconflow-CN`, `sglang`, `volcengine`, `atlascloud` | `thinking: {type: disabled}` | `reasoning_effort: \"high\"` + `thinking: {type: enabled}` | `reasoning_effort: \"max\"` + `thinking: {type: enabled}` |\n| `openrouter`, `novita`, other `together` models | `thinking: {type: disabled}` | `reasoning_effort` pass-through + `thinking: {type: enabled}` | `reasoning_effort: \"xhigh\"` + `thinking: {type: enabled}` |\n| `together` + `thinkingmachines/inkling` | `reasoning_effort: \"none\"` | exact `minimal`/`low`/`medium`/`high` `reasoning_effort` | `reasoning_effort: \"max\"` |\n| Direct Moonshot `kimi-k3` at exact `https://api.moonshot.ai/v1` | top-level `reasoning_effort: \"low\"` (effective normalization) | top-level `reasoning_effort: \"low\"` / `\"high\"` (`medium` becomes `high`) | top-level `reasoning_effort: \"max\"` |\n| Kimi Code membership `k3`, `k3-256k` at exact `https://api.kimi.com/coding/v1` | `thinking: {type: enabled, effort: \"low\"}` (effective normalization) | `thinking: {type: enabled, effort: \"low\" | \"high\"}` | `thinking: {type: enabled, effort: \"max\"}` |\n| Other `moonshot` routes | `thinking: {type: disabled}` | `thinking: {type: enabled}` | `thinking: {type: enabled}` |\n| `ollama` | `think: false` | `think: true` | `think: true` |\n| `ollama-cloud` | `reasoning_effort: \"none\"` | exact `low`/`medium`/`high` `reasoning_effort` | `reasoning_effort: \"max\"` |\n| `xiaomi-mimo` | `thinking: {type: disabled}` | `thinking: {type: enabled}` | `thinking: {type: enabled}` |\n| First-party `minimax` `MiniMax-M3` | `reasoning_split: true` + `thinking: {type: disabled}` | `reasoning_split: true` + `thinking: {type: adaptive}`; effective tier granularity unavailable | `reasoning_split: true` + `thinking: {type: adaptive}`; effective tier granularity unavailable |\n| First-party Z.ai `GLM-5.2` | `thinking: {type: disabled}`; no `reasoning_effort` | enabled thinking; only effective `high` adds `reasoning_effort: \"high\"` | enabled thinking + `reasoning_effort: \"max\"` |\n| First-party Z.ai `GLM-5.3` | `thinking: {type: disabled}`; no `reasoning_effort` | enabled thinking; only effective `high` adds `reasoning_effort: \"high\"` | enabled thinking + `reasoning_effort: \"max\"` |\n| First-party Z.ai `GLM-5.3-Flash` | `thinking: {type: disabled}`; no `reasoning_effort` | enabled thinking; only effective `high` adds `reasoning_effort: \"high\"` | enabled thinking + `reasoning_effort: \"max\"` |\n| First-party Z.ai `GLM-5-Turbo` | `thinking: {type: disabled}` | enabled thinking; effort granularity unavailable | enabled thinking; effort granularity unavailable |\n| Compatible gateways configured as `zai` | omitted; effective unavailable | omitted; effective unavailable | omitted; effective unavailable |\n| `nvidia-nim` | `chat_template_kwargs.thinking: false` | `chat_template_kwargs`: `thinking: true` + `reasoning_effort: \"high\"` | `chat_template_kwargs`: `thinking: true` + `reasoning_effort: \"max\"` |\n| `vllm` | `chat_template_kwargs.enable_thinking: false` | `chat_template_kwargs.enable_thinking: true` + `reasoning_effort` low/medium/high | `chat_template_kwargs.enable_thinking: true` + `reasoning_effort: \"high\"` (vLLM has no max tier) |\n| `arcee`, `huggingface` | omitted | `reasoning_effort` pass-through | `reasoning_effort: \"high\"` |\n| `fireworks` | omitted | `reasoning_effort: \"high\"` | `reasoning_effort: \"max\"` |\n| `openai`, `wanjie-ark`, `telecomjs` | omitted | omitted | omitted |\n| `openmodel` | Anthropic Messages adapter handles thinking/output configuration | Anthropic Messages adapter handles thinking/output configuration | Anthropic Messages adapter handles thinking/output configuration |\n| `openai-codex` | Responses API `reasoning` field (handled by the Responses bridge) | Responses API `reasoning` field | Responses API `reasoning` field |\n\nAtlasCloud serves DeepSeek models, so it speaks the DeepSeek reasoning dialect,\nincluding the `max` tier (#3024).\n\nOn the exact MiniMax OpenAI-compatible Chat endpoints, `MiniMax-M3` uses\n`max_completion_tokens`. Other MiniMax models and compatible gateways retain\n`max_tokens`; the MiniMax Anthropic endpoints use the separate Messages\nadapter.\n\n## Drift Check\n\nRun this before changing provider IDs, provider TOML tables, static model\nregistry rows, or provider default strings:\n\n```bash\npython3 scripts/check-provider-registry.py\n```\n\nThe check fails when:\n\n- `docs/PROVIDERS.md` omits a canonical `ProviderKind::as_str()` ID.\n- Descriptor presentation IDs or released wire tags are duplicated or drift\n from intrinsic kinds, or a second provider enum/ordinal bridge is introduced.\n- The shipped-provider table omits or adds a `[providers.*]` TOML table.\n- The static model registry table drifts from providers used by\n `crates/agent/src/lib.rs`.\n- A provider default model or base URL constant in `crates/tui/src/config.rs`\n is no longer mentioned here.\n\n## Provider Gotchas\n\nThree failures that look like something else (measured by a provider operator\non live traffic):\n\n- **A cold model looks broken.** The first call to a model the provider has not\n loaded can take 90 to 150 seconds, then drop to a second or less once warm.\n Raise `[stream].open_timeout_secs` (default 45 seconds, at most 300) before reading a timeout as \"model unavailable\".\n- **Listed is not callable.** A catalog entry can be visible and still fail\n every call. The same error text on every attempt (for example a \"resources\n busy\" 502) is not transient: a genuinely busy upstream returns successes in\n between. Probe a model before trusting it, and stop retrying one that fails\n identically.\n- **A truncated answer can report `stop`.** A reasoning model can spend the\n whole `max_tokens` allowance on reasoning. When the provider reports\n `completion_tokens` exactly equal to the `max_tokens` the request asked for\n and the finish reason is `stop`, Codewhale treats the turn as cut at the\n ceiling rather than finished (`stop_contradicts_output_ceiling` in\n `codewhale-models`); retrying the same prompt stops at the same count, so\n raise the ceiling or shorten the task instead. No inference is made when the\n provider reports no usage.\n\n## Planned, Not Shipped Yet\n\nThese items belong to the v0.8.48+ provider-abstraction milestone or related\nprovider docs work, but they are not native shipped behavior in this checkout:\n\n- A unified `Provider` trait in `codewhale-agent` that owns env precedence,\n secret resolution, base URL normalization, auth-header construction, and\n provider metadata. Those responsibilities are still split across\n `crates/config`, `crates/secrets`, and `crates/tui/src/client.rs`.\n- Hugging Face model passport metadata in the picker, including license, base\n model, context length, chat template, tool-call support, reasoning support,\n and gated/private status.\n\n## Sign in with Claude\n\nRun `codewhale auth claude` (or `/auth claude` in a running session), open the\nprinted Claude sign-in URL, and paste the complete `code#state` callback.\n`/provider setup anthropic` also offers API-key and Claude Pro / Max choices.\nThen select `anthropic` or its `claude` alias. API keys continue to use separate\nAnthropic API billing; selecting subscription sign-in never falls back to one.\n\nCodewhale stores the grant in its private credentials directory, commits the\nprovider's credential pointer atomically, and refreshes it before requests on a\nworker. Subscription tokens go only to `https://api.anthropic.com`; a custom\nendpoint is rejected. Each request keeps the existing Messages adapter, tool\nprotocol, cache prefix, and Codewhale client identity. No Claude Code credential\nfile or keychain entry is read or modified.\n\n`codewhale auth claude-revoke` (or `/auth claude-revoke`) removes the owned local\ngrant. End remote access separately in your Claude account settings. The\nsubscription route then needs another login; select API-key billing explicitly\nto use an API key. Login and inference follow Anthropic's provider-side\navailability; local checks do not\nestablish that an account is eligible or that live subscription inference works.\n\nThe callback and JSON token exchange follow the interoperable flow used by\n[pi's Anthropic OAuth adapter](https://github.com/fivewillow/badlogic-pi-mono/blob/main/packages/ai/src/utils/oauth/anthropic.ts).\n" + "sha256": "bd1b13b1f8e7c4c83dcb921c9ac7510e2b5d4238508681d86b17403181c2b9f8", + "content": "# Provider Registry\n\n> 阅读简体中文版:[zh_hans/PROVIDERS.md](zh_hans/PROVIDERS.md)\n\nSpeech recognition, synthesis, translation and video have separate execution\npaths. See [media models](MEDIA_MODELS.md) for current support and reviewed\nupstream candidates; a chat catalogue row alone does not add a media backend.\n\nThis registry describes provider behavior that is wired into the current\nCodewhale codebase. It is intentionally conservative: shipped entries are\nlimited to provider IDs, config keys, auth paths, base URLs, model resolution,\nand capability metadata that the code already knows about.\n\nDeepSeek remains the default provider, but every entry in `ProviderKind::ALL`\nis a first-class selectable provider route. `ALL` is the catalog/picker\nsurface — one identity per vendor. Dual-wire dialect kinds (`*Anthropic`, e.g.\n`deepseek-anthropic`) and the Model Studio plan variants stay on the enum for\nserde and `provider_for_kind` but are deliberately **not** catalog rows:\na plan is `mode`/`base_url` and a dialect is `wire = openai|anthropic` on the\nprimary provider config (`crates/config/src/provider_kind.rs:221-226`). Hosted\nroutes, generic OpenAI-compatible endpoints, the OpenAI Codex/ChatGPT route,\nnative Anthropic, and local runtimes all run the same terminal harness against\nthe selected provider/model/base URL.\n\nA host reached over plain Chat Completions is an ordinary named provider,\nnot a `ProviderKind`: enum variants are reserved for distinct *wires*\n(Anthropic Messages, Codex Responses, Google thought signatures). Any such\nhost is a `[providers.]` table with a base URL, a model, and a key env\n(`docs/CONFIGURATION.md`); `/provider` and `/setup` keep a \"paste a Base URL\nand a key\" path for exactly this. Offerings come from live `GET /v1/models`\nplus the Codewhale catalog rather than a compiled roster (#5350, #6289).\n\nKnown-good hosts. These ship as bundled descriptor rows in\n`crates/config/assets/provider_descriptors.json` (compiled in by\n`crates/config/src/descriptors.rs`): each row says how to reach the host — wire,\nbase URL, key env, aliases — while model ids stay live from `GET /v1/models`\nand the Codewhale catalog; the example model is only a bootstrap hint. Verify\nagainst the vendor's own docs before trusting any value here:\n\n| Host | Base URL | Example models | API key env |\n| --- | --- | --- | --- |\n| SenseNova | `https://token.sensenova.cn/v1` | `deepseek-v4-flash` | `SENSENOVA_API_KEY` |\n| Baseten | `https://inference.baseten.co/v1` | `deepseek-ai/DeepSeek-V4-Pro` | `BASETEN_API_KEY` |\n| Groq | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | `GROQ_API_KEY` |\n| Cerebras | `https://api.cerebras.ai/v1` | `llama-3.3-70b` | `CEREBRAS_API_KEY` |\n| Command Code | `https://api.commandcode.ai/provider/v1` | `deepseek/deepseek-v4-flash` | `COMMAND_CODE_API_KEY` |\n| Alibaba Model Studio (DashScope) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `qwen3.8-flash` | `DASHSCOPE_API_KEY` |\n| AICraft | `https://aicraftapi.com/v1` | `claude-4.6-sonnet`; DeepSeek / Claude / Gemini / Qwen / GLM / MiniMax / Doubao families | `AICRAFT_API_KEY` |\n| Tsubasa | `https://api.tsubasa.sh/v1` | `tsubasa-pro`, `tsubasa-fast` (32,768-token context) | `TSUBASA_API_KEY` |\n| Cheaper Inference | `https://api.cheaperinference.com/v1` | `gpt-5.4-mini`, `claude-sonnet-5`, `gemini-3.1-pro` | `CHEAPER_INFERENCE_API_KEY` |\n| Yolo-Auto | `https://yolo-auto.com/v1` | `qwen3.8-flash`; `yolo` / `yolo-small` | `YOLO_AUTO_API_KEY` |\n\nAICraft's roster spans DeepSeek, Anthropic Claude, Google Gemini, Qwen, GLM,\nMiniMax and Doubao ids on its OpenAI-compatible endpoint. The authority is\n`GET https://aicraftapi.com/v1/models` with your key — pick a model from that\nlist, not from this table.\nTsubasa implements only `GET /v1/models` and Chat Completions. Its two public\nmodel ids share a 32,768-token context, smaller than the 128,000 tokens Codewhale assumes\nfor an unknown model, so set it on the route after saving:\n`codewhale config set providers.tsubasa.context_window 32768`.\n\nCheaper Inference is an OpenAI-compatible gateway with one key for models from\nseveral labs. Model ids are bare, such as `gpt-5.4-mini` or `claude-sonnet-5`.\nPricing varies by model and route; consult the provider’s current catalog.\nThe authority is `GET https://api.cheaperinference.com/v1/models` with your key.\nDocs: .\n\nOpenCode Zen and OpenCode Go are first-class provider routes, configured like\nany other provider below; they are not part of this table. In `/provider`,\ntype to filter the list (letters not bound to a row action); `Ctrl+T` probes the\nselected row's `/models` and records reachability only (a 2xx is not\nmodel-ready).\n\nSources to keep in sync:\n\n- `crates/config/assets/provider_descriptors.json` - built-in and compatible-host\n labels, defaults, aliases, key-env lists, config/secret slots, and credential\n guidance. `crates/config/build.rs` generates immutable typed views and the\n existing constant projections from this one data owner.\n- `crates/config/src/provider_kind.rs` and `src/lib.rs` - legacy identity serde,\n config schema, environment precedence and Rust route/auth behavior.\n- `crates/tui/src/config.rs` - captures and verifies exact configured provider\n identities and keeps provider-specific credential and route policy in Rust.\n- `crates/config/assets/catalog_corrections.json` `reviewed` - intrinsic facts,\n scoped selector aliases, completion references and pure transport metadata.\n The existing seed renderer embeds this reviewed supplement in Models.dev.\n- `crates/tui/src/provider_lake.rs` and `route_runtime.rs` - resolve current\n model facts for both CLI model commands, the picker and Runtime API. Public\n catalog facts, provider/account rosters and explicit configuration retain\n their separate ownership. `crates/agent/src/lib.rs` keeps the legacy selector\n compatibility projection; it does not decide the CLI's current route.\n- `config.example.toml` and `docs/CONFIGURATION.md` - user-facing config\n examples and environment variable reference.\n- `scripts/check-provider-registry.py` - drift check for canonical provider\n IDs, live TUI provider IDs, TOML table names, static registry rows, and\n documented defaults.\n\n## Captured Provider Identity\n\nPresentation names, labels, aliases, and historical wire tags come from\n`provider_descriptors.json`. `ProviderKind` remains the Rust-owned intrinsic\ncredential, protocol, and region distinction. A configured route captures both\nits kind and exact table key; a custom table named `openai` stays custom and\ndoes not acquire OpenAI credentials or protocol rules from its name. Case is\nsignificant for custom keys.\n\nDeepSeek China has three preserved representations: `deepseek_c_n` in released\nTUI serde wrappers, `deepseek-cn` as its captured route ID, and `deepseek_cn` as\nits config leaf. Its intrinsic kind is DeepSeek, while its table and endpoint\nstay distinct.\n\nAn old custom route without an additive provider ID can resume only with a\nverified root `base_url` migration into the active `providers.custom` table.\nThat private receipt is bound to the parsed table generation. A table-only,\nprofile-only, conflicting, or later replaced table cannot establish the\nmissing identity; an explicit empty ID is refused. Writes reuse the existing\nlocked config mutation and undo comparison, obtain the fresh migration receipt\nunder that lock, and verify the captured table before changing a leaf.\n\nIn-flight requests retain their captured identity, endpoint, and credential\ngeneration. Health lookup cannot treat an opaque credential reference as ready\nmerely because an earlier request used the same authentication class.\n\n## Provider Selection\n\nWith no saved model, no `default_text_model`, and no `CODEWHALE_MODEL` or\nprovider-specific model variable, a fresh install runs `deepseek-flash` on the\nDeepSeek provider. Precedence is the active provider's configured default, then\nits catalog default — so an explicit `default_text_model` is not silently\noverridden by whichever model the shipped catalog lists first.\n\nRefresh model catalogs without installing a new Codewhale release:\n\n```sh\ncodewhale models --update\ncodewhale models --update --provider openai\ncodewhale models --provider openai --json\n```\n\n`models --update` (also `--refresh`) updates the shared Models.dev metadata\nand calls the existing `/models` endpoint for each configured provider with\nits own credentials. `--provider ID` restricts the refresh to that exact\nprovider, including named custom endpoints. It makes no inference requests\nand never changes the saved provider or model. A command-line API key is\nconfined to the active provider; other routes are reported as skipped for\nthat invocation.\n\nPlain `models` lists the active provider's saved catalog without provider requests.\nFor ChatGPT, it validates the selected registration locally before reading\nthat account's saved roster. Successful refreshes are saved under Codewhale's\ncatalog directory and used by the model/provider pickers. Cache files are\nscoped to provider identity and endpoint; a failed refresh preserves prior\nrows. Text output reports source, last successful fetch time (Unix seconds),\nand freshness. `--update --json` adds per-source receipts and aggregate counts;\npartial failures return a nonzero exit code after writing those receipts.\nOrdinary `models --json` keeps its model-array format. Bundled/configured\nfallbacks are not proof that an account can use every listed model.\n\n### Sign in with ChatGPT\n\nThe `openai-codex` provider ID now uses OpenAI's official open-source\n[Sign in with ChatGPT flow](https://developers.openai.com/siwc/token-sharing-open-source).\nSign in and refresh the selected account's catalog:\n\n```sh\ncodewhale auth chatgpt\ncodewhale models --update --provider openai-codex\ncodewhale --provider openai-codex\n```\n\nSign-in opens your system browser, uses a loopback callback and PKCE, validates\nthe returned identity, and stores the issued registration and renewable tokens\nin Codewhale's protected credential storage. Permission to use your ChatGPT\nplan is separate from identity sign-in. A declined or missing plan grant stops\ninference; choose another provider explicitly if you want another billing path.\n`codewhale auth chatgpt-revoke` signs out of the Codewhale-owned session.\nThis experimental implementation retains one active registration. A picker\nfor multiple saved ChatGPT accounts is not implemented yet.\n\nModel discovery uses your granted bearer token at\n`GET https://api.openai.com/v1/models`; inference uses the public\n`POST https://api.openai.com/v1/responses` endpoint. The model picker keeps\nOpenAI's display labels and order and lists only entries with `visibility: list`.\nThe catalog contains no credentials and is bound to the verified issuer,\nissued client ID, and account subject. Another account or workspace never\ninherits those model choices. Missing, invalid, or older-than-one-day rosters\nare reported explicitly and provide no account entitlement evidence. Run the\nrefresh command after signing in. Neither model discovery nor ordinary listing\nstarts an inference request. Imported Codex CLI tokens and legacy process-token\nvariables cannot authorize this official plan route.\n\nEligible requests consume your ChatGPT plan or credits. ChatGPT Plus's\nfive-hour allowance is shared across apps; each app receives no separate\nallowance. The documented five-hour limit does not apply to Pro. App-specific\nlimits can also apply. Review limits and access in\n[ChatGPT usage settings](https://chatgpt.com/settings/usage). Codewhale does\nnot silently switch to an API key or another provider when a limit is reached.\n\nThis is an OpenAI preview for open-source/local apps. It does not grant access\nto ChatGPT conversation history. Requests stream with `store: false` and\n`stream: true`, while Codewhale retains its own session history and tools.\nOpenAI-hosted image generation, file search, Code Interpreter, native computer\nuse, hosted MCP/connectors, and Responses `tool_search` are unavailable on this\nroute; Codewhale's own tools use supported function/custom tool calls.\nThe ChatGPT preview requests one function call at a time.\nSee [preview limitations](https://developers.openai.com/siwc/token-sharing-open-source/preview-limitations).\nPaid or remotely hosted applications require the\n[commercial partner interest process](https://openai.com/form/sign-in-with-chatgpt-interest/);\nthis local integration is not commercial approval.\n\nThe canonical provider IDs are the entries of `ProviderKind::ALL`\n(`crates/config/src/provider_kind.rs`), in that order:\n\n`deepseek`, `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`,\n`openrouter`, `orcarouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `arcee`,\n`siliconflow-CN`, `moonshot`, `sglang`, `vllm`, `ollama`, `ollama-cloud`, `huggingface`,\n`together`, `qianfan`, `openai-codex`, `anthropic`, `openmodel`, `zai`,\n`stepfun`, `minimax`, `deepinfra`, `sakana`, `longcat`, `opencode-go`,\n`opencode-zen`, `meta`, `xai`, `mistral`, `telecomjs`, `modelstudio-token-plan`, `modelscope`,\n`google`, `edenai`, `zenmux`, `csdn`, `concentrate`, `codewhale`, and `custom`.\n\n`deepseek-anthropic` is *not* on this list — it is a wire dialect of\n`deepseek`, reached with `wire = \"anthropic\"`, not a separate route to select.\n\nUse any of these surfaces to select a provider:\n\n- CLI: `codewhale --provider `\n- TUI: `/provider ` or the provider picker\n- Env: `CODEWHALE_PROVIDER=`; `DEEPSEEK_PROVIDER=` is the legacy alias\n- Config: `provider = \"\"`\n\n`deepseek-cn`, `deepseek_china`, `deepseekcn`, and `deepseek-china` are accepted\nas legacy aliases for `deepseek`. They do not select a different official host;\nDeepSeek uses the same official API host worldwide.\n\n`deepseek_anthropic`, `deepseek-claude`, and `deepseek_claude` select\n`deepseek-anthropic`, the opt-in DeepSeek route that speaks the Anthropic\nMessages API at `https://api.deepseek.com/anthropic`. It keeps the normal\nDeepSeek API key path but uses `x-api-key` plus `anthropic-version: 2023-06-01`\ninstead of Bearer auth. If the key already lives in official DeepSeek Harness\n(`dsh`) at `$DSH_HOME/.credentials.yaml`, grant read-only access with\n`codewhale auth external-consent --provider deepseek --mode read-only`.\nCodewhale never writes that file and only reads `DEEPSEEK_API_KEY`.\n\n`huggingface`, `hugging-face`, `hugging_face`, and `hf` all select the\nHugging Face Inference Providers route. This is the OpenAI-compatible router\npath for chat/inference, not Hub browsing, model-card inspection, uploads, or\nartifact export.\n\n`telecomjs`, `telecom-js`, `telecom_js`, `telecomjs-cn`, and `tokenhub` all\nselect the TelecomJS TokenHub route. Its authenticated `/models` catalog is\nkey-scoped and remains isolated from every other provider's live snapshot.\n\nFresh shared config writes to `~/.codewhale/config.toml`. Existing\n`~/.deepseek/config.toml` files are still read for compatibility.\n\n### Legacy Antigravity tombstone\n\nAntigravity is not a Codewhale provider and cannot be selected or run. Existing\nlegacy Antigravity provider state is recognized only as a non-runnable migration\ntombstone. Run `codewhale auth clear --provider antigravity` to forget only\nCodewhale-owned legacy configuration and consent metadata. This does not sign\nout of, revoke, read, or otherwise alter any official Google or Antigravity\nsession. For Gemini, select the supported `google` provider and supply\n`GEMINI_API_KEY`.\n\n### Wire Protocol Compatibility\n\nProvider selection is explicit. A model string prefix such as\n`deepseek-ai/...`, `deepseek/...`, `qwen/...`, or `arcee-ai/...` is a\nprovider-owned wire ID or catalog namespace hint under the selected provider.\nIt is not a provider switch and must not be treated as proof that the route is\nDeepSeek, OpenRouter, or any other provider.\n\nSet the route with `provider = \"\"`, `CODEWHALE_PROVIDER=`, or\n`codewhale --provider `. Set the request model with `CODEWHALE_MODEL`, a\nprovider-specific model env var, top-level `default_text_model`, or\n`[providers.
].model`. Set the endpoint with `CODEWHALE_BASE_URL`, a\nprovider-specific base URL env var, or `[providers.
].base_url`. Set auth\nwith `codewhale auth set --provider `, `[providers.
].api_key`, or\nthe listed provider env vars.\n\n| Provider ID | TOML table | Wire protocol | Auth env vars |\n| --- | --- | --- | --- |\n| `deepseek` | `[providers.deepseek]` | Model-aware: Responses default (`deepseek-flash`); Chat Completions (`deepseek-v4-pro`) | `DEEPSEEK_API_KEY` |\n| `deepseek-anthropic` | `[providers.deepseek_anthropic]` | Anthropic Messages | `DEEPSEEK_API_KEY` |\n| `nvidia-nim` | `[providers.nvidia_nim]` | OpenAI Chat Completions | `NVIDIA_API_KEY`, `NVIDIA_NIM_API_KEY` |\n| `openai` | `[providers.openai]` | OpenAI Chat Completions | `OPENAI_API_KEY` |\n| `atlascloud` | `[providers.atlascloud]` | OpenAI Chat Completions | `ATLASCLOUD_API_KEY` |\n| `wanjie-ark` | `[providers.wanjie_ark]` | OpenAI Chat Completions | `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, `WANJIE_MAAS_API_KEY` |\n| `volcengine` | `[providers.volcengine]` | OpenAI Chat Completions | `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, `ARK_API_KEY` |\n| `openrouter` | `[providers.openrouter]` | OpenAI Chat Completions | `OPENROUTER_API_KEY` |\n| `xiaomi-mimo` | `[providers.xiaomi_mimo]` | OpenAI Chat Completions | `XIAOMI_MIMO_TOKEN_PLAN_API_KEY`, `MIMO_TOKEN_PLAN_API_KEY`, `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, `MIMO_API_KEY` |\n| `novita` | `[providers.novita]` | OpenAI Chat Completions | `NOVITA_API_KEY` |\n| `fireworks` | `[providers.fireworks]` | OpenAI Chat Completions | `FIREWORKS_API_KEY` |\n| `siliconflow` | `[providers.siliconflow]` | OpenAI Chat Completions | `SILICONFLOW_API_KEY` |\n| `arcee` | `[providers.arcee]` | OpenAI Chat Completions | `ARCEE_API_KEY` |\n| `siliconflow-CN` | `[providers.siliconflow_cn]` | OpenAI Chat Completions | `SILICONFLOW_API_KEY` |\n| `moonshot` | `[providers.moonshot]` | OpenAI Chat Completions | `MOONSHOT_API_KEY`, `KIMI_API_KEY` |\n| `sglang` | `[providers.sglang]` | OpenAI Chat Completions | `SGLANG_API_KEY` |\n| `vllm` | `[providers.vllm]` | OpenAI Chat Completions | `VLLM_API_KEY` |\n| `ollama` | `[providers.ollama]` | Local OpenAI-compatible Chat Completions | `OLLAMA_API_KEY` (optional; only for an authenticated local route) |\n| `ollama-cloud` | `[providers.ollama_cloud]` | Hosted OpenAI-compatible Chat Completions | `OLLAMA_CLOUD_API_KEY`, `OLLAMA_API_KEY` |\n| `huggingface` | `[providers.huggingface]` | OpenAI Chat Completions | `HUGGINGFACE_API_KEY`, `HF_TOKEN` |\n| `modelscope` | `[providers.modelscope]` | OpenAI Chat Completions | `MODELSCOPE_API_KEY` |\n| `together` | `[providers.together]` | OpenAI Chat Completions | `TOGETHER_API_KEY` |\n| `qianfan` | `[providers.qianfan]` | OpenAI Chat Completions | `QIANFAN_API_KEY`, `BAIDU_QIANFAN_API_KEY` |\n| `openai-codex` | `[providers.openai_codex]` | OpenAI Responses | Official Sign in with ChatGPT (`codewhale auth chatgpt`) with a validated Codewhale-owned plan grant |\n| `anthropic` | `[providers.anthropic]` | Anthropic Messages | `ANTHROPIC_API_KEY` or `codewhale auth claude` |\n| `openmodel` | `[providers.openmodel]` | Anthropic Messages | `OPENMODEL_API_KEY` |\n| `zai` | `[providers.zai]` | OpenAI Chat Completions | `ZAI_API_KEY`, `Z_AI_API_KEY` |\n| `stepfun` | `[providers.stepfun]` | OpenAI Chat Completions | `STEPFUN_API_KEY`, `STEP_API_KEY` |\n| `minimax` | `[providers.minimax]` | OpenAI Chat Completions | `MINIMAX_API_KEY` |\n| `deepinfra` | `[providers.deepinfra]` | OpenAI Chat Completions | `DEEPINFRA_API_KEY`, `DEEPINFRA_TOKEN` |\n| `sakana` | `[providers.sakana]` | OpenAI Chat Completions | `FUGU_API_KEY`, `SAKANA_API_KEY` |\n| `longcat` | `[providers.longcat]` | OpenAI Chat Completions | `LONGCAT_API_KEY` |\n| `opencode-go` | `[providers.opencode_go]` | OpenAI Chat Completions | `OPENCODE_GO_API_KEY` |\n| `opencode-zen` | `[providers.opencode_zen]` | Model-aware: OpenAI Responses, Anthropic Messages, or OpenAI Chat Completions | `OPENCODE_ZEN_API_KEY`, `OPENCODE_API_KEY` |\n| `meta` | `[providers.meta]` | OpenAI Chat Completions | `META_MODEL_API_KEY`, `MODEL_API_KEY` |\n| `telecomjs` | `[providers.telecomjs]` | OpenAI Chat Completions | `TELECOMJS_API_KEY` |\n| `xai` | `[providers.xai]` | OpenAI Chat Completions | `XAI_API_KEY` |\n| `mistral` | `[providers.mistral]` | OpenAI Chat Completions | `MISTRAL_API_KEY` |\n| `google` | `[providers.google]` | OpenAI Chat Completions (official Gemini OpenAI-compat route; captures and replays thought signatures on tool calls) | `GOOGLE_API_KEY`, `GEMINI_API_KEY` |\n| `edenai` | `[providers.edenai]` | OpenAI Chat Completions | `EDENAI_API_KEY` |\n| `zenmux` | `[providers.zenmux]` | OpenAI Chat Completions | `ZENMUX_API_KEY` |\n| `csdn` | `[providers.csdn]` | OpenAI Chat Completions | `CSDN_API_KEY` |\n| `concentrate` | `[providers.concentrate]` | OpenAI Responses (`/v1/responses`) | `CONCENTRATE_API_KEY` |\n| `codewhale` | `[providers.codewhale]` | Model-aware: OpenAI Chat Completions (`/v1/chat/completions`) or Anthropic Messages (`/v1/messages`), chosen per model by the account catalog | `CODEWHALE_API_KEY` |\n| `modelstudio-token-plan` | `[providers.modelstudio_token_plan]` | OpenAI Chat Completions | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` |\n| `modelstudio-token-plan-anthropic` | `[providers.modelstudio_token_plan_anthropic]` | Anthropic Messages | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` |\n| `modelstudio-coding-plan` | `[providers.modelstudio_coding_plan]` | OpenAI Chat Completions | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` |\n| `modelstudio-coding-plan-anthropic` | `[providers.modelstudio_coding_plan_anthropic]` | Anthropic Messages | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` |\n\nDefault base URLs and models for each route are listed in the shipped provider\ntable below. The wire protocol values above are derived from\n`crates/config/src/provider.rs`: `ChatCompletions` is the default,\n`openai-codex` overrides to `Responses`; `deepseek-anthropic`, `anthropic`, and\n`openmodel` override to `AnthropicMessages`; `opencode-zen` resolves the\nprotocol from the selected model's curated offering; and `deepseek` is\nmodel-aware — the shipped default `deepseek-flash` (and legacy\n`deepseek-v4-flash`) rides the Responses endpoint while `deepseek-v4-pro`\nstays on Chat Completions.\n\n## Auth And Env Rules\n\nFor hosted providers, `codewhale auth set --provider ` saves an API key for\nthat provider. API-key environment variables are fallback inputs after saved\nconfig and keyring credentials; an explicit process-level `--api-key` still\nwins for that launch.\n\nFor base URL and model selection, prefer:\n\n- `CODEWHALE_BASE_URL` / `CODEWHALE_MODEL` for the active provider.\n- Provider-specific base URL/model env vars when listed below.\n- `DEEPSEEK_BASE_URL`, `DEEPSEEK_MODEL`, and `DEEPSEEK_DEFAULT_TEXT_MODEL` as\n legacy aliases.\n\nNon-local `http://` base URLs are rejected unless\n`DEEPSEEK_ALLOW_INSECURE_HTTP=1` is set. Loopback HTTP URLs are allowed for\nself-hosted runtimes.\n\n## Custom DeepSeek-Compatible Endpoints\n\nMost custom DeepSeek-compatible deployments can use an existing provider ID.\nDo not create `[providers.deepseek_custom]`; the provider table names are fixed.\nInstead, choose the closest shipped route and override its endpoint/model:\n\n- DeepSeek-compatible hosted API: keep `provider = \"deepseek\"` and set\n `[providers.deepseek].base_url` plus `[providers.deepseek].model`, or launch\n with `DEEPSEEK_BASE_URL` and `DEEPSEEK_MODEL`.\n- Generic OpenAI-compatible gateway: use `provider = \"openai\"` with\n `[providers.openai].base_url` plus `[providers.openai].model`, or launch with\n `OPENAI_BASE_URL` and `OPENAI_MODEL`.\n- Multiple named OpenAI-compatible gateways, or local routes you want to pin\n from an AgentProfile, can use a custom table such as\n `[providers.lm-studio] kind = \"openai-compatible\"` and select it with\n `provider = \"lm-studio\"` or a profile `provider = \"lm-studio\"`.\n- Local OpenAI-compatible runtimes: use `provider = \"vllm\"`, `\"sglang\"`, or\n `\"ollama\"` with the matching provider-specific base URL/model values.\n\nExample user config for a DeepSeek-compatible host:\n\n```toml\nprovider = \"deepseek\"\n\n[providers.deepseek]\napi_key = \"YOUR_API_KEY\"\nbase_url = \"https://your-provider.example/v1\"\nmodel = \"deepseek-ai/DeepSeek-V4-Pro\"\n```\n\nExample user config for a generic gateway:\n\n```toml\nprovider = \"openai\"\n\n[providers.openai]\napi_key = \"YOUR_GATEWAY_API_KEY\"\nbase_url = \"https://gateway.example/v1\"\nmodel = \"your-deepseek-compatible-model\"\n```\n\nAlibaba Cloud Model Studio (Bailian / DashScope) is a first-class provider as\nof v0.9.4 with two plan profiles: Token Plan (Personal / Team) and Coding Plan.\nBoth plans expose an OpenAI-compatible Chat Completions endpoint and an\nAnthropic-compatible Messages endpoint.\n\n**Token Plan** (Personal and Team share the same AP-Southeast endpoint):\n\n```toml\nprovider = \"modelstudio-token-plan\"\n\n[providers.modelstudio_token_plan]\napi_key = \"YOUR_MODELSTUDIO_API_KEY\"\n# base_url defaults to https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1\nmodel = \"qwen3.8-max\" # or qwen3.8-max-preview | qwen3.7-plus | qwen3.7-max |\n # qwen3.6-flash | deepseek-v4-pro | deepseek-v4-flash-0731 |\n # glm-5.2\n```\n\n**Coding Plan** (separate international endpoint):\n\n```toml\nprovider = \"modelstudio-coding-plan\"\n\n[providers.modelstudio_coding_plan]\napi_key = \"YOUR_MODELSTUDIO_API_KEY\"\n# base_url defaults to https://coding-intl.dashscope.aliyuncs.com/v1\nmodel = \"qwen3.8-max\"\n```\n\n**Anthropic-compatible dialect** — both plans also expose a native Anthropic\nMessages path. Select it with the `-anthropic` provider suffix:\n\n```toml\nprovider = \"modelstudio-token-plan-anthropic\"\n\n[providers.modelstudio_token_plan_anthropic]\napi_key = \"YOUR_MODELSTUDIO_API_KEY\"\n# base_url defaults to https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic\nmodel = \"qwen3.8-max\"\n```\n\nCreate or copy a Model Studio API key from the\n[Bailian console](https://bailian.console.aliyun.com/). The API key is shared\nacross all four provider IDs above; only the base URL and wire protocol differ.\n\n**Thinking / reasoning.** Reasoning surfaces in the TUI's Thinking view on both\ndialects, per Model Studio's\n[deep-thinking docs](https://www.alibabacloud.com/help/en/model-studio/deep-thinking).\n\nOn the OpenAI-compatible routes the top-level controls are **route- and\nmodel-specific**, and Codewhale fails closed: they are sent only when the\nconfigured `base_url` is an official Alibaba Chat Completions host\n(`*.maas.aliyuncs.com/compatible-mode/v1`, including workspace-scoped hosts, or\n`coding-intl.dashscope.aliyuncs.com/v1`). A custom `base_url` on the same\nprovider ID gets `thinking`, `enable_thinking`, `preserve_thinking`, and\n`reasoning_effort` stripped, so an arbitrary OpenAI-compatible gateway is never\nhanded Alibaba's dialect. On a verified host:\n\n- **Hybrid models** (`qwen3.7-*`, `qwen3.6-*`, `deepseek-v4*`, `glm-*`,\n `kimi-k2.6*`) get `enable_thinking`: `false` for `off`, `true` otherwise.\n- **Thinking-only models** — `qwen3.8-max` (catalogued `thinking: always_on`),\n `qwen3.8-max-preview` (effort/budget options, no toggle), and\n `kimi-k2.7-code` — get **no** enable/disable switch at all. Sending one is at\n best ignored.\n- `preserve_thinking` is sent for the models documented to accept it\n (`qwen3.7-max`/`-plus`, `qwen3.6-max-preview`/`-plus`/`-flash`, `kimi-k2.6*`,\n `kimi-k2.7-code`), so the next turn keeps the assistant's trace.\n- `reasoning_effort` is sent only for the two families with a documented ladder\n — `deepseek-v4*` and `glm-5`/`5.1`/`5.2` — mapped to `high` or `max`.\n\nReasoning streams back as `delta.reasoning_content`. It is replayed to the\nprovider on later turns only for the `preserve_thinking` models above and the\nthinking-only models; `deepseek-v3.1`, `deepseek-v3.2`, and `glm-*` history\nstays stripped pending live confirmation that DashScope accepts\n`reasoning_content` in input messages. (`deepseek-v4*` replays regardless — the\nDeepSeek thinking-mode contract requires it on every provider.)\n\nOn the Anthropic-compatible routes, thinking uses the documented\n`{\"type\":\"enabled\",\"budget_tokens\":N}` / `{\"type\":\"disabled\"}` shapes from the\n[Anthropic-compatible Messages API](https://www.alibabacloud.com/help/en/model-studio/anthropic-api-messages),\nwith `budget_tokens` derived from the effort level.\n\nDeepSeek (`deepseek-v4-pro`, `deepseek-v4-flash-0731`) and GLM (`glm-5.2`)\nmodels served by Model Studio are provider-scoped and do not collide with the\nfirst-party DeepSeek or Zhipu/Z.ai routes. Model Studio publishes no `glm-5.3`\nentry, so Codewhale does not offer one on this route.\nPay-as-you-go workspace-id templating is not yet in the built-in provider; use\na custom provider entry for that plan until a follow-up adds it.\n\nPrivate gateways with broken or intercepted certificates should use\n`SSL_CERT_FILE` with a trusted CA bundle. The legacy\n`insecure_skip_tls_verify = true` key is still parsed so `codewhale doctor` can\nreport stale configs, but provider clients reject it instead of skipping TLS\ncertificate verification.\n\nKeep `provider`, `api_key`, and `base_url` in user config or process\nenvironment. Project-local config overlays intentionally cannot set those keys,\nso a repository cannot silently redirect prompts or credentials to another\nendpoint.\n\n## Local Models (DS4, Ollama, vLLM, SGLang)\n\nSelf-hosted OpenAI-compatible runtimes are first-class routes and are keyless\nby default — set an API key only when your server requires one. Start your\nrunner, then point Codewhale at it with `--provider` / `/provider` or a config\ntable.\n\n| Runner | Default base URL | Default model | Base URL override |\n| --- | --- | --- | --- |\n| `ollama` | `http://localhost:11434/v1` | live tag from `GET /v1/models` (pre-refresh: `unknown`) | `OLLAMA_BASE_URL` |\n| `vllm` | `http://localhost:8000/v1` | `deepseek-ai/DeepSeek-V4-Pro` | `VLLM_BASE_URL` |\n| `sglang` | `http://localhost:30000/v1` | `deepseek-ai/DeepSeek-V4-Pro` | `SGLANG_BASE_URL` |\n\n### DS4 (DwarfStar)\n\n[DS4](https://github.com/antirez/ds4/tree/84cc882352757baf628a1776badf7cc54d584e28)\nserves DeepSeek V4 Flash and Pro locally\nthrough an OpenAI-compatible API. Start DS4, then open Codewhale's prefilled,\nkeyless setup form:\n\n```bash\n./ds4-server --ctx 100000 --kv-disk-dir /tmp/ds4-kv --kv-disk-space-mb 8192\ncodewhale\n# In Codewhale: /setup provider ds4\n```\n\nReview the prefilled route and press Enter to save it. The preset budgets a\n100,000-token context to match that starter command and defaults to the Flash\ncompatibility alias. Check the local route explicitly with\n`codewhale doctor --probe-local`.\n\nDS4 loads the actual GGUF when the server starts. Its `deepseek-v4-flash` and\n`deepseek-v4-pro` API ids are compatibility aliases; changing `/model` does\nnot swap the resident model. To run Pro, download the supported Pro weights\nand start `ds4-server -m ...` as described by DS4. Update\n`context_window` whenever the server's `--ctx` value changes.\n\nThe equivalent config is:\n\n```toml\nprovider = \"ds4\"\n\n[providers.ds4]\nkind = \"openai-compatible\"\nbase_url = \"http://127.0.0.1:8000/v1\"\nmodel = \"deepseek-v4-flash\"\nauth_mode = \"none\"\ncontext_window = 100000\n```\n\nCodewhale reuses its existing OpenAI-compatible transport and DeepSeek\nreasoning/tool-call shaping for DS4. It does not invent an API key, confuse an\nAPI alias with the loaded GGUF, or silently switch to a hosted DeepSeek route.\nThe pinned DS4 [agent-client contract](https://github.com/antirez/ds4/blob/84cc882352757baf628a1776badf7cc54d584e28/README.md#agent-client-usage)\ndocuments Chat Completions at `/v1`, DeepSeek thinking replay, streamed usage,\n`max_tokens`, and no strict-tool mode; Codewhale follows those exact route\nfacts instead of inheriting unsupported capabilities from a generic gateway.\nThe primary sources for model-facing behavior are DeepSeek's official\n[thinking-mode](https://api-docs.deepseek.com/guides/thinking_mode),\n[tool-call](https://api-docs.deepseek.com/guides/tool_calls), and\n[Chat Completion](https://api-docs.deepseek.com/api/create-chat-completion)\ncontracts. The pinned\n[DeepSeek Harness adapter](https://github.com/deepseek-ai/deepseek-harness/blob/47f943859bef60e4160492346772ded9b24f765a/packages/llm/llm-deepseek/README.md)\nis only a secondary implementation cross-check; it is not the API contract.\n\n### Ollama\n\n```bash\nollama serve # if not already running\nollama pull # e.g. deepseek-v4-flash, or any tag you prefer\ncodewhale --provider ollama --model \n```\n\nProvider-hinted model names are sent as-is, so `--model qwen3:8b` works with\nany tag Ollama has pulled.\n\n### Ollama Cloud\n\nOllama Cloud is a separate hosted provider. It uses the authenticated\nOpenAI-compatible `/v1/chat/completions` route and defaults to `gpt-oss:120b`:\n\n```toml\nprovider = \"ollama-cloud\"\n\n[providers.ollama_cloud]\nbase_url = \"https://ollama.com/v1\"\nmodel = \"gpt-oss:120b\"\n```\n\nCreate a key in [Ollama account settings](https://ollama.com/settings/keys),\nthen run `codewhale auth set --provider ollama-cloud`. For ambient auth,\n`OLLAMA_CLOUD_API_KEY` wins over Ollama's official `OLLAMA_API_KEY`.\n`OLLAMA_CLOUD_BASE_URL` and `OLLAMA_CLOUD_MODEL` override the Cloud defaults;\narbitrary provider-owned model IDs pass through unchanged. Local `ollama`\nremains a separate, keyless-by-default provider.\n\nCompatibility is read-only and in memory: a released config that selected\n`provider = \"ollama\"` with the exact normalized\n`[providers.ollama] base_url = \"https://ollama.com/v1\"` tuple is treated as\n`ollama-cloud` at runtime. Only that exact tuple may fall back to the legacy\n`ollama` secret slot. Codewhale does not rewrite the config, copy or delete a\nsecret, migrate neighboring paths, or make an explicit `ollama-cloud` route\nconsume the legacy slot.\n\n### vLLM\n\n```bash\nvllm serve --port 8000\n# or: python -m vllm.entrypoints.openai.api_server --model --port 8000\ncodewhale --provider vllm --model \n```\n\nvLLM's OpenAI-compatible server listens on port 8000 by default, matching\nCodewhale's `VLLM_BASE_URL`.\n\n### SGLang\n\n```bash\npython -m sglang.launch_server --model-path --port 30000\ncodewhale --provider sglang --model \n```\n\nSGLang's default port 30000 matches Codewhale's `SGLANG_BASE_URL`.\n\n### Pinning a local route in config\n\n```toml\nprovider = \"ollama\" # or \"vllm\" / \"sglang\"\n\n[providers.ollama]\nmodel = \"qwen3:8b\" # default is deepseek-v4-flash\n# base_url defaults to http://localhost:11434/v1\n```\n\nLocal models that print tool-call JSON without the wire markers: see\n[When a Local Model Prints Tool JSON](#when-a-local-model-prints-tool-json).\n\n## Credential Links\n\nProvider setup surfaces use the same typed credential metadata as onboarding,\n`/provider`, `/links`, setup receipts, and doctor output. A missing URL is\nintentional: local, OAuth-only, and user-defined routes show their supported\nconfiguration path instead of guessing a vendor page.\n\n| Provider ID | Credential or console link |\n| --- | --- |\n| `deepseek`, `deepseek-anthropic` | [DeepSeek API keys](https://platform.deepseek.com/api_keys) |\n| `nvidia-nim` | [NVIDIA NIM API keys](https://build.nvidia.com/settings/api-keys) |\n| `openai` | [OpenAI API keys](https://platform.openai.com/api-keys) |\n| `atlascloud` | [Atlas Cloud API keys](https://atlascloud.ai/docs/en/api-keys) |\n| `wanjie-ark` | [Wanjie MaaS APIKEY docs](https://docs.wanjiedata.com/maas/maas-openapi-v1.html) |\n| `volcengine` | [Volcengine Ark API keys](https://console.volcengine.com/ark/apiKey) |\n| `openrouter` | [OpenRouter keys](https://openrouter.ai/settings/keys) |\n| `xiaomi-mimo` | [Xiaomi MiMo Token Plan](https://platform.xiaomimimo.com/token-plan) |\n| `novita` | [Novita key management](https://novita.ai/en/settings/key-management) |\n| `fireworks` | [Fireworks API keys](https://fireworks.ai/api-keys) |\n| `siliconflow` | [SiliconFlow global API keys](https://cloud.siliconflow.com/account/ak) |\n| `siliconflow-CN` | [SiliconFlow China API keys](https://cloud.siliconflow.cn/account/ak) |\n| `arcee` | [Arcee API key guide](https://docs.arcee.ai/other/create-your-first-api-key) |\n| `moonshot` | [Kimi API platform keys](https://platform.kimi.ai/console/api-keys) or [Kimi Code membership console](https://www.kimi.com/code/console) |\n| `zai` | [Z.ai model API](https://z.ai/model-api) |\n| `stepfun` | [StepFun Open Platform](https://platform.stepfun.ai/) |\n| `minimax`, `minimax-anthropic` | [MiniMax interface keys](https://platform.minimax.io/user-center/basic-information/interface-key) |\n| `huggingface` | [Hugging Face tokens](https://huggingface.co/settings/tokens) |\n| `modelscope` | [ModelScope API Keys](https://modelscope.cn/my/settings/token) |\n| `deepinfra` | [DeepInfra API keys](https://deepinfra.com/dash/api_keys) |\n| `together` | [Together API keys](https://api.together.ai/settings/api-keys) |\n| `qianfan` | [Baidu Cloud access keys](https://console.bce.baidu.com/iam/#/iam/accesslist) |\n| `anthropic` | [Anthropic API keys](https://console.anthropic.com/settings/keys) |\n| `openmodel` | [OpenModel console](https://console.openmodel.ai/) ([authentication guide](https://docs.openmodel.ai/en/docs/getting-started/authentication)) |\n| `openai-codex` | Official Sign in with ChatGPT via `codewhale auth chatgpt` (eligible ChatGPT plan or credits, Codewhale-owned tokens). The `openai` API-key route has separate billing. Legacy imported Codex credentials do not authorize this route. |\n| `sglang`, `vllm` | Local OpenAI-compatible endpoints are keyless by default; configure a key only when the server requires one. |\n| `ollama` | Local Ollama is keyless by default; configure a key only when the local server requires one. |\n| `ollama-cloud` | Create an [Ollama API key](https://ollama.com/settings/keys), save it with `codewhale auth set --provider ollama-cloud`, or set `OLLAMA_CLOUD_API_KEY` / `OLLAMA_API_KEY` in that precedence order. |\n| `sakana` | [Sakana AI API keys](https://console.sakana.ai/api-keys) ([get started](https://console.sakana.ai/get-started)) |\n| `longcat` | [Meituan LongCat platform](https://longcat.chat/platform) |\n| `opencode-go` | [OpenCode Go](https://opencode.ai/docs/go/) |\n| `opencode-zen` | [OpenCode Zen](https://opencode.ai/docs/zen/) |\n| `meta` | [Meta Model API](https://developer.meta.com/ai/) |\n| `telecomjs` | [TelecomJS TokenHub](https://aigw.telecomjs.com/) |\n| `xai` | [xAI Console](https://console.x.ai/) for an API key, Codewhale-owned device login, or explicitly consented read-only Grok CLI credentials. |\n| `mistral` | [Mistral Console (la Plateforme)](https://console.mistral.ai/api-keys) |\n| `google` | [Google AI Studio](https://aistudio.google.com/apikey) — Codewhale uses the official Gemini OpenAI-compatible endpoint and never reads Google OAuth files. |\n| `edenai` | [Eden AI API keys](https://app.edenai.run/settings/api-keys) |\n| `zenmux` | [ZenMux API keys](https://zenmux.ai/platform/pay-as-you-go) |\n| `csdn` | [CSDN 星图 console](https://ai.csdn.net/workbench/api-key) — choose the Coding Plan key type for the `glm_for_coding` plan route; a general key bills metered. Docs: [Coding Plan](https://ai.csdn.net/coding-plan). |\n| `concentrate` | [Concentrate dashboard](https://concentrate.ai/) → API Keys → Create API Key (a Universal API key); docs: [API introduction](https://concentrate.ai/docs/api-reference/introduction). BYOK only — the key stays in the local secret store. |\n| `modelstudio-token-plan`, `modelstudio-token-plan-anthropic`, `modelstudio-coding-plan`, `modelstudio-coding-plan-anthropic` | [Alibaba Cloud Model Studio (Bailian console)](https://bailian.console.aliyun.com/) — create or copy a Model Studio API key. |\n| `codewhale` | [Codewhale account settings](https://app.codewhale.net/settings?section=api) — create an API key with the `models:infer` scope, or run `codewhale account api-keys create --name --use`. |\n| `custom` | Set the named provider's `base_url` and `api_key_env` or `api_key`; no canonical vendor credential page exists. |\n\nFor Kimi, the official [quickstart](https://platform.kimi.ai/docs/overview)\ndirects users to sign in, open **API Keys**, create and copy a key, and keep it\nsecret. Codewhale links straight to that console and accepts the copied key.\nIt never probes or impersonates `kimi_cli`/`kimi_code_cli`; first-class Kimi\nOAuth remains blocked on a vendor-registered Codewhale identity.\n\n### Subscription sign-in accounts (ChatGPT, xAI)\n\nCodewhale keeps one sign-in of its own per subscription provider. It is\nseparate from the browser, the ChatGPT or Grok apps, and the Codex or Grok\nCLIs, so those can be signed in to a different account. A successful login\nreplaces the previous owned grant; there is no picker for multiple saved\nChatGPT accounts.\n\n- **See which account is signed in.** `codewhale auth status --provider\n openai-codex` (or `--provider xai`) and `codewhale auth list` show an account\n label when the ID token contains an email. ChatGPT plan and workspace\n details appear only when those claims are present; a workspace label uses\n the first characters of its account ID. The label is display metadata, not\n proof of plan entitlement. A valid sign-in may have no email label. Status\n reads local credential state without contacting the issuer: an expired\n access token with a stored refresh token remains structurally usable, while\n missing or invalid storage, or an expired token without a refresh token,\n is reported unusable. The provider picker shows the label of the sign-in\n the route would use (for xAI this includes a consented Grok CLI import).\n Successful login reports the account when known and whether it replaced\n an earlier sign-in, in the TUI's selected language. No token is printed.\n- **Reauthorize ChatGPT.** Run `/auth chatgpt` inside Codewhale to update the\n running session, or `codewhale auth chatgpt` from a shell and restart open\n Codewhale sessions. Ordinary login reuses the selected account/workspace's\n issued client ID and checks that the returned account is the same. The\n browser opens with `prompt=login`; `CODEWHALE_CHATGPT_OAUTH_NO_PROMPT=1`\n omits that parameter if the issuer refuses it.\n- **Replace the ChatGPT account or workspace.** From a shell, run\n `CODEWHALE_CHATGPT_NEW_ACCOUNT=1 codewhale auth chatgpt`, then refresh its\n model catalog and restart open Codewhale sessions. If the browser holds the\n wrong account, sign out there or open the printed URL in a private window.\n Only a fully validated new grant replaces the active owned sign-in.\n- **Switch xAI accounts.** Run `/auth xai-device` inside Codewhale, or\n `codewhale auth xai-device` from a shell and restart open sessions. The\n device page approves with whichever account the browser holds; sign out\n there or use a private window to approve another account. The login names\n the account it replaced, or says it is the same account as before.\n- **Usage limits.** ChatGPT plan requests can fail with\n `subscription_sharing_usage_limit_exceeded` or\n `subscription_sharing_usage_unavailable`. These errors, and subscription\n quota errors from other providers, name the account that made the request\n when its label is known and offer recovery guidance. Codewhale does not\n retry these terminal errors or silently choose another billing route. An\n xAI route set to OAuth that fell back to an API key gets no sign-in\n guidance, since no sign-in was used.\n- **ChatGPT credential authority.** The official ChatGPT plan route uses\n only its selected, verified Codewhale-owned grant.\n `OPENAI_CODEX_ACCESS_TOKEN`, `CODEX_ACCESS_TOKEN`, and consented Codex CLI\n tokens cannot override or authorize this route.\n\n### External CLI credential consent\n\nCredential files owned by another CLI are disabled by default. Without an\nexplicit grant, provider discovery, setup, routing, `auth status`, and doctor\ndo not stat, read, refresh, contact an identity provider for, or rewrite Codex,\nGrok, Kimi, or future external credential files.\n\nCodewhale currently supports exact-path, provider-scoped **read-only** grants\nfor the Codex CLI and Grok CLI. Codex grants remain available for legacy\ncredential inspection only; they cannot authorize the official ChatGPT plan\nroute. Use `codewhale auth chatgpt` for that route.\n\n```bash\n# Legacy Codex credential inspection; does not authorize ChatGPT plan requests.\ncodex login\ncodewhale auth external-consent --provider openai-codex --mode read-only\n\ngrok login\ncodewhale auth external-consent --provider xai --mode read-only\n\ncodewhale auth status --provider openai-codex\ncodewhale auth external-revoke --provider openai-codex\n```\n\nPass `--path /absolute/path/to/auth.json` when the external CLI uses a custom\nlocation. Consent persists the provider, external owner, exact absolute path,\nand consent schema version. Later environment-variable changes do not redirect\nthat authority to a different file. Read-only grants never refresh, contact an\nidentity/discovery service, or rewrite the external file. Supported routes such\nas xAI may use the external token after explicit selection. The official ChatGPT\nplan route ignores imported Codex tokens and requires its own verified grant.\nAn expired external token fails with login guidance. Doctor reports structural\nconsent/config state without\nopening credential files and is always non-mutating.\n\n`managed` is reserved for a future provider-specific preservation adapter.\nv0.9.1 rejects it before file or network I/O because no reviewed adapter can\nyet preserve every unknown external schema field safely. Codewhale-started xAI\ndevice login instead atomically activates a Codewhale-owned generation named\n`$CODEWHALE_HOME/credentials/xai-auth-.json`, stores only that\nvalidated basename in config, and revokes any Grok-file grant. Superseded\ngenerations are cleaned only after the new config pointer commits.\nKimi remains API-key-only; external consent for Kimi is rejected.\n\nThe official DeepSeek Harness (`dsh`) is a third read-only credential owner:\n`codewhale auth external-consent --provider deepseek --mode read-only` grants\nexact-path read access to `DEEPSEEK_API_KEY` in `$DSH_HOME/.credentials.yaml`\n(or `~/.dsh/.credentials.yaml`), which Codewhale never writes, refreshes, or\nloads into the process environment. This is separate from the DSH *harness*\nintegration (`codewhale integrations dsh …`, see\n[INTEGRATIONS_DSH.md](INTEGRATIONS_DSH.md)), which never touches credentials\nin either direction: it pins Codewhale's route identity into a `--patch`\noverlay and lets DSH resolve its own keys.\n\n## Shipped Providers\n\n| Provider ID | TOML table | Auth env | Base URL env and default | Default or static models | Notes |\n| --- | --- | --- | --- | --- | --- |\n| `deepseek` | `[providers.deepseek]` | `DEEPSEEK_API_KEY` | `CODEWHALE_BASE_URL` / `DEEPSEEK_BASE_URL`; default `https://api.deepseek.com/beta` | `deepseek-flash` (shipped default; V4.1 Flash, unversioned id), `deepseek-v4-pro`, `deepseek-v4-flash`, experimental `deepseek-v4-flash-vision-exp`; vision aliases `flash-vision`, `deepseek-v4flashvisionexp`; compatibility aliases `deepseek-chat`, `deepseek-reasoner` | First-class default. The live Pro backend is labeled `DeepSeek-V4-Pro-0813`; the callable API ID remains `deepseek-v4-pro`. Beta URL enables strict tool mode, chat prefix completion, and FIM completion. The documented V4 routes can use provider-native web search through a separate bounded Responses request; compatible custom endpoints do not inherit that capability. Set `https://api.deepseek.com` or `/v1` explicitly to opt out of beta-only features. The shipped default `deepseek-flash` speaks the Responses API (DeepSeek's documented path for Codex-style integration, since the 2026-07-31 Flash production update); the Chat-only controls (the `thinking` toggle and strict-tool `/beta` routing) apply to Chat Completions routes, and `deepseek-v4-pro` stays on Chat Completions until its announced Responses rollout. Reasoning effort follows DeepSeek's documented requested-to-actual mapping: `minimal`/`low` land on `low`, `medium`/`xhigh` on `high`, and `max`/`ultra` on `max`; `off` disables thinking (`thinking: {\"type\":\"disabled\"}` on Chat, `reasoning.effort: \"none\"` on Responses). The experimental vision ID was observed in the authenticated `/models` roster on 2026-08-21 and is advertised as image-input capable on the direct Chat Completions route only. Its limits, reasoning, and tool-call flags provisionally inherit Flash; pricing remains unknown, and no funded image round trip was made during this release work. |\n| `deepseek-anthropic` | `[providers.deepseek_anthropic]` | `DEEPSEEK_API_KEY` | `DEEPSEEK_ANTHROPIC_BASE_URL`; default `https://api.deepseek.com/anthropic` | `deepseek-v4-pro`, `deepseek-v4-flash`; compatibility aliases `deepseek-chat`, `deepseek-reasoner` | Opt-in DeepSeek route for the Anthropic Messages wire protocol. Uses `/v1/messages`, `x-api-key`, and `anthropic-version: 2023-06-01`. Keep `provider = \"deepseek\"` for the default Chat Completions path. |\n| `nvidia-nim` | `[providers.nvidia_nim]` | `NVIDIA_API_KEY`, `NVIDIA_NIM_API_KEY` | `NVIDIA_NIM_BASE_URL`, `NIM_BASE_URL`, `NVIDIA_BASE_URL`; default `https://integrate.api.nvidia.com/v1` | `deepseek-ai/deepseek-v4-pro`, `deepseek-ai/deepseek-v4-flash` | Hosted DeepSeek V4 through NVIDIA NIM. `NVIDIA_NIM_MODEL` is accepted by the TUI config path. |\n| `openai` | `[providers.openai]` | `OPENAI_API_KEY` | `OPENAI_BASE_URL`; default `https://api.openai.com/v1` | `gpt-5.6` (default), `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` | Generic OpenAI-compatible route whose built-in endpoint and fallback catalog are native to OpenAI. The [GPT-5.6 family](https://developers.openai.com/api/docs/models/gpt-5.6-sol) uses OpenAI's documented 1.05M context, 128K max output, and reasoning levels. Custom gateways remain free to select an explicit gateway-owned model. `OPENAI_MODEL` is accepted. |\n| `atlascloud` | `[providers.atlascloud]` | `ATLASCLOUD_API_KEY` | `ATLASCLOUD_BASE_URL`; default `https://api.atlascloud.ai/v1` | Default `deepseek-ai/deepseek-v4-flash`; explicit `vendor/model-id` values pass through when AtlasCloud is selected | OpenAI-compatible hosted route. `ATLASCLOUD_MODEL` is accepted by the TUI config path, the static `ModelRegistry` keeps DeepSeek V4 fallback rows, and provider-hinted CLI model IDs are sent to AtlasCloud exactly as requested. Use Atlas Cloud's own catalog or Coding Plan page for the current provider-owned model list and pricing. |\n| `wanjie-ark` | `[providers.wanjie_ark]` | `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, `WANJIE_MAAS_API_KEY` | `WANJIE_ARK_BASE_URL`, `WANJIE_BASE_URL`, `WANJIE_MAAS_BASE_URL`; default `https://maas-openapi.wanjiedata.com/api/v1` | `deepseek-reasoner` | OpenAI-compatible hosted route. `WANJIE_ARK_MODEL`, `WANJIE_MODEL`, and `WANJIE_MAAS_MODEL` are accepted. |\n| `volcengine` | `[providers.volcengine]` | `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, `ARK_API_KEY` | `VOLCENGINE_BASE_URL`, `VOLCENGINE_ARK_BASE_URL`, `ARK_BASE_URL`; default `https://ark.cn-beijing.volces.com/api/coding/v3` | `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash` | Volcengine/Volcano Engine Ark OpenAI-compatible coding endpoint. `VOLCENGINE_MODEL` and `VOLCENGINE_ARK_MODEL` are accepted. |\n| `openrouter` | `[providers.openrouter]` | `OPENROUTER_API_KEY` | `OPENROUTER_BASE_URL`; default `https://openrouter.ai/api/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`; recent large IDs include `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`, `qwen/qwen3.6-flash`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`, `qwen/qwen3.6-plus`, `google/gemma-4-31b-it`, `z-ai/glm-5.1`, `z-ai/glm-5.2`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6` | Additive open-model routing layer. It does not replace DeepSeek; it lets users route supported model IDs through OpenRouter when they choose it. |\n| `orcarouter` | `[providers.orcarouter]` | `ORCAROUTER_API_KEY` | `ORCAROUTER_BASE_URL`; default `https://api.orcarouter.ai/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`; router alias `orcarouter/auto`; recent large IDs mirror the OpenRouter namespaced catalog | [OrcaRouter](https://www.orcarouter.ai) OpenAI-compatible aggregation gateway. Shares the namespaced `vendor/model` wire-model format and DeepSeek model set with OpenRouter, so the OpenRouter base-URL and model-normalization rules apply. `ORCAROUTER_MODEL` is accepted. Provider aliases: `orcarouter`, `orca_router`, `orca`. |\n| `xiaomi-mimo` | `[providers.xiaomi_mimo]` | `XIAOMI_MIMO_TOKEN_PLAN_API_KEY`, `MIMO_TOKEN_PLAN_API_KEY`, `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, `MIMO_API_KEY` | `XIAOMI_MIMO_BASE_URL`, `MIMO_BASE_URL`, `XIAOMI_MIMO_MODE`, `MIMO_MODE`; default `https://token-plan-sgp.xiaomimimo.com/v1` | Chat: `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, `mimo-v2.5`; speech/TTS: `mimo-v2.5-tts`, `mimo-v2.5-tts-voicedesign`, `mimo-v2.5-tts-voiceclone`, `mimo-v2-tts` | Xiaomi MiMo OpenAI-compatible chat completions route. `mimo-v2.5-pro` and `mimo-v2.5` can use the documented provider-native web-search plugin; ultraspeed, speech/TTS, and custom-compatible routes do not inherit it. Token Plan keys (`tp-...`) use `api-key` auth and the token-plan endpoint by default; pay-as-you-go mode uses standard API keys (`sk-...`) and `https://api.xiaomimimo.com/v1`. It sends `max_completion_tokens` and uses MiMo's `thinking` field for reasoning control. Token Plan cost/usage is credit/quota based; Codewhale shows it as unknown until Xiaomi exposes a reliable balance API. `codewhale speech` / `tts` uses the TTS models. |\n| `novita` | `[providers.novita]` | `NOVITA_API_KEY` | `NOVITA_BASE_URL`; default `https://api.novita.ai/openai/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | OpenAI-compatible hosted route for DeepSeek model IDs. Use config or `CODEWHALE_MODEL` / `DEEPSEEK_MODEL` for model overrides. |\n| `fireworks` | `[providers.fireworks]` | `FIREWORKS_API_KEY` | `FIREWORKS_BASE_URL`; default `https://api.fireworks.ai/inference/v1` | `accounts/fireworks/models/deepseek-v4-pro` | OpenAI-compatible hosted route. Use config or `CODEWHALE_MODEL` / `DEEPSEEK_MODEL` for model overrides. |\n| `siliconflow` | `[providers.siliconflow]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.com/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | OpenAI-compatible hosted route. Official docs use the `.com` endpoint. `SILICONFLOW_MODEL` is accepted. Reasoning aliases `deepseek-reasoner` and `deepseek-r1` map to Pro; `deepseek-chat` and `deepseek-v3` map to Flash. |\n| `siliconflow-CN` | `[providers.siliconflow_cn]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.cn/v1` | Uses the SiliconFlow model set | China regional SiliconFlow route. Falls back to `[providers.siliconflow]` for api_key / base_url / model when unset. Select it with `provider = \"siliconflow-CN\"` or `CODEWHALE_PROVIDER=siliconflow-CN`. |\n| `arcee` | `[providers.arcee]` | `ARCEE_API_KEY` | `ARCEE_BASE_URL`; default `https://api.arcee.ai/api/v1` | `trinity-large-thinking`, `trinity-large-preview` | Arcee AI direct OpenAI-compatible route, tracked as 256K-context BF16 serving. `ARCEE_MODEL` is accepted. OpenRouter's `arcee-ai/trinity-large-thinking` remains the OpenRouter namespaced model ID; direct Arcee uses the bare `trinity-large-thinking` ID. |\n| `moonshot` | `[providers.moonshot]` | `MOONSHOT_API_KEY`, `KIMI_API_KEY` | `MOONSHOT_BASE_URL`, `KIMI_BASE_URL`; default `https://api.moonshot.ai/v1` | Direct Moonshot: `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.7-code-highspeed`, `kimi-k2.6`; Kimi Code membership: `k3`, `kimi-for-coding`, `kimi-for-coding-highspeed` at `https://api.kimi.com/coding/v1` | Moonshot/Kimi route. Exact direct `kimi-k3` routes use the documented Formula web-search tool/fiber loop; direct `kimi-k2.6` retains the built-in `$web_search` contract, and exact Kimi Code membership routes use their structured `/search` service. Adjacent paths, K2.7 direct models, and cross-product model IDs do not inherit native search. `kimi` and `kimi-k2` aliases select `kimi-k2.7-code`; `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, and `KIMI_MODEL` are accepted. Kimi thinking streams through `reasoning_content`; Codewhale keeps it in Thinking cells and replays it for thinking/tool-call continuity. For direct K3, use exact `base_url = \"https://api.moonshot.ai/v1\"` and `model = \"kimi-k3\"`; it is always-thinking and receives top-level `reasoning_effort = \"low\" | \"high\" | \"max\"` (`off` normalizes to `low`), uses only `max_completion_tokens`, and omits `temperature`/`top_p` per the [K3 quickstart](https://platform.kimi.ai/docs/guide/kimi-k3-quickstart). For Kimi Code K3, use a key from the [Kimi Code console](https://www.kimi.com/code/console), exact `base_url = \"https://api.kimi.com/coding/v1\"`, and bare `model = \"k3\"`; `off` becomes enabled `low`, while normal dispatched `auto` selects and sends a concrete Codewhale tier. Only an omitted reasoning setting leaves the provider default in control. That membership route defaults safely to 262,144 context tokens; the [Kimi Code model-tier table](https://www.kimi.com/code/docs/en/kimi-code/models.html) grants Allegretto and higher plans up to 1M, which those plans may express as `context_window = 1048576`. `k3[1m]` is Claude Code-only and Codewhale rejects it. `kimi-for-coding` remains the valid K2.7 membership route, and `kimi-for-coding-highspeed` is its own high-speed roster entry (262,144 context); membership ids are rejected on the direct platform endpoint, and `kimi-k3` stays rejected on the membership endpoint. Billing is decided by the endpoint the route resolves to, judged once against the two exact product endpoints: direct Moonshot (`https://api.moonshot.ai/v1` or the default) bills metered with dollar estimates, the exact Kimi Code membership endpoint bills as Kimi Code quota and never shows dollar estimates, and anything else — a gateway host, a neighboring Kimi-hosted path — reports `cost: unknown` rather than borrowing either product. An imported Kimi Code token with no `base_url` in its table still resolves to the membership endpoint, so it bills as Kimi Code quota and never accrues dollars. A completed turn, parent or sub-agent, is billed from the immutable endpoint receipt its own client was built with, never from a later config re-read: `MOONSHOT_BASE_URL`/`KIMI_BASE_URL` are merged into the *active* provider's table only, and an in-turn provider switch can move the ambient config off the route that actually ran. Legacy `auth_mode = \"kimi_oauth\"` fails to API-key guidance without probing Kimi CLI files. Codewhale does not impersonate `kimi_cli` or `kimi_code_cli`. **China-region keys:** contributor field evidence (@vFONGv, PR #5229, verified on Windows 10) reports that a China-region Moonshot key must be paired with `base_url = \"https://api.moonshot.cn/v1\"`; left on the default international host (`https://api.moonshot.ai/v1`) it fails authentication. We have no China-region key to verify this ourselves, so it is recorded as a user report rather than a tested route. Note also that editing `base_url` alone does not take effect until `codewhale auth set` is re-run for that provider. |\n| `google` | `[providers.google]` | `GOOGLE_API_KEY`, `GEMINI_API_KEY` | `GOOGLE_BASE_URL`, `GEMINI_BASE_URL`; default `https://generativelanguage.googleapis.com/v1beta/openai/` | `gemini-3.1-pro-preview` (default); `/model` also lists `gemini-3-pro-preview`, `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-2.5-pro`, `gemini-2.5-flash` | Google Gemini as its own backend on the official OpenAI-compatible Chat Completions route. Thinking models capture `extra_content.google.thought_signature` on tool calls and replay it with the assistant tool-call messages; replaying a tool call whose signature was not captured fails closed with an actionable error instead of letting the tool loop break. `gemini-2.5-flash-lite` ships thinking off and degrades with a warning instead. Reasoning effort maps onto the documented `google.thinking_config.thinking_level` (`low`/`high`). The dialect binds to the exact official base URL: a `google` row pointed at another gateway gets plain OpenAI semantics and no signature requirements. Codewhale never reads Google OAuth files; only an AI Studio API key is used. Not live-tested against the real endpoint in this environment. |\n| `zai` | `[providers.zai]` | `ZAI_API_KEY`, `Z_AI_API_KEY`, `ZHIPU_API_KEY`, `GLM_API_KEY` | `ZAI_BASE_URL`, `Z_AI_BASE_URL`; default `https://api.z.ai/api/coding/paas/v4`; general APIs `https://api.z.ai/api/paas/v4` and `https://open.bigmodel.cn/api/paas/v4` | `GLM-5.3` default; `/model` also lists `GLM-5.3-Flash`, `GLM-5.2`, `GLM-5.1`, and `GLM-5-Turbo` | Z.AI GLM Coding Plan route. All three first-party Chat routes (both api.z.ai products and BigModel's general platform endpoint) share one request dialect, so reasoning controls — the GLM-5.2 thinking toggle, tiered effort, and the forced-thinking GLM-5.3 rewrite that sends `off` as `enabled` + `reasoning_effort: \"low\"` — apply on BigModel too; neighboring paths such as `/preview` stay fail-closed. The two general API products expose structured provider-native web search (`search-prime` globally, `search_std` in China); Coding Plan and compatible custom endpoints do not inherit it. `GLM-5.3` is the default and a first-class picker row (`model = \"GLM-5.3\"` or `ZAI_MODEL=GLM-5.3`); `GLM-5.3-Flash` is the 1M multimodal fast sibling (`model = \"GLM-5.3-Flash\"`). An explicit `GLM-5.2` selection keeps its own id. Limits and reasoning options for 5.3 are inherited from `GLM-5.2` until Z.ai publishes distinct 5.3 metadata; 5.3 carries no price. Flash ships the published $0.15/$0.50 list. A live call can still 429 with entitlement code 1311 on accounts that are not provisioned for 5.3. |\n| `stepfun` | `[providers.stepfun]` | `STEPFUN_API_KEY`, `STEP_API_KEY` | `STEPFUN_BASE_URL`, `STEP_BASE_URL`; default `https://api.stepfun.ai/v1`; Coding Plan endpoint `https://api.stepfun.ai/step_plan/v1` | `step-3.7-flash` | StepFun / StepFlash direct OpenAI-compatible route. `/provider` setup asks which billing route the key belongs to — pay-as-you-go or Step Plan — validates the key against the chosen endpoint, and writes the answer to `[providers.stepfun].base_url` only. A base URL that is neither recognized route is left alone and the question is skipped. You can also set `[providers.stepfun].base_url` or `STEP_BASE_URL` to the Coding Plan URL by hand. Offline accounting labels recognized routes as `stepfun-payg` or `stepfun-plan` without persisting the raw endpoint, and only the standard PAYG route receives token pricing. `STEPFUN_MODEL` and `STEP_MODEL` are accepted. |\n| `minimax` | `[providers.minimax]` | `MINIMAX_API_KEY` | `MINIMAX_BASE_URL`; default `https://api.minimax.io/v1`; China `https://api.minimaxi.com/v1` | `MiniMax-M3`, `MiniMax-M2.7`, `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `MiniMax-M2.1`, `MiniMax-M2.1-highspeed`, `MiniMax-M2` | MiniMax direct OpenAI-compatible route. Codewhale sends `reasoning_split = true` so MiniMax thinking arrives separately from answer text. Both MiniMax dialects sell pay-as-you-go and Token Plan over the same endpoints and the same key, so billing is classified from the credential *product*, never from the endpoint or from a default. `mode = \"token-plan\"` in `[providers.minimax]`/`[providers.minimax_anthropic]`, or a Token Plan key shaped `sk-cp…`, bills as MiniMax Token Plan quota with no dollar estimates; an explicit pay-as-you-go mode (`pay-as-you-go`/`payg`/`metered`) wins over key shape. The key's product prefix is only visible when the key is in config, bound by `api_key_env`, or exported as `MINIMAX_API_KEY` on an official endpoint — a key saved through `codewhale auth set` (secret store / OS keyring) is deliberately not read to classify billing. With no explicit mode and no visible product marker the route reports `cost: unknown` rather than assuming pay-as-you-go, so a Token Plan account is never charged invented dollars. Custom/gateway endpoints also fail closed with `cost: unknown`. Official M3 input modalities are text, image, and video; M2.7 is text-only. |\n| `minimax-anthropic` | `[providers.minimax_anthropic]` | `MINIMAX_API_KEY` | `MINIMAX_ANTHROPIC_BASE_URL`; default `https://api.minimax.io/anthropic`; China `https://api.minimaxi.com/anthropic` | `MiniMax-M3`, `MiniMax-M2.7` | MiniMax direct Anthropic-compatible Messages route. Keep the `/anthropic` suffix because Codewhale appends `/v1/messages`; the route uses `x-api-key`. M3 supports adaptive or disabled thinking. M2.7 always keeps thinking enabled. |\n| `sglang` | `[providers.sglang]` | Optional `SGLANG_API_KEY` | `SGLANG_BASE_URL`; default `http://localhost:30000/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Self-hosted OpenAI-compatible route. Localhost deployments commonly omit auth. `SGLANG_MODEL` is accepted. |\n| `vllm` | `[providers.vllm]` | Optional `VLLM_API_KEY` | `VLLM_BASE_URL`; default `http://localhost:8000/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Self-hosted vLLM OpenAI-compatible route. Localhost deployments commonly omit auth. `VLLM_MODEL` is accepted. |\n| `ollama` | `[providers.ollama]` | Local optional `OLLAMA_API_KEY` | `OLLAMA_BASE_URL`; default `http://localhost:11434/v1` | live tag from the local catalog; pre-refresh placeholder `unknown`; provider-hinted custom tags pass through | Local Ollama is keyless by default. `OLLAMA_MODEL` is accepted. The header must not paint a hosted id the local daemon did not list. |\n| `ollama-cloud` | `[providers.ollama_cloud]` | `OLLAMA_CLOUD_API_KEY`, then `OLLAMA_API_KEY` | `OLLAMA_CLOUD_BASE_URL`; default `https://ollama.com/v1` | `gpt-oss:120b`; arbitrary provider-owned IDs pass through | Hosted OpenAI-compatible `/v1/chat/completions` route. Save credentials under `ollama-cloud`; the exact released `ollama` + Cloud URL tuple has bounded read-only in-memory compatibility with its legacy table and secret slot. `OLLAMA_CLOUD_MODEL` is accepted. |\n| `huggingface` | `[providers.huggingface]` | `HUGGINGFACE_API_KEY`, `HF_TOKEN` | `HUGGINGFACE_BASE_URL`, `HF_BASE_URL`; default `https://router.huggingface.co/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Hugging Face Inference Providers OpenAI-compatible router route. Accepted aliases: `huggingface`, `hugging-face`, `hugging_face`, `hf`. Org-prefixed model IDs pass through. `HUGGINGFACE_MODEL` and `HF_MODEL` are accepted. Hub browsing/export are separate future features. |\n| `modelscope` | `[providers.modelscope]` | `MODELSCOPE_API_KEY` | `MODELSCOPE_BASE_URL`; default `https://api-inference.modelscope.cn/v1` | `Qwen/Qwen3.5-397B-A17B` (default), `Qwen/Qwen3.5-122B-A10B`, `Qwen/Qwen3.5-27B`, `Qwen/Qwen3.5-35B-A3B`, `Qwen/Qwen3.8-27B`, `Qwen/Qwen3.8-Flash-Next`, `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Pro-0813`, `deepseek-ai/DeepSeek-V4.1-Flash`, `ZhipuAI/GLM-4.7-Flash`, `ZhipuAI/GLM-5.2` | ModelScope OpenAI-compatible inference route. Org-prefixed model IDs pass through. `MODELSCOPE_MODEL` is accepted. |\n| `deepinfra` | `[providers.deepinfra]` | `DEEPINFRA_API_KEY`, `DEEPINFRA_TOKEN` | `DEEPINFRA_BASE_URL`; default `https://api.deepinfra.com/v1/openai` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | DeepInfra OpenAI-compatible route. Drop-in replacement for OpenAI SDK. |\n| `together` | `[providers.together]` | `TOGETHER_API_KEY` | `TOGETHER_BASE_URL`; default `https://api.together.xyz/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash`, `thinkingmachines/inkling` | Together AI OpenAI-compatible route. `TOGETHER_MODEL` is accepted. Model aliases `deepseek-v4-pro` and `deepseek-v4-flash` normalize to Together's org-prefixed IDs; `inkling` and `together-inkling` normalize to Together's published lowercase Inkling wire ID. Inkling uses the exact `none`/`minimal`/`low`/`medium`/`high`/`max` reasoning vocabulary from Thinking Machines' [official model repository](https://huggingface.co/thinkingmachines/Inkling). Together's [launch post](https://www.together.ai/blog/together-ai-brings-thinking-machines-labs-new-model-inkling-on-day-0) currently says Inkling is live with 1M context, while its [model detail page](https://www.together.ai/models/inkling) says coming soon with 256K context and publishes no price. Until Together's active `/models` endpoint and the Models.dev catalog resolve that conflict, Inkling is not seeded into Codewhale's offline picker and no route-specific context or cost is inferred. |\n| `qianfan` | `[providers.qianfan]` | `QIANFAN_API_KEY`, `BAIDU_QIANFAN_API_KEY` | `QIANFAN_BASE_URL`, `BAIDU_QIANFAN_BASE_URL`; default `https://api.baiduqianfan.ai/v1` | `ernie-4.0-turbo-8k`; provider-scoped custom Qianfan service/model IDs pass through | Baidu Qianfan OpenAI-compatible route. Requests use Bearer auth and Chat Completions payloads. `QIANFAN_MODEL` and `BAIDU_QIANFAN_MODEL` are accepted; aliases `baidu-qianfan`, `baidu_qianfan`, and `baidu` resolve to this provider. Tool/function calling is model-scoped in Qianfan docs, so Codewhale preserves the selected wire model and leaves live capability proof to follow-up route/capability work. |\n| `openai-codex` | `[providers.openai_codex]` | Official Sign in with ChatGPT (`codewhale auth chatgpt` / `/provider setup openai-codex`) | Official `https://api.openai.com/v1` | Selected account catalog; configured model IDs remain explicit selections | **Experimental.** Public Responses endpoint (`/v1/responses`) with a validated `chatgpt.tokens.use.direct` grant. Dynamic OSS registration issues a client ID for the selected account/workspace; Codewhale protects and renews its own tokens. The account catalog supplies selectable models; public model lists and legacy Codex tokens do not establish plan permission. No silent billing fallback. See [Sign in with ChatGPT](#sign-in-with-chatgpt) for setup, usage limits, and preview boundaries. |\n| `anthropic` | `[providers.anthropic]` | `ANTHROPIC_API_KEY` | `ANTHROPIC_BASE_URL`; default `https://api.anthropic.com` | `claude-opus-4-8`, `claude-sonnet-4-6` (default), `claude-haiku-4-5` | Native Anthropic Messages API route (`/v1/messages`, `x-api-key` + `anthropic-version: 2023-06-01`) — not OpenAI-compatible. Prompt caching via `cache_control` breakpoints, adaptive thinking + `output_config.effort`, signed thinking blocks replayed verbatim, cache telemetry normalized per #2961. `ANTHROPIC_MODEL` is accepted. |\n| `openmodel` | `[providers.openmodel]` | `OPENMODEL_API_KEY` | `OPENMODEL_BASE_URL`; default `https://api.openmodel.ai` | `deepseek-v4-flash`; provider-scoped custom model IDs pass through | OpenModel Anthropic-compatible Messages route. Uses `/v1/messages`, Bearer auth, and `anthropic-version: 2023-06-01`; OpenModel selects DeepSeek, DashScope, Xiaomi, Claude, and other routes by model id. `OPENMODEL_MODEL` is accepted. |\n| `sakana` | `[providers.sakana]` | `FUGU_API_KEY`, `SAKANA_API_KEY` | `SAKANA_BASE_URL`; default `https://api.sakana.ai/v1` | `fugu` (default), `fugu-ultra-20260615` | Sakana AI Fugu OpenAI-compatible route. Standard Chat Completions wire protocol; streaming supported. `fugu-ultra-20260615` is the heavy/reasoning variant. Env var aliases: `FUGU_API_KEY` (primary), `SAKANA_API_KEY`; provider aliases: `sakana-ai`, `sakana_ai`, `fugu`. |\n| `longcat` | `[providers.longcat]` | `LONGCAT_API_KEY` | `LONGCAT_BASE_URL`; default `https://api.longcat.chat/openai/v1` | `LongCat-2.0` (default) | Meituan LongCat curated model gateway. OpenAI-compatible Chat Completions wire protocol. Sign up at https://longcat.chat/platform for an API key. Provider aliases: `long-cat`, `meituan-longcat`, `meituan`. |\n| `opencode-go` | `[providers.opencode_go]` | `OPENCODE_GO_API_KEY` | `OPENCODE_GO_BASE_URL`; default `https://opencode.ai/zen/go/v1` | `deepseek-v4-pro` (default), `grok-4.5`, `glm-5.2`, `glm-5.1`, `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.6`, `deepseek-v4-flash`, `mimo-v2.5`, `mimo-v2.5-pro` | [OpenCode Go](https://opencode.ai/docs/go/) subscription route using OpenAI-compatible Chat Completions. `OPENCODE_GO_MODEL` is accepted. Codewhale uses bare wire IDs; familiar `opencode-go/` input aliases normalize to the bare ID. Go models documented only on the Anthropic `/messages` endpoint are deliberately not advertised by this route until Codewhale supports per-model wire selection. Billing surfaces show the Go allowance instead of token-price estimates. |\n| `opencode-zen` | `[providers.opencode_zen]` | `OPENCODE_ZEN_API_KEY`, fallback `OPENCODE_API_KEY` | `OPENCODE_ZEN_BASE_URL`; default `https://opencode.ai/zen/v1` | `gpt-5.6` (default); current GPT, Claude, Qwen, DeepSeek, MiniMax, GLM, Kimi, Grok, Muse Spark, and free-model IDs | [OpenCode Zen](https://opencode.ai/docs/zen/) model-aware gateway. `OPENCODE_ZEN_MODEL` is accepted, and official `opencode/` selectors normalize to bare wire IDs. Each model's wire comes from the curated snapshot, then from the AI SDK package its [Models.dev](https://models.dev) row names: GPT, Grok, and Muse Spark rows use `/responses`; Claude and most Qwen rows use `/messages`; DeepSeek, MiniMax, GLM, Kimi, `qwen3.8-max`, and the free rows use `/chat/completions`. Responses and Chat Completions authenticate with Bearer `Authorization`, while Anthropic Messages uses `x-api-key`; none of these routes use ChatGPT/Codex OAuth guidance or headers. Gemini fails closed because its model-specific Google wire protocol is not implemented; Models.dev rows marked `deprecated` and models no loaded catalog lists also fail closed. See [OpenCode Zen protocol catalog](#opencode-zen-protocol-catalog). |\n| `meta` | `[providers.meta]` | `META_MODEL_API_KEY`, `MODEL_API_KEY` | `META_MODEL_API_BASE_URL`, `MODEL_API_BASE_URL`; default `https://api.meta.ai/v1` | `muse-spark-1.2` (default) | [Meta Model API](https://developer.meta.com/ai/resources/blog/build-with-muse-spark/) public-preview route using OpenAI-compatible Chat Completions. Muse Spark 1.2 keeps its wire ID, tool support, 1M context, 32K output metadata, and `none` through `xhigh` reasoning effort. `META_MODEL_API_MODEL` and `MODEL_API_MODEL` are accepted. Provider aliases: `meta-ai`, `meta_model_api`, `muse`, `muse-spark`. |\n| `telecomjs` | `[providers.telecomjs]` | `TELECOMJS_API_KEY` | `TELECOMJS_BASE_URL`; default `https://aigw.telecomjs.com/v1` | `deepseek-v4-pro` conservative fallback; authenticated `/models` rows when a key is configured | TelecomJS TokenHub OpenAI-compatible Chat Completions route. Live catalogs are isolated by provider and key fingerprint, stale rows survive transient refresh failures, and unsupported reasoning request fields are omitted. `TELECOMJS_MODEL` is accepted. Provider aliases: `telecom-js`, `telecom_js`, `telecomjs-cn`, `tokenhub`. |\n| `mistral` | `[providers.mistral]` | `MISTRAL_API_KEY` | `MISTRAL_BASE_URL`; default `https://api.mistral.ai/v1` | `mistral-code-latest` (default; `codestral-latest` accepted as alias), `mistral-medium-latest` (aliases: `mistral-medium-3-5`), `mistral-small-latest` (aliases: `mistral-small-2603`), `mistral-large-latest` | Mistral AI (la Plateforme) OpenAI-compatible Chat route. On the documented first-party HTTPS `/v1` hosts, Medium and Small send adjustable `reasoning_effort` (`none` or `high` only), parse Mistral's polymorphic thinking/text blocks, and replay stored thinking in that same wire shape. Deprecated native Magistral IDs remain explicit-configuration compatibility routes: they are always-reasoning and never receive the adjustable effort field. Code and Large are non-reasoning. A custom `MISTRAL_BASE_URL` keeps generic Chat semantics unless it is one of the documented first-party hosts. `MISTRAL_MODEL` is accepted. Provider aliases: `mistral-ai`, `mistralai`, `la-plateforme`. |\n| `edenai` | `[providers.edenai]` | `EDENAI_API_KEY` | `EDENAI_BASE_URL`; default `https://api.edenai.run/v3`; EU `https://api.eu.edenai.run/v3` | `deepseek/deepseek-v4-pro` (default); live `/models` catalog of `provider/model` ids | Eden AI OpenAI-compatible aggregation gateway. Catalog rows remain provider-scoped; generic reasoning controls are omitted because supported fields depend on the selected upstream family. `EDENAI_MODEL` is accepted. The default `deepseek/deepseek-v4-pro` is listed on the global catalog only; on the EU endpoint set `EDENAI_MODEL` (or `model`) to a row from the EU `/models` list, for example `qwen/deepseek-v4-pro`. Provider aliases: `eden-ai`, `eden_ai`. |\n| `zenmux` | `[providers.zenmux]` | `ZENMUX_API_KEY` | `ZENMUX_BASE_URL`; default `https://zenmux.ai/api/v1` | `deepseek/deepseek-v4.1-flash` (default); live `/models` catalog of `provider/model` ids (keyless-readable) | ZenMux OpenAI-compatible aggregation gateway (~190 models). Catalog rows remain provider-scoped; generic reasoning controls are omitted because supported fields depend on the selected upstream family. `ZENMUX_MODEL` is accepted. Provider aliases: `zen-mux`, `zen_mux`. |\n| `csdn` | `[providers.csdn]` | `CSDN_API_KEY` | `CSDN_BASE_URL`; default `https://ai.csdn.net/api/model/v1` | `glm_for_coding` (default; the Coding Plan's dedicated model id); other marketplace model ids pass through | CSDN 星图 (Starmap) OpenAI-compatible hosted platform. Coding Plan keys and general marketplace keys share the one endpoint, so billing follows the credential product, never the URL alone: routing `glm_for_coding` — the shipped default — or setting `mode = \"coding_plan\"`/`\"plan\"`/`\"subscription\"` bills as CSDN Coding Plan quota with no dollar estimates; any other model, or an explicit `pay-as-you-go`/`metered` mode, bills metered; an unrecognized mode or an endpoint off `ai.csdn.net/api/model/v1` reports `cost: unknown`. `CSDN_MODEL` is accepted. Provider aliases: `csdn-ai`, `csdn_ai`, `csdn-coding-plan`, `csdn_coding_plan`, `starmap`. |\n| `concentrate` | `[providers.concentrate]` | `CONCENTRATE_API_KEY` | `CONCENTRATE_BASE_URL`; default `https://api.concentrate.ai/v1` | `deepseek-v4-pro` (default; a plain catalog id lets the gateway pick the upstream provider); `provider/model` ids such as `openai/gpt-5.6-sol` pin one upstream; `concentrate/auto` is the gateway's own router; unauthenticated live `/v1/models` catalog | Concentrate OpenAI Responses-compatible gateway (`POST /v1/responses`, bearer Universal API key). Opt-in and BYOK only: your key, your Concentrate bill, zero Codewhale fee, no managed default. Requests carry only documented fields (the system prompt rides as a `system` input item). Contract: [API introduction](https://concentrate.ai/docs/api-reference/introduction), [request parameters](https://concentrate.ai/docs/api-reference/endpoint/request-parameters), [streaming](https://concentrate.ai/docs/api-reference/endpoint/streaming), [errors](https://concentrate.ai/docs/api-reference/endpoint/errors). See [Concentrate Notes](#concentrate-notes). |\n| `codewhale` | `[providers.codewhale]` | `CODEWHALE_API_KEY` | `CODEWHALE_API_BASE`; default `https://api.codewhale.net/v1` | `deepseek/deepseek-v4-pro` (default), `anthropic/claude-sonnet-5`, `openai/gpt-5.6` are offline bootstrap rows only; the authenticated `GET /v1/models` listing of the account's connected providers is the catalog authority | Codewhale API: account-backed model access over the provider keys the customer connected to their Codewhale account. One `cwc_key_…` account API key with the `models:infer` scope authenticates every model. Model ids are `provider/model` exactly as the account catalog returns them, and each row states its protocol (`chat-completions` → `/v1/chat/completions`, `anthropic-messages` → `/v1/messages`, `responses` → `/v1/responses`); every protocol uses `Authorization: Bearer`, never `x-api-key`. `CODEWHALE_API_BASE` must be HTTPS except on loopback. Connect provider keys with `codewhale account keys set `. Provider aliases: `codewhale-api`, `cw-api`, `codewhale-cloud`. |\n| `xai` | `[providers.xai]` | `XAI_API_KEY`, Codewhale-owned device OAuth, or explicit read-only Grok CLI consent | `XAI_BASE_URL`; default `https://api.x.ai/v1` | `grok-4.6` (default), `grok-4.7`, `grok-4.5`, `grok-4.3`, `grok-build`, `grok-composer-2.5-fast`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning` | xAI/Grok OpenAI-compatible Chat Completions route. Grok 4.6 has a 500K context window, text/image input, function calls, structured output, server-side web search, and `low`/`medium`/`high`/`xhigh` reasoning (default `high`). [Grok 4.7](https://docs.x.ai/docs/models/grok-4.7) has the same 500K window, text/image input, function calls, structured output, reasoning ladder and $2.00 / $0.50 cached / $6.00 rates; xAI documents no web-search support for it yet, so Codewhale does not claim it. Grok reasoning cannot be disabled ([reasoning guide](https://docs.x.ai/docs/guides/reasoning)). Its standard rates double when the prompt reaches 200K tokens; the same 2x long-context rule applies to `grok-4.5` (500K context, $2.00 / $0.30 cached / $6.00) and `grok-4.3` (1M context, $1.25 / $0.20 cached / $2.50) per their [model pages](https://docs.x.ai/docs/models/grok-4.5). There is no documented `latest`/`fast` alias and no published numeric output limit. **API-key** (default): Bearer token from console.x.ai via `XAI_API_KEY` / keyring / `api_key`. **OAuth**: `codewhale auth xai-device` uses SSH-friendly device login and Codewhale-owned storage, which may refresh itself. Existing Grok CLI credentials require `codewhale auth external-consent --provider xai --mode read-only`; the granted external file is never refreshed or rewritten. OAuth may return HTTP 403 on some SuperGrok tiers — keep API-key as the reliable fallback. `XAI_MODEL` is accepted. Provider aliases: `x-ai`, `x_ai`, `grok`. |\n| `modelstudio-token-plan` | `[providers.modelstudio_token_plan]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | `MODELSTUDIO_TOKEN_PLAN_BASE_URL`; default `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` | `qwen3.8-max` (default), `qwen3.8-max-preview`, `qwen3.7-plus`, `qwen3.7-max`, `qwen3.6-flash`, `deepseek-v4-pro`, `deepseek-v4-flash-0731`, `glm-5.2` | Alibaba Cloud Model Studio Token Plan OpenAI-compatible Chat Completions route. Token Plan Personal and Team share this endpoint. `qwen3.8-max`, `qwen3.7-plus`, and `qwen3.7-max` can use provider-native web search through the Token Plan Responses Harness; the preview, Coding Plan, and Anthropic routes do not inherit that capability. All listed models are reasoning-capable text/coding models. DeepSeek and GLM entries are provider-scoped and do not collide with first-party routes. `MODELSTUDIO_TOKEN_PLAN_MODEL` is accepted. Provider aliases: `modelstudio-token-plan`, `alibaba-token-plan`, `dashscope-token-plan`. |\n| `modelstudio-token-plan-anthropic` | `[providers.modelstudio_token_plan_anthropic]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | default `https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic` | Same model catalog as `modelstudio-token-plan` | Token Plan Anthropic-compatible Messages route (`/apps/anthropic`). Same API key as the OpenAI dialect. Provider aliases: `modelstudio-token-plan-anthropic`, `alibaba-token-plan-anthropic`. |\n| `modelstudio-coding-plan` | `[providers.modelstudio_coding_plan]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | `MODELSTUDIO_CODING_PLAN_BASE_URL`; default `https://coding-intl.dashscope.aliyuncs.com/v1` | `qwen3.8-max` (default); same catalog as Token Plan | Alibaba Cloud Model Studio Coding Plan OpenAI-compatible Chat Completions route. `MODELSTUDIO_CODING_PLAN_MODEL` is accepted. Provider aliases: `modelstudio-coding-plan`, `alibaba-coding-plan`, `dashscope-coding-plan`. |\n| `modelstudio-coding-plan-anthropic` | `[providers.modelstudio_coding_plan_anthropic]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | default `https://coding-intl.dashscope.aliyuncs.com/apps/anthropic` | Same model catalog as `modelstudio-coding-plan` | Coding Plan Anthropic-compatible Messages route (`/apps/anthropic`). Provider aliases: `modelstudio-coding-plan-anthropic`, `alibaba-coding-plan-anthropic`. |\n\nStepFun's four coding models are available through both its standard API and\n[Step Plan](https://platform.stepfun.ai/docs/en/step-plan/integrations/reasoning-api).\nChoose the billing route in `/provider`, then the model in `/model`; existing\nStep 3.7 selections remain unchanged. [Step 5 Preview](https://platform.stepfun.ai/docs/en/guides/models/step-5-preview)\nhas a 1M context window. Step 5 and Step 3.7 expose low/medium/high reasoning;\nStep 3.5 Flash 2603 exposes low/high; base Step 3.5 uses provider-default reasoning.\n[Published API prices](https://platform.stepfun.ai/docs/en/guides/pricing/details)\napply only to verified PAYG routes. Step Plan displays subscription allowance.\nSpeech, music and image-generation models use separate interfaces and are not\npresented as coding models. Provider video capability metadata does not imply\nthat every Codewhale client can attach video.\n\n\n### OpenCode Zen protocol catalog\n\nZen Responses and Chat Completions requests authenticate with Bearer\n`Authorization`; Zen Anthropic Messages requests use `x-api-key`. None of these\nroutes add ChatGPT/Codex OAuth headers.\n\nEach Zen model's wire comes from two sources, and neither is guessed from a\nmodel-id family (`qwen3.8-flash` is Messages while `qwen3.8-max` is Chat):\n\n1. **The curated snapshot** compiled into Codewhale, verified against the\n [official endpoint table](https://opencode.ai/docs/zen/) and the `opencode`\n provider in [Models.dev](https://models.dev). It is the offline floor, and\n it wins when a catalog row names a different wire for the same id.\n2. **The Models.dev catalog.** Its `opencode` provider is Zen's published\n catalog: each model row names the AI SDK package OpenCode itself uses, which\n Codewhale maps exactly — `@ai-sdk/openai` to Responses, `@ai-sdk/anthropic`\n to Anthropic Messages, and `@ai-sdk/openai-compatible` (the provider\n default) to Chat Completions. A Zen model released after this build routes\n once the catalog lists it, without a Codewhale release. The interactive TUI\n and `codewhale exec` both load the persisted catalog; refresh it with\n `codewhale models --update`.\n\nThe curated snapshot:\n\n- Responses: `gpt-6-astra`, `gpt-6-sol`, `gpt-6-luna`, `gpt-5.6-sol`,\n `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5`, `gpt-5.5-pro`, `gpt-5.4`,\n `gpt-5.4-pro`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.3-codex`,\n `gpt-5.3-codex-spark`, `gpt-5.2`, `gpt-5.2-codex`, `gpt-5.1`,\n `gpt-5.1-codex`, `gpt-5.1-codex-max`, `gpt-5.1-codex-mini`, `gpt-5`,\n `gpt-5-codex`, `gpt-5-nano`, `grok-4.7`, `grok-4.6`, `grok-4.5`,\n `grok-build-0.1`, `muse-spark-1.3`, `muse-spark-1.3-contributor-free`,\n `muse-spark-1.2`, `muse-spark-1.2-contributor`,\n `muse-spark-1.2-contributor-free`.\n- Anthropic Messages: `claude-fable-5-1`, `claude-fable-5`,\n `claude-opus-5-5`, `claude-opus-5`, `claude-opus-4-8`, `claude-opus-4-7`,\n `claude-opus-4-6`, `claude-opus-4-5`, `claude-sonnet-5-5`,\n `claude-sonnet-5`, `claude-sonnet-4-6`, `claude-sonnet-4-5`,\n `claude-sonnet-4`, `claude-haiku-4-5`, `qwen3.8-flash`, `qwen3.7-max`,\n `qwen3.7-plus`, `qwen3.6-plus`, `qwen3.5-plus`.\n- Chat Completions: `deepseek-v4.1-flash`, `deepseek-v4-pro`,\n `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp`, `minimax-m3`,\n `minimax-m2.7`, `minimax-m2.5`, `glm-5.3-flash`, `glm-5.3`, `glm-5.2`,\n `glm-5.1`, `glm-5`, `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.6`, `kimi-k2.5`,\n `qwen3.8-max`, `big-pickle`, `space-bunny-free`,\n `longcat-2.5-preview-free`, `mimo-v2.6-flash-free`, `mimo-v2.5-free`,\n `ling-3.0-flash-fin-free`, `north-mini-code-free`,\n `nemotron-3-ultra-free`, `nemotron-3.5-lightning-free`,\n `deepseek-v4-flash-free`.\n\nThese fail closed locally, with the reason in the error, instead of reaching\nZen: Gemini (`@ai-sdk/google`, Google's model-specific protocol, which Codewhale\ndoes not speak); a catalog row naming any other package; a catalog row Models.dev\nmarks `deprecated`; and a model neither source lists. A miss never falls back to\nanother Zen wire shape, including when a custom Zen base URL is configured.\n\n### Concentrate Notes\n\nConcentrate (`concentrate`) is an OpenAI **Responses**-compatible gateway:\n`POST https://api.concentrate.ai/v1/responses` with `Authorization: Bearer\n`. It is opt-in and **BYOK only**.\n\n- **Model ids pass through.** A plain catalog id (`gpt-5.6-sol`,\n `deepseek-v4-pro`) lets the gateway choose the upstream provider;\n `provider/model` (`openai/gpt-5.6-sol`) pins the upstream provider inside the\n gateway; `concentrate/auto` sends the gateway's own `auto` router. Codewhale's\n bare `--model auto` remains the resolver sentinel (provider default), which is\n why the gateway router has its own explicit spelling. Once streaming begins\n the gateway commits to one provider ([auto routing](https://concentrate.ai/docs/api-reference/endpoint/auto-routing)).\n- **Wire.** Fixed Responses dialect. The request carries only documented\n fields — `model`, `input`, `stream`, `max_output_tokens`, `tools`,\n `tool_choice`, `parallel_tool_calls`, `reasoning.effort` — and the system\n prompt travels as a leading `system` input item because `instructions` is\n not in the gateway's [parameter reference](https://concentrate.ai/docs/api-reference/endpoint/request-parameters).\n Streaming follows the typed `response.*` SSE events and ends on\n `response.completed` without a `[DONE]` sentinel ([streaming](https://concentrate.ai/docs/api-reference/endpoint/streaming)).\n- **Errors.** `{ \"error\", \"message\", \"model\"?, \"retry_after\"? }` with 400\n (bad request / unknown model), 401 (invalid key), 402 (insufficient\n credits), 424 (upstream provider unavailable), 429 (rate limit) —\n surfaced verbatim and classified by message ([errors](https://concentrate.ai/docs/api-reference/endpoint/errors)).\n- **Catalog.** `GET /v1/models` needs no key\n ([list models](https://concentrate.ai/docs/api-reference/endpoint/list-models)); the live rows\n are provider-scoped and unclaimed (no pricing or capability claims).\n- **Commercial boundary.** Concentrate's [Terms of Service](https://concentrate.ai/legal/terms-of-service)\n forbid reselling, white-labeling, or providing the service on a\n service-bureau basis without written consent, and its\n [Acceptable Use Policy](https://concentrate.ai/legal/acceptable-use-policy)\n forbids sharing or sublicensing keys. Codewhale therefore ships **no**\n Codewhale-owned Concentrate key, no stored customer key, no default or\n managed routing, and no markup — activation of any hosted lane is gated on\n written consent, terms, and billing approval (tracked in the Codewhale ops\n evidence checklist `concentrate-gateway-20260829/CHECKLIST.md`). Public\n pricing states \"No platform markup on tokens\".\n- **Self-test without a key.** `scripts/concentrate-selftest.sh` boots a\n local stub that speaks the documented contract and drives the real\n `codewhale exec` path through it, asserting the URL, bearer header, model\n passthrough, streaming, and the completed-turn receipt. No network call\n leaves the machine and no account is required.\n\n### Hugging Face Provider vs MCP vs Hub\n\nCodewhale's `huggingface` provider ID is only the OpenAI-compatible chat\ninference route through Hugging Face Inference Providers. It is selected with\n`/provider huggingface`, `CODEWHALE_PROVIDER=huggingface`, or\n`provider = \"huggingface\"`.\n\nHugging Face MCP is a separate external-tool route. Configure it through the\nMCP config described in `docs/MCP.md`, preferably using the settings-generated\nsnippet from . In the TUI, `/hf mcp status`\nchecks whether the Hugging Face MCP server appears in the resolved MCP config,\n`/hf mcp setup` prints the settings workflow and a placeholder-only shape, and\n`/hf concepts` explains the provider/MCP/Hub distinction.\n\nHub publishing or repository management remains explicit user action through\nHub-native tooling such as `huggingface_hub` or git. The `/hf` helper does not\nupload to Hugging Face and does not perform direct Hugging Face Hub HTTP search.\n\n### Xiaomi MiMo Notes\n\n`xiaomi-mimo` defaults to `mimo-v2.5-pro` for long-context reasoning and coding\nwork. The chat picker also exposes `mimo-v2.5-pro-ultraspeed` and the latest\nOmni model `mimo-v2.5`. Xiaomi MiMo TTS is available through\n`codewhale --provider xiaomi-mimo speech \"text\" --model tts` (or the `tts`\nalias). In Act and Operate, the provider-specific `speech` / `tts` tools are\navailable through deferred discovery when the Xiaomi MiMo route is configured.\n\n`/provider xiaomi-mimo ultraspeed` and `/provider xiaomi-mimo pro-ultraspeed`\nboth select `mimo-v2.5-pro-ultraspeed`. Speech aliases such as `tts`,\n`voice-design`, and `voice-clone` are separate from normal chat defaults.\n\nToken Plan keys default to the Singapore endpoint\n`https://token-plan-sgp.xiaomimimo.com/v1`. If your MiMo account is provisioned\nfor the China region, set `base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"`\nexplicitly in `[providers.xiaomi_mimo]` or set `mode = \"token-plan-cn\"`. Europe\nToken Plan accounts can set\n`base_url = \"https://token-plan-ams.xiaomimimo.com/v1\"` or use\n`mode = \"token-plan-ams\"`; `mode = \"pay-as-you-go\"`\nselects the standard API endpoint and standard MiMo key family. Xiaomi Token\nPlan docs and console expose credit/quota semantics, but Codewhale does not\ncurrently have a documented balance endpoint to poll, so cost display remains\nunknown rather than reusing token-price estimates from another provider.\n\nVoice-design and voice-clone shorthands map to `mimo-v2.5-tts-voicedesign` and\n`mimo-v2.5-tts-voiceclone`. Xiaomi's current\n[image-understanding guide](https://platform.xiaomimimo.com/docs/en-US/usage-guide/multimodal-understanding/image-understanding)\nincludes `mimo-v2.5` for image input. Codewhale exposes image analysis through the\nseparate `[vision_model]` / `image_analyze` path; set that model to\n`mimo-v2.5` when using MiMo for vision.\n\n### OpenRouter-Compatible Base URLs\n\nOpenRouter-compatible gateways should usually stay on the `openrouter`\nprovider with a provider-scoped `base_url` override instead of moving through\nthe generic `openai` route. That keeps OpenRouter-style reasoning, streaming,\ncache usage, and namespaced wire model parsing attached to the selected route:\n\n```toml\nprovider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"sk-...\"\nbase_url = \"https://openrouter-compatible.example/v1\"\nmodel = \"deepseek/deepseek-v4-pro\"\n```\n\nCodewhale preserves the `deepseek/` wire-model prefix under the OpenRouter\nprovider scope; it does not infer a switch to the direct DeepSeek provider from\nthat model string. Cache fields such as `prompt_cache_hit_tokens`,\n`prompt_cache_miss_tokens`, and `prompt_tokens_details.cached_tokens` are\nparsed when the upstream gateway sends them. If a key/account type omits those\nfields, Codewhale treats them as absent for that response rather than as a\ndifferent provider route.\n\nOrcaRouter (`https://api.orcarouter.ai/v1`) is a dedicated named route\n([OrcaRouter](https://www.orcarouter.ai)) that speaks the same OpenAI\nChat Completions wire protocol and serves the same namespaced\n`vendor/model` catalog. It does not need an OpenRouter-compatible\n`base_url` override: select `provider = \"orcarouter\"` and its namespaced\nwire models (for example `deepseek/deepseek-v4-pro` or its own\n`orcarouter/auto` router) pass through verbatim, exactly as they do on the\nOpenRouter provider scope.\n\n#### OrcaRouter credentials: API key or OAuth 2.0 + PKCE\n\nOrcaRouter accepts two credential sources, and both write the same durable\n`sk-orca-…` key to the same `orcarouter` secret-store slot with\n`auth_mode = \"api_key\"`:\n\n- **API key** — `codewhale auth set --provider orcarouter`, `/provider`, or\n `ORCAROUTER_API_KEY`. Use this when you already have a key.\n- **Connect with OrcaRouter** — `codewhale auth orcarouter` (CLI) or\n `/auth orcarouter` (in-session). This runs an OAuth 2.0 authorization-code\n flow with PKCE (S256) on a loopback redirect. The browser is sent to\n `GET https://www.orcarouter.ai/auth` with `callback_url`, `code_challenge`,\n `code_challenge_method=S256`, `state`, `app_name`, and `scope`; the code is\n exchanged at `POST https://www.orcarouter.ai/api/v1/auth/keys` for a\n durable API key. There is no client secret and no pre-registered redirect\n URI; state is compared in constant time before the code is used.\n\nAuthentication and inference use different origins: the consent and exchange\nlive on `https://www.orcarouter.ai`; models and chat live on\n`https://api.orcarouter.ai/v1`. Neither is derived from the other, and\n`/v1/auth/keys` on the API origin is not the exchange route. Self-hosted\ndeployments can override each origin explicitly with `ORCA_AUTH_BASE_URL`\n(auth) and `ORCA_API_BASE_URL`/`ORCAROUTER_BASE_URL` (inference); the explicit\nvalue wins. Remote origins must be HTTPS; plain HTTP is accepted only on\nloopback.\n\nA PKCE-issued key is a durable API key, **not** a refresh token: Codewhale\nstores it, reuses it across restarts, and never sends a refresh grant. Revoke\nthe key on the OrcaRouter console (`/auth orcarouter-revoke` clears the local\ncopy) and sign in again to get a new one. A 401 from the relay marks that\ncredential generation as needing re-authentication rather than silently\nretrying.\n\nThe chat model list is discovered live from `GET https://api.orcarouter.ai/v1/models`\nwith the configured key. OrcaRouter publishes `supported_endpoint_types` on\nevery row, so the chat selector keeps only rows advertising `openai`,\n`anthropic`, `gemini`, or `openai-response`, and drops image-generation,\nvideo, and rerank rows instead of guessing from a model name. A row that\nstates `architecture.input_modalities` with `image` is image-input capable;\nrows that state no architecture are treated as unknown, not as text-only.\n\n### Recent OpenRouter Large Models\n\nOpenRouter completions and static registry rows include the April 2026 onward\nlarge models verified through OpenRouter's model metadata:\n`arcee-ai/trinity-large-thinking`, `qwen/qwen3.6-flash`,\n`qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`,\n`qwen/qwen3.6-plus`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`,\n`xiaomi/mimo-v2.5`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`,\n`z-ai/glm-5.1`, `z-ai/glm-5.2`, `z-ai/glm-5-turbo`, `tencent/hy3-preview`,\n`google/gemma-4-31b-it`, `google/gemma-4-26b-a4b-it`, and\n`nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`.\n`minimax/minimax-m3` was added from OpenRouter's May 31, 2026 listing as a 1M\ncontext multimodal model for coding, tool use, and long-horizon agentic work.\n`GLM-5.3` is now the default direct Z.AI Coding Plan model; `GLM-5.2` /\n`z-ai/glm-5.2` remain available (explicit selections keep their own id),\n`GLM-5.1` / `z-ai/glm-5.1` remain available as the smaller model,\n`GLM-5.3-Flash` / `z-ai/glm-5.3-flash` is the faster/explore sibling of\n`GLM-5.3`, and `GLM-5-Turbo` / `z-ai/glm-5-turbo` remains the faster sibling\nof `GLM-5.2`.\n`GLM-5.3` / `z-ai/glm-5.3` and `GLM-5.3-Flash` / `z-ai/glm-5.3-flash` are\nfirst-class picker ids on the Z.ai and OpenRouter routes (`/model` after\n`/provider zai`, or `model = \"GLM-5.3-Flash\"`).\nLimits and reasoning options for 5.3 are inherited from\n`GLM-5.2` until Z.ai publishes distinct 5.3 metadata, and 5.3 carries no\nprice. Flash ships the published $0.15/$0.50 list. A live call can still\n429 with entitlement code 1311 on accounts that are not provisioned for 5.3.\n\n## Static Model Registry\n\nThis table records legacy selector compatibility. Current `codewhale model\nresolve`, `codewhale model list`, the picker and Runtime API use the shared\nprovider lake and route resolver. `models --update` refreshes the existing\ncatalog cache; later routes see new metadata, while dispatched requests retain\ntheir admitted limits and price receipt.\n\nThe reviewed `selections` in `crates/config/assets/catalog_corrections.json`\nremain compatibility metadata for legacy callers. They do not establish account\navailability or executable route permission. Public discovery can contain\nmetadata-only models; an authenticated provider roster establishes a different\nfact. A same-named custom endpoint keeps its own declared limits and prices.\n\n`codewhale model resolve MODEL --provider PROVIDER` reports the exact wire ID,\ncanonical ID when declared, limits and implemented capabilities. Runtime\n`GET /v1/providers/PROVIDER/models` adds source, fetch/expiry metadata, published\nprice structure, reasoning options, configured effective limits and the price\nstatus. `account_access: unverified` means these facts have not established the\nselected account's access. Context responses distinguish an active engine\nwindow from the static route's resolution source, including any fallback.\nSee [Catalog refresh](CATALOG_REFRESH.md) for freshness and estimate boundaries.\n\n| Provider | Static registry entries | Tool calls | Registry reasoning flag |\n| --- | --- | --- | --- |\n| `deepseek` | `deepseek-v4-pro`, `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp` | yes | yes |\n| `nvidia-nim` | `deepseek-ai/deepseek-v4-pro`, `deepseek-ai/deepseek-v4-flash` | yes | yes |\n| `openai` | `deepseek-v4-pro`, `deepseek-v4-flash`, `gpt-5.6`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` | yes | yes |\n| `atlascloud` | `deepseek-ai/deepseek-v4-flash`, `deepseek-ai/deepseek-v4-pro` | yes | yes |\n| `wanjie-ark` | `deepseek-reasoner` | yes | yes |\n| `volcengine` | `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash` | yes | yes |\n| `openrouter` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`, `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `minimax/minimax-m2.7`, `xiaomi/mimo-v2.5-pro`, `xiaomi/mimo-v2.5`, `qwen/qwen3.6-flash`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`, `qwen/qwen3.6-plus`, `qwen/qwen3.7-max`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`, `z-ai/glm-5.1`, `z-ai/glm-5.2`, `z-ai/glm-5.3`, `z-ai/glm-5.3-flash`, `z-ai/glm-5-turbo`, `tencent/hy3-preview`, `google/gemma-4-31b-it`, `google/gemma-4-26b-a4b-it`, `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`, `nvidia/nemotron-3-ultra-550b-a55b` | yes | yes |\n| `orcarouter` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`, `orcarouter/auto` | yes | yes |\n| `xiaomi-mimo` | `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, `mimo-v2.5`; speech/TTS IDs are selected through `codewhale speech` / `tts` | yes | yes for chat models; no for speech/TTS models |\n| `novita` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | yes | yes |\n| `fireworks` | `accounts/fireworks/models/deepseek-v4-pro` | yes | yes |\n| `siliconflow` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes |\n| `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` |\n| `moonshot` | `kimi-k2.7-code`, `kimi-k2.6` | yes | yes |\n| `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 |\n| `stepfun` | `step-3.7-flash` (default), `step-5-preview`, `step-3.5-flash`, `step-3.5-flash-2603` | yes | yes |\n| `minimax` | `MiniMax-M3`, `MiniMax-M2.7`, `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `MiniMax-M2.1`, `MiniMax-M2.1-highspeed`, `MiniMax-M2` | yes | yes |\n| `minimax-anthropic` | `MiniMax-M3`, `MiniMax-M2.7` | yes | yes |\n| `sglang` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes |\n| `vllm` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes |\n| `ollama` | live local tag; custom tags pass through when provider hint is `ollama` | yes | no |\n| `ollama-cloud` | `gpt-oss:120b`; arbitrary provider-owned model IDs pass through | yes | yes |\n| `huggingface` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | no |\n| `modelscope` | `Qwen/Qwen3.5-397B-A17B`, `Qwen/Qwen3.5-122B-A10B`, `Qwen/Qwen3.5-27B`, `Qwen/Qwen3.5-35B-A3B`, `Qwen/Qwen3.8-27B`, `Qwen/Qwen3.8-Flash-Next`, `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Pro-0813`, `deepseek-ai/DeepSeek-V4.1-Flash`, `ZhipuAI/GLM-4.7-Flash`, `ZhipuAI/GLM-5.2` | yes | no |\n| `deepinfra` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes |\n| `together` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash`, `thinkingmachines/inkling` | yes | yes |\n| `openai-codex` | `gpt-5.5` | yes | yes |\n| `anthropic` | `claude-opus-5`, `claude-opus-4-8`, `claude-sonnet-5`, `claude-sonnet-4-6`, `claude-fable-5`, `claude-haiku-4-5` | yes | yes except `claude-haiku-4-5` |\n| `openmodel` | `deepseek-v4-flash`; provider-scoped custom model IDs pass through | yes | model-dependent |\n| `sakana` | `fugu`, `fugu-ultra-20260615` | yes | yes for `fugu-ultra-20260615` |\n| `longcat` | `LongCat-2.0` | yes | yes |\n| `opencode-go` | `deepseek-v4-pro`, `grok-4.5`, `glm-5.2`, `glm-5.1`, `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.6`, `deepseek-v4-flash`, `mimo-v2.5`, `mimo-v2.5-pro` | yes | yes |\n| `meta` | `muse-spark-1.2` | yes | yes |\n| `xai` | `grok-4.6`, `grok-4.7`, `grok-4.5`, `grok-4.3`, `grok-build`, `grok-composer-2.5-fast`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning` | yes | yes for `grok-4.6`, `grok-4.7`, `grok-4.5`, `grok-4.3`, `grok-build`, and `grok-4.20-0309-reasoning` |\n| `google` | `gemini-3.1-pro-preview`, `gemini-3-pro-preview`, `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-2.5-pro`, `gemini-2.5-flash` | yes | yes except `gemini-3.5-flash-lite` |\n| `mistral` | `mistral-code-latest`, `mistral-medium-latest`, `mistral-small-latest`, `mistral-large-latest` | yes | yes for Medium and Small (`reasoning_effort` `none` or `high` on exact first-party routes); deprecated native Magistral remains an always-on explicit compatibility ID; no for Code and Large |\n| `modelstudio-token-plan`, `modelstudio-coding-plan` | `qwen3.8-max`, `qwen3.8-max-preview`, `qwen3.7-plus`, `qwen3.7-max`, `qwen3.6-flash`, `deepseek-v4-pro`, `deepseek-v4-flash-0731`, `glm-5.2` | yes | yes |\n| `csdn` | `glm_for_coding`; other marketplace model IDs pass through | yes | yes |\n\nAtlasCloud keeps the same default model as the config layer and adds\nprovider-scoped aliases for the Pro and Flash rows. Other AtlasCloud model IDs\nshould still be selected through `ATLASCLOUD_MODEL`, config, or live model\nlisting when available.\n\n## Capability Metadata\n\n`codewhale doctor --json` exposes the `capability` object. It is static\nmetadata, not a live API probe. Current fields are:\n\n`resolved_provider`, `resolved_model`, `context_window`, `max_output`,\n`thinking_supported`, `cache_telemetry_supported`, and `request_payload_mode`.\n\nWhen configuration cannot be loaded or validated, `doctor --json` exits\nnonzero and prints a bounded, secret-redacted JSON error envelope with\n`status = \"error\"` and `error.kind = \"config_validation\"` instead of emitting\nmisleading route or capability metadata.\n\nMost shipped providers use the Chat Completions request payload mode. Native\nMessages routes, including `minimax-anthropic`, use `/v1/messages`, and\n`openai-codex` uses Responses.\n\nFor OpenAI-compatible gateways or self-hosted runtimes whose real window\ndiffers from the static table, set `[providers.] context_window = N`.\nThe configured value becomes the route-effective context window for prompts,\ncontext-pressure checks, compaction, and output-cap budgeting.\n\n`max_output` is optional and truthful: it is `null` (and omitted from the\ncapability struct on the wire) when the route publishes no output maximum we\ncan stand behind — the Kimi Code membership `kimi-for-coding` family is the\ncanonical example, since the membership catalog owns their limits. An unknown\noutput ceiling is never backfilled with a placeholder, and it applies **no**\ncompatibility clamp to a turn's requested `max_tokens`; only a concrete\nroute/offering maximum narrows the request. A model the catalogue simply has no\nrow for is a different fact — absence is not permission, so an uncatalogued id\nkeeps a conservative ceiling. The \"Max output metadata\" column below reads\n`unknown` wherever no documented maximum exists.\n\nThe exact Kimi Code membership roster contains `k3`, `k3-256k`,\n`kimi-for-coding`, and `kimi-for-coding-highspeed`. The two K3 ids share the\nsame reasoning and fixed-sampling contract; `k3-256k` stays at 262,144 tokens,\nwhile bare `k3` can use an entitled 1M override.\n\n| Provider/model class | Context window | Max output metadata | Thinking support | Cache telemetry | FIM endpoint |\n| --- | --- | --- | --- | --- | --- |\n| DeepSeek V4 (`deepseek-v4-pro`, `deepseek-v4-flash`) | 1,000,000 | 384,000 | yes | yes | DeepSeek beta only |\n| DeepSeek V4 Flash Vision experimental (`deepseek-v4-flash-vision-exp`) | 1,000,000 inherited from Flash | 384,000 inherited from Flash | yes, inherited | yes, inherited | not claimed; Chat Completions route only |\n| DeepSeek compatibility aliases (`deepseek-chat`, `deepseek-reasoner`) | 1,000,000 | 384,000 | yes | yes | DeepSeek beta only |\n| NVIDIA NIM V4 registry models | 1,000,000 | 384,000 | yes | yes | not documented in code |\n| Volcengine Ark V4 model IDs | 1,000,000 | 384,000 | yes | yes | not documented in code |\n| OpenRouter, Novita, Fireworks, SiliconFlow, SGLang, and vLLM V4 model IDs | 1,000,000 | 384,000 | yes | no | not documented in code |\n| Xiaomi MiMo `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, `mimo-v2.5` | 1,000,000 | 131,072 | yes | no | not documented in code |\n| OpenRouter Qwen 3.6 Flash / Plus | 1,000,000 | 65,536 | yes | no | not documented in code |\n| OpenRouter Qwen 3.6 35B / 27B | 262,144 | 262,140 | yes | no | not documented in code |\n| OpenRouter Qwen 3.6 Max Preview | 262,144 | 65,536 | yes | no | not documented in code |\n| OpenAI API `gpt-5.5` | 1,050,000 | 128,000 | yes | no | not documented in code |\n| OpenAI API `gpt-5.6`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` | 1,050,000 | 128,000 | yes | no | not documented in code |\n| Anthropic API `claude-opus-5`, `claude-opus-4-8`, `claude-sonnet-5`, `claude-sonnet-4-6`, `claude-fable-5` | 1,000,000 | 128,000 | yes | yes | not documented in code |\n| Google Gemini API `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-3.1-pro-preview`, `gemini-2.5-pro`, `gemini-2.5-flash` | 1,048,576 | 65,536 | model-dependent | no | not documented in code |\n| Meta Model API `muse-spark-1.2` | 1,000,000 | 32,000 | yes | no | not documented in code |\n| ChatGPT plan route (`openai-codex`) | conservatively budgeted; account listing does not state a window | no output cap sent in preview | model dependent | no | public `/v1/responses`; no context limit is inferred from account eligibility |\n| OpenModel default/custom model IDs | 200,000 fallback unless model metadata or config overrides it | 64,000 fallback | model-dependent | no | route uses Messages payload at `/v1/messages` |\n| Wanjie Ark `reasoner` / `r1` model IDs | 128,000 | unknown (no documented maximum) | yes | no | not documented in code |\n| Direct Arcee API `trinity-large-thinking` | 262,144 | 262,144 | yes | no | not documented in code |\n| Direct Arcee API `trinity-large-preview` | 262,144 | unknown (no documented maximum) | no in doctor capability metadata | no | not documented in code |\n| Direct Moonshot `kimi-k3` | 1,048,576 | 1,048,576 documented maximum; 131,072 provider default | yes | no | exact route uses `max_completion_tokens` and omits fixed sampling fields ([K3 quickstart](https://platform.kimi.ai/docs/guide/kimi-k3-quickstart)) |\n| Kimi Code membership `k3` | 262,144 safe baseline; 1,048,576 with an explicit entitled-plan override | 131,072 conservative default ceiling; membership maximum is not published | yes | no | exact `https://api.kimi.com/coding/v1` route |\n| Kimi Code membership `k3-256k` | 262,144 fixed | 131,072 conservative default ceiling; membership maximum is not published | yes | no | exact `https://api.kimi.com/coding/v1` route |\n| Direct Moonshot/Kimi K2.7/K2.6 (`kimi-k2.7-code`, `kimi-k2.7-code-highspeed`, `kimi-k2.6`) | 262,144 | 32,768 | yes | no | provider-reported bundled catalog |\n| Kimi Code membership `kimi-for-coding`, `kimi-for-coding-highspeed` | 262,144 | unknown — the membership catalog owns these limits and no client-side ceiling is claimed | yes | no | exact `https://api.kimi.com/coding/v1` route |\n| Direct Z.AI `GLM-5.3` (default) | 1,000,000 | 131,072 | yes | no | live on the GLM Coding Plan; limits inherited from `GLM-5.2` until Z.ai publishes distinct 5.3 numbers; no USD price |\n| Direct Z.AI `GLM-5.3-Flash` | 1,000,000 | 131,072 | yes | no | natively multimodal; $0.15/$0.50 list (2026-08-26); faster/explore sibling of `GLM-5.3` |\n| Direct Z.AI `GLM-5.2` | 1,000,000 | 131,072 | yes | no | not documented in code |\n| Direct Z.AI `GLM-5.1` | 202,752 | 131,072 | yes | no | not documented in code |\n| Direct Z.AI `GLM-5-Turbo` | 202,752 | 131,072 | yes | no | faster/explore sub-agent sibling |\n| Direct MiniMax `MiniMax-M3` | 1,000,000 | 524,288 | yes | no | not documented in code |\n| Direct MiniMax M2.x models | 204,800 | unknown until MiniMax output metadata is promoted | yes | no | not documented in code |\n| MiniMax Messages route (`MiniMax-M3`, `MiniMax-M2.7`) | model-specific values above | model-specific values above | yes | no | route uses `/anthropic/v1/messages` |\n| Generic `openai` and AtlasCloud | 128,000 | unknown (no documented maximum) | no in doctor capability metadata | no | not documented in code |\n| Ollama | 8,192 | unknown (no documented maximum) | no | no | not documented in code |\n| Hugging Face Inference Providers V4 model IDs | 131,072 | unknown (no documented maximum) | yes | no | not documented in code |\n| Other recognized DeepSeek model IDs | 128,000 unless the model name carries an explicit `Nk` hint | unknown (no documented maximum) | no unless V4/reasoner logic matches | DeepSeek/NIM only | DeepSeek beta only |\n\nMiniMax M3 uses input-length and service tiers. Codewhale omits\n`service_tier`, so requests use the standard tier and cost estimates select the\ncorrect standard rate from total input usage. Priority rates are listed to keep\nthe official tier structure visible. Prices are USD per million tokens.\n\n| Model / service tier | Input length | Input | Output | Cache read | Cache write |\n| --- | --- | ---: | ---: | ---: | ---: |\n| `MiniMax-M3` standard | up to 512,000 input tokens | $0.30 | $1.20 | $0.06 | not published |\n| `MiniMax-M3` standard | over 512,000 input tokens | $0.60 | $2.40 | $0.12 | not published |\n| `MiniMax-M3` priority | up to 512,000 input tokens | $0.45 | $1.80 | $0.09 | not published |\n| `MiniMax-M3` priority | over 512,000 input tokens | $0.90 | $3.60 | $0.18 | not published |\n| `MiniMax-M2.7` standard | all supported inputs | $0.30 | $1.20 | $0.06 | $0.375 |\n\nThese values come from the [MiniMax pay-as-you-go pricing\nguide](https://platform.minimax.io/docs/guides/pricing-paygo). M3 thinking is\nadaptive or disabled; the OpenAI-compatible API defaults to adaptive and the\nAnthropic-compatible API defaults to disabled. M2.7 thinking cannot be\ndisabled. Codewhale sends explicit controls when the user selects a reasoning\nmode.\n\nTool-call support is tracked separately by the static `ModelRegistry` and by\nthe endpoint's ability to accept OpenAI-compatible `tools` payloads. A custom\nOpenAI-compatible or local endpoint can still reject tool calls even if\nCodewhale can send the schema.\n\n### Hugging Face Inference Providers Notes\n\nThe shipped Hugging Face route targets the OpenAI-compatible Inference Providers\nrouter at `https://router.huggingface.co/v1`. Configure auth with\n`HUGGINGFACE_API_KEY` first, or `HF_TOKEN` as a fallback. Configure the endpoint\nwith `HUGGINGFACE_BASE_URL` first, or `HF_BASE_URL` as a fallback; configure the\nmodel with `HUGGINGFACE_MODEL` first, or `HF_MODEL` as a fallback.\n\nThis route does not imply Hub browsing, model-card metadata, dataset access,\nJobs, uploads, or export. Those remain explicit Model Lab work items so\nprovider auth and artifact movement stay separate.\n\n### When a Local Model Prints Tool JSON\n\nCodewhale only executes tools when the provider returns Chat Completions\n`tool_calls` or streamed `delta.tool_calls`. If a local model prints text such\nas `{\"name\":\"File\",\"arguments\":{\"action\":\"search_content\",...}}` in the\nassistant message, that is ordinary model output, not an executable tool\nrequest.\n\nFor OpenAI-compatible or local runtimes, check:\n\n- The endpoint accepts the `tools` array in `/v1/chat/completions` requests.\n- The selected model or chat template is configured for function/tool calls.\n- The server returns `tool_calls` in the response rather than plain JSON text.\n- The compatibility layer does not strip tools before forwarding the request.\n- If in doubt, test a small `File` `read` or `search_content` action against a\n known tool-calling model before debugging Codewhale's tool registry.\n\nChanging `provider`, `base_url`, or `model` can select a route that supports the\nOpenAI-compatible payload shape, but Codewhale cannot convert arbitrary JSON\ntext into a trusted tool call after the model has emitted it as prose.\n\nDeepSeek will retire `deepseek-chat` and `deepseek-reasoner` on 2026-07-24 at\n15:59 UTC. Codewhale migrates either name to `deepseek-v4-flash` before a\nrequest reaches DeepSeek's first-party OpenAI or Anthropic endpoint. If no\nreasoning tier was configured, `deepseek-chat` also migrates to `off` and\n`deepseek-reasoner` to `high`, preserving their former non-thinking / thinking\nintent; an explicit `reasoning_effort` remains authoritative. The mapping is\ndeliberately not global: Wanjie Ark, aggregators, self-hosted runtimes, and\ncustom endpoints continue to own their model ids.\n\n## Reasoning Effort\n\n`/reasoning ` (and the `reasoning_effort` config key) is translated to\neach provider's wire dialect by the client before the request is sent. `off`\ndisables thinking where the route supports it. Both exact K3 routes map `off`\nto their lowest supported tier, `low`, and the model is never switched to\nsatisfy `off` — but they do so for different reasons:\n\n- **Kimi Code membership K3** (exact `https://api.kimi.com/coding/v1` with\n `model = \"k3\"` or `model = \"k3-256k\"`) — the membership roster declares K3 always-thinking, so `off`\n cannot be honored without changing what the model is. The clamp preserves the\n fixed K3 identity.\n- **Direct Moonshot K3** (exact `https://api.moonshot.ai/v1` with\n `model = \"kimi-k3\"`) — this clamp is *defensive*, not a documented contract.\n The direct platform publishes no `off` state for K3, and Codewhale will not\n assert a fixed-thinking guarantee it cannot verify for a given key's\n entitlement, so the requested `off` is normalized to the lowest tier with the\n live entitlement left unknown.\n\nNormal dispatched\n`auto` uses Codewhale's auto-reasoning selector and sends a concrete tier;\nonly an omitted reasoning setting leaves the provider default in control.\nProviders marked \"omitted\" receive no reasoning fields at all for that tier.\n\n| Provider | `off` | `low`/`medium`/`high` | `max`/`xhigh` |\n| --- | --- | --- | --- |\n| `deepseek`, `deepseek-cn`, `siliconflow`, `siliconflow-CN`, `sglang`, `volcengine`, `atlascloud` | `thinking: {type: disabled}` | `reasoning_effort: \"high\"` + `thinking: {type: enabled}` | `reasoning_effort: \"max\"` + `thinking: {type: enabled}` |\n| `openrouter`, `novita`, other `together` models | `thinking: {type: disabled}` | `reasoning_effort` pass-through + `thinking: {type: enabled}` | `reasoning_effort: \"xhigh\"` + `thinking: {type: enabled}` |\n| `together` + `thinkingmachines/inkling` | `reasoning_effort: \"none\"` | exact `minimal`/`low`/`medium`/`high` `reasoning_effort` | `reasoning_effort: \"max\"` |\n| Direct Moonshot `kimi-k3` at exact `https://api.moonshot.ai/v1` | top-level `reasoning_effort: \"low\"` (effective normalization) | top-level `reasoning_effort: \"low\"` / `\"high\"` (`medium` becomes `high`) | top-level `reasoning_effort: \"max\"` |\n| Kimi Code membership `k3`, `k3-256k` at exact `https://api.kimi.com/coding/v1` | `thinking: {type: enabled, effort: \"low\"}` (effective normalization) | `thinking: {type: enabled, effort: \"low\" | \"high\"}` | `thinking: {type: enabled, effort: \"max\"}` |\n| Other `moonshot` routes | `thinking: {type: disabled}` | `thinking: {type: enabled}` | `thinking: {type: enabled}` |\n| `ollama` | `think: false` | `think: true` | `think: true` |\n| `ollama-cloud` | `reasoning_effort: \"none\"` | exact `low`/`medium`/`high` `reasoning_effort` | `reasoning_effort: \"max\"` |\n| `xiaomi-mimo` | `thinking: {type: disabled}` | `thinking: {type: enabled}` | `thinking: {type: enabled}` |\n| First-party `minimax` `MiniMax-M3` | `reasoning_split: true` + `thinking: {type: disabled}` | `reasoning_split: true` + `thinking: {type: adaptive}`; effective tier granularity unavailable | `reasoning_split: true` + `thinking: {type: adaptive}`; effective tier granularity unavailable |\n| First-party Z.ai `GLM-5.2` | `thinking: {type: disabled}`; no `reasoning_effort` | enabled thinking; only effective `high` adds `reasoning_effort: \"high\"` | enabled thinking + `reasoning_effort: \"max\"` |\n| First-party Z.ai `GLM-5.3` | `thinking: {type: disabled}`; no `reasoning_effort` | enabled thinking; only effective `high` adds `reasoning_effort: \"high\"` | enabled thinking + `reasoning_effort: \"max\"` |\n| First-party Z.ai `GLM-5.3-Flash` | `thinking: {type: disabled}`; no `reasoning_effort` | enabled thinking; only effective `high` adds `reasoning_effort: \"high\"` | enabled thinking + `reasoning_effort: \"max\"` |\n| First-party Z.ai `GLM-5-Turbo` | `thinking: {type: disabled}` | enabled thinking; effort granularity unavailable | enabled thinking; effort granularity unavailable |\n| Compatible gateways configured as `zai` | omitted; effective unavailable | omitted; effective unavailable | omitted; effective unavailable |\n| `nvidia-nim` | `chat_template_kwargs.thinking: false` | `chat_template_kwargs`: `thinking: true` + `reasoning_effort: \"high\"` | `chat_template_kwargs`: `thinking: true` + `reasoning_effort: \"max\"` |\n| `vllm` | `chat_template_kwargs.enable_thinking: false` | `chat_template_kwargs.enable_thinking: true` + `reasoning_effort` low/medium/high | `chat_template_kwargs.enable_thinking: true` + `reasoning_effort: \"high\"` (vLLM has no max tier) |\n| `arcee`, `huggingface` | omitted | `reasoning_effort` pass-through | `reasoning_effort: \"high\"` |\n| `fireworks` | omitted | `reasoning_effort: \"high\"` | `reasoning_effort: \"max\"` |\n| `openai`, `wanjie-ark`, `telecomjs` | omitted | omitted | omitted |\n| `openmodel` | Anthropic Messages adapter handles thinking/output configuration | Anthropic Messages adapter handles thinking/output configuration | Anthropic Messages adapter handles thinking/output configuration |\n| `openai-codex` | Responses API `reasoning` field (handled by the Responses bridge) | Responses API `reasoning` field | Responses API `reasoning` field |\n\nAtlasCloud serves DeepSeek models, so it speaks the DeepSeek reasoning dialect,\nincluding the `max` tier (#3024).\n\nOn the exact MiniMax OpenAI-compatible Chat endpoints, `MiniMax-M3` uses\n`max_completion_tokens`. Other MiniMax models and compatible gateways retain\n`max_tokens`; the MiniMax Anthropic endpoints use the separate Messages\nadapter.\n\n## Drift Check\n\nRun this before changing provider IDs, provider TOML tables, static model\nregistry rows, or provider default strings:\n\n```bash\npython3 scripts/check-provider-registry.py\n```\n\nThe check fails when:\n\n- `docs/PROVIDERS.md` omits a canonical `ProviderKind::as_str()` ID.\n- Descriptor presentation IDs or released wire tags are duplicated or drift\n from intrinsic kinds, or a second provider enum/ordinal bridge is introduced.\n- The shipped-provider table omits or adds a `[providers.*]` TOML table.\n- The static model registry table drifts from providers used by\n `crates/agent/src/lib.rs`.\n- A provider default model or base URL constant in `crates/tui/src/config.rs`\n is no longer mentioned here.\n\n## Provider Gotchas\n\nThree failures that look like something else (measured by a provider operator\non live traffic):\n\n- **A cold model looks broken.** The first call to a model the provider has not\n loaded can take 90 to 150 seconds, then drop to a second or less once warm.\n Raise `[stream].open_timeout_secs` (default 45 seconds, at most 300) before reading a timeout as \"model unavailable\".\n- **Listed is not callable.** A catalog entry can be visible and still fail\n every call. The same error text on every attempt (for example a \"resources\n busy\" 502) is not transient: a genuinely busy upstream returns successes in\n between. Probe a model before trusting it, and stop retrying one that fails\n identically.\n- **A truncated answer can report `stop`.** A reasoning model can spend the\n whole `max_tokens` allowance on reasoning. When the provider reports\n `completion_tokens` exactly equal to the `max_tokens` the request asked for\n and the finish reason is `stop`, Codewhale treats the turn as cut at the\n ceiling rather than finished (`stop_contradicts_output_ceiling` in\n `codewhale-models`); retrying the same prompt stops at the same count, so\n raise the ceiling or shorten the task instead. No inference is made when the\n provider reports no usage.\n\n## Planned, Not Shipped Yet\n\nThese items belong to the v0.8.48+ provider-abstraction milestone or related\nprovider docs work, but they are not native shipped behavior in this checkout:\n\n- A unified `Provider` trait in `codewhale-agent` that owns env precedence,\n secret resolution, base URL normalization, auth-header construction, and\n provider metadata. Those responsibilities are still split across\n `crates/config`, `crates/secrets`, and `crates/tui/src/client.rs`.\n- Hugging Face model passport metadata in the picker, including license, base\n model, context length, chat template, tool-call support, reasoning support,\n and gated/private status.\n\n## Sign in with Claude\n\nRun `codewhale auth claude` (or `/auth claude` in a running session), open the\nprinted Claude sign-in URL, and paste the complete `code#state` callback.\n`/provider setup anthropic` also offers API-key and Claude Pro / Max choices.\nThen select `anthropic` or its `claude` alias. API keys continue to use separate\nAnthropic API billing; selecting subscription sign-in never falls back to one.\n\nCodewhale stores the grant in its private credentials directory, commits the\nprovider's credential pointer atomically, and refreshes it before requests on a\nworker. Subscription tokens go only to `https://api.anthropic.com`; a custom\nendpoint is rejected. Each request keeps the existing Messages adapter, tool\nprotocol, cache prefix, and Codewhale client identity. No Claude Code credential\nfile or keychain entry is read or modified.\n\n`codewhale auth claude-revoke` (or `/auth claude-revoke`) removes the owned local\ngrant. End remote access separately in your Claude account settings. The\nsubscription route then needs another login; select API-key billing explicitly\nto use an API key. Login and inference follow Anthropic's provider-side\navailability; local checks do not\nestablish that an account is eligible or that live subscription inference works.\n\nThe callback and JSON token exchange follow the interoperable flow used by\n[pi's Anthropic OAuth adapter](https://github.com/fivewillow/badlogic-pi-mono/blob/main/packages/ai/src/utils/oauth/anthropic.ts).\n" }, { "path": "docs/MODEL_LAB.md", diff --git a/web/data/features.generated.json b/web/data/features.generated.json index 2da615ec71..1e9f2e7d68 100644 --- a/web/data/features.generated.json +++ b/web/data/features.generated.json @@ -2,7 +2,7 @@ "format": "codewhale.features.v1", "source": { "path": "docs/features.toml", - "sha256": "2e0161dd38e297e2c844c67313a13ecf154cda5562cbb50411fb462562baae2b" + "sha256": "253d29b798ab2194172144bb0254fd0572105ebcac4a772e3da07c063c20fc69" }, "features": [ { @@ -322,7 +322,7 @@ { "id": "providers", "name": "Providers", - "summary": "First-class provider routes plus bundled OpenAI-compatible host descriptors.", + "summary": "Provider routes with endpoint-scoped model facts, explicit limits and sourced price estimates.", "status": "stable", "surfaces": { "tui": "stable", From bc590156cb60a87d947ef38562ec86ba798992d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 01:41:28 +0000 Subject: [PATCH 02/26] fix(config): keep the bare-selector suffix match on aggregator default endpoints The #6396 handoff added a suffix match in RouteResolver::scope_selector so a bare saved selector (`deepseek-v4-pro`) resolves to the sole provider-owned namespaced wire id (`deepseek/deepseek-v4-pro`). It ran for every provider class, so it also rewrote selectors on custom endpoints (which classify as LocalOrCustom and must pass the caller's id through) and pre-empted the StrictDirect casefold match on Z.ai/DeepSeek. The match now applies only when the class is Aggregator. A custom base URL already classifies as LocalOrCustom, so custom endpoints keep bare pass-through, and StrictDirect keeps its casefold behavior. Evidence (non-root user, scripts/with-hermetic-test-home.sh, RUST_MIN_STACK=8 MiB): - cargo test -p codewhale-config --lib --locked: test result: ok. 801 passed; 0 failed; 1 ignored (was 797 passed; 2 failed at the handoff: openrouter_custom_endpoint_ preserves_qwen37_alias, resolver_direct_owned_row_match_survives_casing_mismatch) - cargo test -p codewhale-config --locked: configured_models 9 passed, doctests 1 passed - As root, private_directory::endpoint_admission_refuses_linked_component_... and xai_credentials::unix_store_rejects_symlinked_root_component_... fail; the same binary passes both as a non-root user. Environment, not code. Refs #6396 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YCbYZQhRvzf3jQZA1cNhcP --- crates/config/src/route/resolver.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/config/src/route/resolver.rs b/crates/config/src/route/resolver.rs index 57b2b109a6..15d0cadd20 100644 --- a/crates/config/src/route/resolver.rs +++ b/crates/config/src/route/resolver.rs @@ -639,7 +639,10 @@ impl RouteResolver { .canonical_model .as_ref() .map(|model| model.as_str().rsplit('/').next().unwrap_or_default()); - if !raw.contains('/') && (wire_tail == raw || canonical_tail == Some(raw)) { + if class == ProviderClass::Aggregator + && !raw.contains('/') + && (wire_tail == raw || canonical_tail == Some(raw)) + { match suffix_match { None => suffix_match = Some(offering), Some(first) if first.wire_model_id != offering.wire_model_id => { From 4138d70b0859fe6781aa13dd09881b9089c80986 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 01:41:40 +0000 Subject: [PATCH 03/26] docs(release): 0.10.2 notes pass, registry since values, grant persistence CHANGELOG 0.10.2 (root and the synced crates/tui copy stay identical): - Date 2026-10-10 -> 2026-10-13. - Added: `codewhale account computers files` (416e2e7f4); GET /v1/goals with orphan omission and status-bound cursors (d8fba1e32, 25046c001); approval history target/summary/standing_rule (826020fdd, 72a5eaf3b); automation revision preconditions and Idempotency-Key (774689ada, bd2a7b49c). - Changed: Full Access skips the Auto-Review floor (bdcc8cfc5); file_search default-active (#6934); Runtime session grants persist and resume only on an authenticated thread resume (e537df587). - Fixed: live /network allow|deny (#6928) and the authority fold that keeps a managed or Fleet denial from widening (0379673f8, 5a2fa012e); the #6553-#6560 audit fixes (4e35b3029). - Known limits: Computer Use preview qualification (87cdaea83); heuristic approval-history redaction. docs/features.toml `since`: the rows whose code first shipped in v0.10.1 (chatgpt-plan-sign-in, orcarouter-sign-in, plugin-oauth-providers, profile-constitution: each row's introducing commit is an ancestor of v0.10.1) say 0.10.1; account-computer-files, claude-plan-sign-in and plugin-shell-install say 0.10.2. merch-interest stays "unreleased": it is a community-website feature with no release surface. docs/RUNTIME_API.md and its zh_hans mirror said session grants live in memory and a restart forgets them. Since e537df587 they are stored in approval_grants.json and stay suspended after a restart until an authenticated resume; an unreadable store holds no grants. web/data/*.generated.json regenerated with web/scripts/derive-facts.mjs. Evidence: - scripts/release/check-versions.sh and --require-dated-release: "Feature release-note receipts OK: 8 linked issue reference(s) checked in v0.10.1..HEAD." / "Version state OK: workspace=0.10.2, npm=0.10.2, npm-binary=0.10.2, lockfile in sync." - scripts/sync-changelog.sh --check clean; 0.10.2 sections diff identical. - node web/scripts/check-facts.mjs: OK; git diff --check clean. Refs #6396 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YCbYZQhRvzf3jQZA1cNhcP --- CHANGELOG.md | 60 +++++++++++++++++++++++++++++- crates/tui/CHANGELOG.md | 60 +++++++++++++++++++++++++++++- docs/RUNTIME_API.md | 7 +++- docs/features.toml | 14 +++---- docs/zh_hans/RUNTIME_API.md | 6 ++- web/data/agent-docs.generated.json | 4 +- web/data/features.generated.json | 16 ++++---- 7 files changed, 144 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f09894e828..34d5d2cdba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.10.2] - 2026-10-10 +## [0.10.2] - 2026-10-13 - Model lists, resolution, picker controls and Runtime facts use the same endpoint-scoped catalog facts. Cost estimates preserve complete price tiers, @@ -227,6 +227,25 @@ launcher guidance. `CODEWHALE_TELEMETRY_SURFACE`, including `vscode-extension`. This changes the session label while preserving collection settings and opt-outs ([#6916](https://github.com/codewhale-hq/Codewhale/pull/6916)). +- `codewhale account computers files` saves a Computer's workspace file (up to + 16 MiB) to account storage, lists its versions, and restores (up to 4 MiB) + or downloads one. Restoring over an existing file needs its current + `--expected-revision`; a download verifies size and SHA-256 and never + overwrites a local file. `codewhale account computers delete` now asks for + `--discard-files`. This is experimental. +- Runtime API: `GET /v1/goals` lists goal records across threads, newest + update first, with `status` and cursor paging. Goals whose thread no longer + exists are left out, and a cursor issued under a different `status` is `400`. +- Runtime API: `GET /v1/approvals` rows add a redacted `target` (a URL's host + or a workspace path) and a one-line `summary`, plus `standing_rule` (`allow` + or `deny`) when a remembered rule or grant answered instead of a person. A + shell command keeps only its program name and plain arguments. Rows recorded + earlier omit these fields. +- Runtime API: automations carry a `revision`. `PATCH`, `run`, `pause` and + `resume` accept `expected_revision` and answer `409` `revision_conflict` on a + mismatch. Create and those four accept an `Idempotency-Key`; a retry replays + the first success instead of creating or running twice. `DELETE` honors + neither. Clients check `capabilities.automation_mutation_preconditions`. ### Changed @@ -291,6 +310,18 @@ launcher guidance. this package" now lists the skipped rows, each with its package and reason, and the Native composition refusal names the unresolved rows. Both name at most five rows and count the rest. +- Full Access runs the calls it grants. The built-in Auto-Review floor + (publish-like commands, destructive background or headless work, and the + Windows npm-launcher hold) applies only in Ask, Auto-Review and Never, and no + longer blocks a call under Full Access, including a detached agent's. +- `file_search` is one of the model's default tools from the first turn + instead of being loaded through `tool_search` + ([#6934](https://github.com/codewhale-hq/Codewhale/issues/6934)). +- Runtime session grants (`"remember": true`) are stored in the Runtime data + directory instead of memory. After a restart a thread's grants stay + suspended, and matching calls ask again, until an authenticated client + resumes the thread. A grant store that cannot be read holds no grants, so + every call asks. ### Fixed @@ -446,6 +477,26 @@ launcher guidance. a reused process ID, or a publication from another user, store or config is still refused ([#6915](https://github.com/codewhale-hq/Codewhale/issues/6915)). +- `/network allow ` and `/network deny ` take effect on the next + tool call without a restart + ([#6928](https://github.com/codewhale-hq/Codewhale/pull/6928)). Under a + managed `[network]` overlay or a Fleet `network_access = false`, editing your + own `[network]` table cannot widen the policy: a denial from either layer + stands, a deny-by-default authority keeps its own allow list, proxy and + fake-IP exceptions, a prompt-by-default authority adds your hosts to its own, + and audit logging stays on if either layer asks for it. +- A workflow's final receipt is no longer dropped when its completion inbox is + full, and each agent's queue of messages to its parent holds at most 64; a + send past that returns an error instead of growing without bound + ([#6560](https://github.com/codewhale-hq/Codewhale/issues/6560)). +- `cargo + build`, `scripts/dev-test.sh` and `scripts/dev-cargo.sh`, + also through a wrapper, count as heavy commands for resource admission + ([#6560](https://github.com/codewhale-hq/Codewhale/issues/6560)). +- Sub-agent state is written atomically, with the file and its directory + flushed before the rename + ([#6555](https://github.com/codewhale-hq/Codewhale/issues/6555)), and image + search reads its seed and result bodies through the size cap + ([#6554](https://github.com/codewhale-hq/Codewhale/issues/6554)). ### Known limits @@ -459,6 +510,13 @@ launcher guidance. be changed inside the `F5` inspector. The browser studio and the macOS window are not views in the cycle, and `Ctrl+]` cannot open the work bar while the pet main view is showing. +- Computer Use is a preview. Its observation diffs, fresh element targets and + named action batches were checked against a macOS fixture app only; + packaged installs, installed-model use and sharing the desktop with a person + working at the same time have not been qualified. +- The `GET /v1/approvals` `target` and `summary` redaction is heuristic: a + secret passed as a plain word argument is not recognized, so treat approval + history as sensitive. ### Maintenance diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 32ee7352e7..60a1388a18 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.10.2] - 2026-10-10 +## [0.10.2] - 2026-10-13 - Model lists, resolution, picker controls and Runtime facts use the same endpoint-scoped catalog facts. Cost estimates preserve complete price tiers, @@ -227,6 +227,25 @@ launcher guidance. `CODEWHALE_TELEMETRY_SURFACE`, including `vscode-extension`. This changes the session label while preserving collection settings and opt-outs ([#6916](https://github.com/codewhale-hq/Codewhale/pull/6916)). +- `codewhale account computers files` saves a Computer's workspace file (up to + 16 MiB) to account storage, lists its versions, and restores (up to 4 MiB) + or downloads one. Restoring over an existing file needs its current + `--expected-revision`; a download verifies size and SHA-256 and never + overwrites a local file. `codewhale account computers delete` now asks for + `--discard-files`. This is experimental. +- Runtime API: `GET /v1/goals` lists goal records across threads, newest + update first, with `status` and cursor paging. Goals whose thread no longer + exists are left out, and a cursor issued under a different `status` is `400`. +- Runtime API: `GET /v1/approvals` rows add a redacted `target` (a URL's host + or a workspace path) and a one-line `summary`, plus `standing_rule` (`allow` + or `deny`) when a remembered rule or grant answered instead of a person. A + shell command keeps only its program name and plain arguments. Rows recorded + earlier omit these fields. +- Runtime API: automations carry a `revision`. `PATCH`, `run`, `pause` and + `resume` accept `expected_revision` and answer `409` `revision_conflict` on a + mismatch. Create and those four accept an `Idempotency-Key`; a retry replays + the first success instead of creating or running twice. `DELETE` honors + neither. Clients check `capabilities.automation_mutation_preconditions`. ### Changed @@ -291,6 +310,18 @@ launcher guidance. this package" now lists the skipped rows, each with its package and reason, and the Native composition refusal names the unresolved rows. Both name at most five rows and count the rest. +- Full Access runs the calls it grants. The built-in Auto-Review floor + (publish-like commands, destructive background or headless work, and the + Windows npm-launcher hold) applies only in Ask, Auto-Review and Never, and no + longer blocks a call under Full Access, including a detached agent's. +- `file_search` is one of the model's default tools from the first turn + instead of being loaded through `tool_search` + ([#6934](https://github.com/codewhale-hq/Codewhale/issues/6934)). +- Runtime session grants (`"remember": true`) are stored in the Runtime data + directory instead of memory. After a restart a thread's grants stay + suspended, and matching calls ask again, until an authenticated client + resumes the thread. A grant store that cannot be read holds no grants, so + every call asks. ### Fixed @@ -446,6 +477,26 @@ launcher guidance. a reused process ID, or a publication from another user, store or config is still refused ([#6915](https://github.com/codewhale-hq/Codewhale/issues/6915)). +- `/network allow ` and `/network deny ` take effect on the next + tool call without a restart + ([#6928](https://github.com/codewhale-hq/Codewhale/pull/6928)). Under a + managed `[network]` overlay or a Fleet `network_access = false`, editing your + own `[network]` table cannot widen the policy: a denial from either layer + stands, a deny-by-default authority keeps its own allow list, proxy and + fake-IP exceptions, a prompt-by-default authority adds your hosts to its own, + and audit logging stays on if either layer asks for it. +- A workflow's final receipt is no longer dropped when its completion inbox is + full, and each agent's queue of messages to its parent holds at most 64; a + send past that returns an error instead of growing without bound + ([#6560](https://github.com/codewhale-hq/Codewhale/issues/6560)). +- `cargo + build`, `scripts/dev-test.sh` and `scripts/dev-cargo.sh`, + also through a wrapper, count as heavy commands for resource admission + ([#6560](https://github.com/codewhale-hq/Codewhale/issues/6560)). +- Sub-agent state is written atomically, with the file and its directory + flushed before the rename + ([#6555](https://github.com/codewhale-hq/Codewhale/issues/6555)), and image + search reads its seed and result bodies through the size cap + ([#6554](https://github.com/codewhale-hq/Codewhale/issues/6554)). ### Known limits @@ -459,6 +510,13 @@ launcher guidance. be changed inside the `F5` inspector. The browser studio and the macOS window are not views in the cycle, and `Ctrl+]` cannot open the work bar while the pet main view is showing. +- Computer Use is a preview. Its observation diffs, fresh element targets and + named action batches were checked against a macOS fixture app only; + packaged installs, installed-model use and sharing the desktop with a person + working at the same time have not been qualified. +- The `GET /v1/approvals` `target` and `summary` redaction is heuristic: a + secret passed as a plain word argument is not recognized, so treat approval + history as sensitive. ### Maintenance diff --git a/docs/RUNTIME_API.md b/docs/RUNTIME_API.md index 242d72fe4a..4f1b464df0 100644 --- a/docs/RUNTIME_API.md +++ b/docs/RUNTIME_API.md @@ -1403,8 +1403,11 @@ emits `approval.grant_added` with `{ "grant": { "grant_id", "tool_name", revokes one (emitting `approval.grant_revoked`); the next matching call prompts again. Archiving or deleting the thread ends all of its grants (archiving emits `approval.grant_revoked` for each; unarchiving does not -restore them). Grants live in memory for the Runtime process: a restart -forgets them, and a forced (non-bypassable) prompt is never answered by one. +restore them). Grants are stored in the Runtime data directory +(`approval_grants.json`). After a Runtime restart a thread's grants stay +suspended, and matching calls prompt again, until an authenticated client +resumes the thread. A grant store that cannot be read holds no grants, so every +call prompts. A forced (non-bypassable) prompt is never answered by a grant. **User input** - `POST /v1/user-input/{thread_id}/{input_id}` with body diff --git a/docs/features.toml b/docs/features.toml index ab24f62072..263e5fbb97 100644 --- a/docs/features.toml +++ b/docs/features.toml @@ -579,7 +579,7 @@ name = "Account computer files" summary = "Save computer files as account versions, restore with revision checks, and download verified bytes." status = "experimental" surfaces = { tui = "preview" } -since = "unreleased" +since = "0.10.2" owner = "crates/cli/src/cloud.rs" [[feature]] @@ -665,7 +665,7 @@ name = "Sign in with ChatGPT" summary = "Uses official OAuth and the selected account catalog for eligible ChatGPT plan requests in the local OSS app." status = "experimental" surfaces = { tui = "preview", api = "partial", web = "none" } -since = "unreleased" +since = "0.10.1" docs = "docs/PROVIDERS.md#sign-in-with-chatgpt" owner = "crates/tui/src/oauth.rs" @@ -675,7 +675,7 @@ name = "Sign in with Claude" summary = "Adds Claude subscription sign-in and native Messages routing. Live provider acceptance remains unverified." status = "experimental" surfaces = { tui = "preview", api = "partial", web = "none" } -since = "unreleased" +since = "0.10.2" docs = "docs/PROVIDERS.md#sign-in-with-claude" owner = "crates/tui/src/oauth.rs" @@ -685,7 +685,7 @@ name = "Reviewed plugin OAuth providers" summary = "Reviewed plugins sign in with /connect and out with /disconnect; the host owns PKCE, models and approvals." status = "experimental" surfaces = { tui = "preview", api = "partial", web = "none" } -since = "unreleased" +since = "0.10.1" docs = "docs/PLUGIN_PROVIDERS.md" owner = "crates/tui/src/plugins/providers.rs" @@ -695,7 +695,7 @@ name = "Sign in with OrcaRouter" summary = "OrcaRouter provider: sk-orca- API key or OAuth 2.0 + PKCE sign-in, plus a live chat model catalog." status = "experimental" surfaces = { tui = "preview", api = "none", web = "none" } -since = "unreleased" +since = "0.10.1" docs = "docs/PROVIDERS.md" owner = "crates/tui/src/oauth.rs" @@ -716,7 +716,7 @@ name = "Profile constitution" summary = "Account preferences are captured per turn and previewed by the Engine." status = "experimental" surfaces = { tui = "preview", api = "preview" } -since = "unreleased" +since = "0.10.1" docs = "docs/RUNTIME_API.md#profile-constitution" owner = "crates/tui/src/profile_constitution.rs" @@ -726,6 +726,6 @@ name = "Plugin shell installation" summary = "codewhale install uses the reviewed bundle installer with local, GitHub and version-pinned npm sources." status = "experimental" surfaces = { tui = "preview" } -since = "unreleased" +since = "0.10.2" docs = "docs/PLUGINS.md" owner = "crates/tui/src/plugins/mutation.rs" diff --git a/docs/zh_hans/RUNTIME_API.md b/docs/zh_hans/RUNTIME_API.md index 4c8dd06006..9e2b66b7bd 100644 --- a/docs/zh_hans/RUNTIME_API.md +++ b/docs/zh_hans/RUNTIME_API.md @@ -1206,8 +1206,10 @@ Runtime 会移除它。客户端回显它所得到的值,不得自行构造、 撤销其中一个(发出 `approval.grant_revoked`);下一次匹配的调用 会再次提示。归档或删除线程会结束它的所有授权 (归档会为每个授权发出 `approval.grant_revoked`;取消归档不会 -恢复它们)。授权在 Runtime 进程内是内存态:重启 -就会忘掉它们,而一个强制的(不可绕过的)提示永不会被授权回答。 +恢复它们)。授权保存在 Runtime 数据目录(`approval_grants.json`)。 +Runtime 重启后,线程的授权保持暂停,匹配的调用会再次提示,直到经过认证的客户端 +恢复该线程。无法读取的授权存储不含任何授权,因此每次调用都会提示。一个强制的 +(不可绕过的)提示永不会被授权回答。 **用户输入** - `POST /v1/user-input/{thread_id}/{input_id}`,请求体 diff --git a/web/data/agent-docs.generated.json b/web/data/agent-docs.generated.json index 95bbcf2165..de25b8434e 100644 --- a/web/data/agent-docs.generated.json +++ b/web/data/agent-docs.generated.json @@ -288,8 +288,8 @@ "sources": [ { "path": "docs/RUNTIME_API.md", - "sha256": "8099f51c887ec1d4a35122c0057f584b05bc9a665ae82f3b74a1992a7c849732", - "content": "# Codewhale Engine Runtime API & Integration Contract\n\n> 阅读简体中文版:[zh_hans/RUNTIME_API.md](zh_hans/RUNTIME_API.md)。\n\n`codewhale app-server` exposes the [Codewhale Engine](ARCHITECTURE.md) through\nits canonical local Runtime API and control plane.\nLocal SDKs, mobile/remote-control clients, and editor integrations talk to it\ninstead of screen-scraping terminal output. It serves the full HTTP/SSE runtime\nAPI (`/v1/*`), a JSON-RPC control transport over stdio, and the phone-friendly\nmobile page. `codewhale doctor --json` provides machine-readable health, and\n`codewhale serve --acp` speaks the Agent Client Protocol over stdio for editors\nsuch as Zed.\n\n`codewhale serve --http` / `serve --mobile` remain as **compatibility aliases**\nfor `codewhale app-server --http` / `--mobile`; both launch the identical\nserver. New integrations should target `app-server`.\n\n`codewhale exec` is the separate one-shot headless worker path (stream-json,\nfleet worker subprocess, CI primitive). It is not part of this API, but it\nshares the same runtime, provider/model resolution, permission profiles, and\nevent vocabulary.\n\nThis document is the stable integration contract for native workbench\napplications (and other local supervisors) that embed the Codewhale Engine.\n\n## Architecture\n\n```\nlocal supervisor / SDK / automation harness\n │\n ├─ codewhale app-server --http → HTTP/SSE runtime API (/v1/*) [canonical]\n ├─ codewhale app-server --mobile → runtime API + mobile control page\n ├─ codewhale app-server --stdio → JSON-RPC control transport over stdio\n ├─ codewhale app-server --socket → same JSON-RPC over a unix domain socket (desktop daemon)\n ├─ codewhale doctor --json → machine-readable health & capability\n ├─ codewhale serve --acp → ACP stdio agent for editors such as Zed\n ├─ codewhale serve --mcp → MCP stdio server\n ├─ codewhale serve --http/--mobile → legacy aliases for `app-server --http/--mobile`\n └─ codewhale exec [args] → one-shot headless worker (stream-json)\n```\n\nThe engine runs as a local-only process. All APIs bind to `localhost` by\ndefault. No hosted relay, no provider-token custody, no secret leakage.\n\nFor the read-only record of what a thread or turn did, see\n[`docs/RECEIPTS.md`](RECEIPTS.md): `codewhale receipts` on the CLI and the\n`/receipt` routes under **Threads** below.\n\n## Runtime API entrypoints\n\n| Entry | Transport | Use |\n|---|---|---|\n| `codewhale web [--port 7878]` | HTTP/SSE on `127.0.0.1:7878` + embedded client | First-class loopback-only browser client; opens the default browser |\n| `codewhale app-server --http` | HTTP/SSE on `127.0.0.1:7878` | Full `/v1/*` runtime API (canonical) |\n| `codewhale app-server --mobile` | HTTP/SSE on loopback + `/mobile` | Runtime API + local mobile control page |\n| `codewhale app-server --stdio` | JSON-RPC 2.0 over stdio | Local SDK / control probe (no listener) |\n| `codewhale app-server --socket [--socket-path P]` | JSON-RPC 2.0 over a `0600` Unix domain socket (macOS/Linux) or an owner-only named pipe (Windows) | Desktop daemon: multi-client, kernel peer identity checked, `daemon/attach` claim handshake. Windows native-app qualification remains open. |\n| `codewhale app-server` | HTTP on `127.0.0.1:8787` | Legacy in-process app-server (`/healthz`, `/thread`, `/app`, `/prompt`); background jobs are `GET /v1/jobs` on the Runtime API; `/prompt` and `/thread` messages execute real turns via the runtime bridge. There is no direct `/tool` route: tools run only inside Engine turns, under the Engine's tool catalog and approval posture. This legacy server does not surface approvals: its bridge forwards only text deltas and the turn's completion, and it has no decision route, so an approval-gated call waits unanswered. Drive approval-gated work through the Runtime API (`/v1/threads/*` events and `POST /v1/approvals/{approval_id}`) |\n| `codewhale serve --http` / `--mobile` | same server as `app-server --http`/`--mobile` | Compatibility aliases |\n\n`app-server --http` and `--mobile` launch the same mature runtime API server\nhistorically reached through `serve --http` — no routes or behavior changed, so\nevery endpoint documented below is identical across both entrypoints. The\nruntime API token is read from `--auth-token`, then `CODEWHALE_RUNTIME_TOKEN`,\nthen `DEEPSEEK_RUNTIME_TOKEN`; use `--insecure-no-auth` only with a loopback\nbind. The `serve` compatibility aliases keep their `--insecure` flag.\nThe legacy in-process `codewhale app-server` also requires an explicit\n`--auth-token` or `CODEWHALE_APP_SERVER_TOKEN` before binding a non-loopback\nhost; its generated one-time `cwapp_*` token is loopback-only.\n\nDevice tokens minted by the master through `POST /v1/auth/client-tokens`\nhave an immutable `intent`: `watch` (the default when omitted) or explicit\n`drive`. Labels do not grant authority. Watch permits ordinary GET/HEAD reads,\nComputer display and one-use display tickets; it cannot mutate Runtime state,\nupgrade a protected HTTP read into a write channel, acquire/release control,\nor forward display input. Drive retains the existing Runtime/control authority\nbut cannot mint, list or revoke device tokens. Display tickets retain the\nissuing principal's intent; input still requires its current, live control lease.\n\n`GET /v1/runtime/info` advertises `capabilities.client_token_intents: true`,\nand the mint receipt returns `device_id`, `intent` and `expires_at`. A relay\ngrant issuer must require this capability before minting and validate that\nreceipt against the requested device/intent before exposing a token. Older\nEngines lack enforcement and must refuse relay grants through this issuer;\nan intent-like label or a successful legacy mint is insufficient. This change\ndoes not add account/Computer ownership scopes to Engine-local device tokens.\n\n### Workspace file suggestions\n\n`GET /v1/workspace/files/search?query=runtime&limit=20` returns\n`{\"paths\":[\"src/runtime.rs\"]}` through the existing authenticated `/v1/*`\nrouter. It searches only the server's configured workspace, not a thread's\nworkspace or the process's current directory. No workspace/path override is\naccepted. The response contains workspace-relative file paths with `/`\nseparators, never file contents, absolute paths, or directories.\n\n- `query` is a literal partial filename/path, without an `@` prefix, at most\n 256 UTF-8 bytes. Missing, empty, or whitespace-only queries return an empty\n list without walking the filesystem. No match also returns an empty list.\n- `limit` defaults to 20; accepted values are 1–100. Invalid limits, oversized\n queries, and unknown query parameters return HTTP 400.\n- Matching reuses TUI fuzzy `@file` discovery/ranking: case-insensitive path\n prefix matches first, then substring matches, alphabetically within each\n group. This is not glob, subsequence, content, or semantic search, and does\n not apply the TUI's personal frecency boosts.\n- Discovery shares the composer's ignore policy, including `.ignore` and\n `.deepseekignore`, always-discoverable AI directories, and the bounded\n hidden/gitignored local-reference fallback. The special `.agents`, `.claude`,\n `.cursor`, and `.deepseek` walks intentionally bypass ignore rules, as in\n the TUI. Ignore files are not confidentiality boundaries.\n- Directory symlinks are not traversed. Files are canonicalized and filtered\n for containment in the workspace before applying the result limit; external\n and broken file symlinks are omitted. In-workspace file symlinks may appear\n by their relative names. Suggestions are a filesystem snapshot, not\n authorization to read a file later; consumers must revalidate when opening it.\n\nDiscovery runs off the async executor, with the shared default depth of 10,\nat most 20,000 candidates, and a cooperative two-second discovery budget.\nResults are best-effort, not an exhaustive listing; a slow filesystem operation\ncan finish after that budget. Each request scans anew; there is no new index or\ncache. This read-only endpoint does not alter sessions or the pinned model\nprompt/tool prefix.\n\n### Workspace files and session artifacts\n\nNative clients (the GPUI desktop's Files and Preview modules) browse and edit\nthe server's configured workspace through three authenticated routes. They\nread and write the workspace directly; there is no second file store, cache\nor index, and no path override: the workspace root is the only root.\n\n- `GET /v1/workspace/files?path=&limit=<1-2000>` lists one directory.\n `path` is workspace-relative with `/` separators; empty or `.` is the root.\n Each entry carries `name`, `path`, `kind` (`file`, `directory`, `symlink`,\n `other`), and for files `size` and `modified` (RFC 3339). Directories sort\n first, then names case-insensitively. `limit` defaults to 200; `truncated`\n reports a cut. `.git` is never listed or served, and symlinks are listed by\n name only: they are never followed, so `path=` returns 403.\n- `GET /v1/workspace/files/read?path=&offset=&limit=<1-4194304>`\n returns one byte window of a regular file with `size`, `revision` (the\n SHA-256 hex of the **whole** file, not of the window), `modified`,\n `offset`, `bytes`, `truncated`, `encoding` and `content`. Text windows are\n `utf-8`; a window with a NUL byte, invalid UTF-8, or a split multi-byte\n character is `base64`. `limit` defaults to 256 KiB. Files above 16 MiB are\n refused with 413; a directory is 400; a link is 403; a missing file is 404.\n- `PUT /v1/workspace/files` with `{\"path\", \"content\", \"encoding\"?,\n \"expected_revision\"?}` writes one file atomically through the same confined\n opener Fleet artifacts use. `encoding` is `utf-8` (default) or `base64`;\n bodies above 4 MiB are 413. Creating a new file requires **no**\n `expected_revision` (and creates missing parent directories inside the\n workspace); overwriting requires the `revision` from the read that the\n edit was based on, and a stale or missing one is 409 with the current\n revision in the error message so the client can re-read and merge. This is\n optimistic concurrency, not a lock: two writers racing between the check and\n the write can still interleave. The response carries `path`, `size`,\n `revision`, `created` and `written_at`; 201 for a new file, 200 otherwise.\n Writes through a link, into `.git`, or to a directory are refused.\n\nEvery path is validated before any filesystem access: absolute paths,\nbackslashes, `.` or `..` components are 400, and each directory on the way is\nopened without following links (`O_NOFOLLOW` per component on Unix, reparse\npoint checks on Windows). These routes use the runtime bearer token like every\nother `/v1/*` route; they do not consult the model's tool permission posture,\nbecause the caller is the authenticated operator, not the model.\n\nSession artifacts are the oversized tool outputs a session recorded as\n`ArtifactRecord`s (`crates/tui/src/artifacts.rs`), stored under\n`sessions//artifacts/`:\n\n- `GET /v1/sessions/{id}/artifacts` lists the records a saved session carries:\n `id`, `kind`, `tool_call_id`, `tool_name`, `created_at`, `byte_size`,\n `preview` and the session-relative `path`.\n- `GET /v1/sessions/{id}/artifacts/{artifact_id}?offset=&limit=` reads one\n artifact with the same window, `revision` and `encoding` contract as the\n workspace file read. A record whose stored path is absolute or leaves the\n session directory is 403; a record whose file is gone is 404.\n\nThese routes only serve what a SavedSession indexes (plus immutable image\nevidence). A runtime turn's spills are read through the turn route below. An\nunbound runtime thread's engine has no SavedSession index at all, so for its\nspills the turn record is the only way in.\n\n#### Turn artifacts\n\nA turn records what it produced as typed references on its items and on the\nturn itself. Nothing is scanned to build them. Each fact is recorded where the\nbytes were written:\n- file tools and `apply_patch` report `size`/`sha256` in `mutation.files[]`;\n- spills report `artifact_digest`;\n- tool media reports `sha256`.\n\nThe workspace-level half comes from the turn's own restore points: the\n`pre_turn` and `post_turn` receipts in `TurnRecord.workspace_snapshots` (see\n\"Workspace restore points\" below), diffed by tree id in the existing side\nrepo. No second store, snapshot or event is involved.\n\nA reference (`TurnArtifactRef`) carries:\n\n| Field | Meaning |\n| --- | --- |\n| `id` | Stable within the turn. A file's id is `file_` plus the first 32 hex digits of SHA-256(path). A spill's is `art_`. Media's is `art_image_`. |\n| `kind` | `file`, `tool_output` or `media`. |\n| `path` | For `file`: workspace-relative with `/` separators. For `tool_output` and `media`: session-relative (`artifacts/...`). |\n| `change` | For `file` only: `created`, `updated`, `deleted` or `renamed`. A rename also has `previous_path`. |\n| `size` | Byte size. Absent when the file was deleted. |\n| `revision` | SHA-256 hex of the whole content. This is the value `GET /v1/workspace/files/read` reports as `revision`, and file-revert's `expected_hash` is `sha256:` + `revision`. Absent when the file was deleted, or for a delta blob over 16 MiB. |\n| `content_type` | For `media`: the exact media type. |\n| `session_id` | For `tool_output` and `media`: the artifact session that owns the bytes. |\n| `item_id`, `tool_call_id`, `tool_name` | The tool call that wrote it. Absent for a change seen only in the workspace delta. |\n| `source` | `tool_mutation`, `tool_output_spill`, `tool_media`, or `workspace_changed_during_turn`. |\n| `restore_snapshot_id` | A restore point `POST /v1/threads/{id}/file-revert` accepts for this path on this thread: the `tree_id` of a receipt recorded on this turn's `workspace_snapshots`, so the thread owns it whether or not it is bound to a saved session. For a tool write it is the call's `tool` receipt; for a delta change it is the turn's `pre_turn` receipt. Absent when the turn recorded no such receipt. |\n| `recorded_at` | When the reference was recorded. |\n\nWhere references appear:\n- **Items.** `TurnItemRecord.artifacts` lists what one tool call produced. It\n is set when the call completes, succeeded or failed, so `item.completed` and\n `item.failed` carry it live.\n- **Legacy projection.** `artifact_refs` is derived from `artifacts`. It holds\n only the workspace-relative paths of files that still exist: never spills,\n media or deleted files.\n- **Turns.** `TurnRecord.artifacts` is the turn aggregate. It is computed by\n one merge and ordered most recent first.\n - Spill and media refs are always kept.\n - File refs compose in item order: created then deleted drops the file,\n created then updated stays `created`, and a rename folds its origin.\n - Once the workspace delta settles, it is authoritative for the net change,\n `size` and `revision` of every path the snapshots can see. It adds files\n no tool receipt named, such as shell and sub-agent writes. It drops an item\n path the snapshots track but that ended the turn unchanged.\n - The aggregate is capped at 1000 refs, and `workspace.truncated` /\n `workspace.omitted` report the cut.\n\n`TurnRecord.workspace` follows the delta's lifecycle. It is `null` while the\nturn runs. `turn.completed` carries it with one of these states:\n- `pending`: the turn recorded both a `pre_turn` and a `post_turn` receipt,\n and their diff is still running. When it finishes, the runtime publishes\n `turn.artifacts`\n (`{turn_id, workspace, artifacts}`). That event may arrive after the next\n turn's `turn.started`, so key it by `turn_id`.\n- `settled`: the delta is merged. `pre_turn_snapshot_id` and\n `post_turn_snapshot_id` are the tree ids of the pair.\n- `unavailable`: no delta will come. `reason` says why:\n - `snapshots_disabled`, `workspace_too_large`, `too_many_files`,\n `unsafe_location`, `snapshot_failed`: the snapshot gates. A turn with a\n `pre_turn` receipt but no `post_turn` one keeps `pre_turn_snapshot_id`.\n - `not_captured`: the turn recorded no restore point: a compaction or purge\n operation, or a turn whose engine ended before taking one.\n - `runtime_restarted`: the process stopped before the delta settled. This is\n reconciled at startup and never recomputed.\n - `delta_failed`: the diff itself failed.\n\n `artifacts` still holds what the tool receipts recorded. `turn.artifacts` is\n published for every settlement outcome, so a client waiting on `pending`\n always hears back.\n\nWhat the delta means, and what it cannot see:\n- **It is a workspace diff, not attribution.** A delta change is everything\n that changed in the workspace while the turn ran. That includes an editor,\n another thread, or a background job writing the same workspace at the same\n time. `source: workspace_changed_during_turn` says exactly that.\n- **Excluded paths are invisible to snapshots.** This covers the built-in\n excludes (for example `node_modules/`, `target/`, `dist/`, `build/`,\n `.next/`, and binary and media extensions) and the workspace's `.gitignore`. A file tool's write to\n such a path is still reported from its receipt. A shell command's write to\n one is not reported at all.\n- **Shell writes have no per-call record.** A shell command's writes are\n never attributed to its item, only to the turn, and only when snapshots are\n enabled.\n\nRoutes:\n\n- `GET /v1/threads/{id}/turns/{turn_id}/artifacts` returns\n `{thread_id, turn_id, workspace, artifacts}` from the runtime store's turn\n record. While the turn runs, `artifacts` is merged from its items on the fly\n and `workspace` is `null`. An unknown turn, or a turn of another thread, is\n 404.\n- `GET /v1/threads/{id}/turns/{turn_id}/artifacts/{artifact_id}?offset=&limit=&revision=`\n reads one reference.\n - It uses the workspace file read's window contract (`size`, `revision`,\n `offset`, `bytes`, `truncated`, `encoding`, `content`) and adds:\n - `artifact`: the reference;\n - `source`: `workspace`, `snapshot` or `session_artifact`;\n - `current`: whether the workspace still holds these bytes, or `null`\n when that is not a question for this reference.\n - `revision` selects an intermediate revision one of the turn's items\n recorded. The default is the reference's own revision.\n - A `file` is served from the workspace when it still holds the recorded\n revision (`current: true`). Otherwise it comes from the turn's post-turn\n snapshot (`current: false`).\n - A `tool_output` or `media` reference is read under the session artifact\n root the writer used. The same confinement, image-manifest and integrity\n checks apply as for the session route.\n- `GET /v1/threads/{id}/turns/{turn_id}/calls/{tool_call_id}/changes?limit=`\n returns what **one** tool call changed, read from the same two restore\n points the engine recorded around it (the call's `tool:` receipt and its\n `post-tool:` partner on this turn):\n\n ```json\n {\n \"thread_id\": \"thr_1a2b3c4d\", \"turn_id\": \"turn_…\", \"tool_call_id\": \"call_…\",\n \"tool_name\": \"exec_shell\",\n \"state\": \"captured\", \"reason\": null, \"truncated\": false,\n \"files\": [\n {\n \"path\": \"out/result.json\", \"change\": \"created\",\n \"added\": 12, \"removed\": 0, \"size\": 210, \"revision\": \"\",\n \"restore_snapshot_id\": \"
\",\n        \"diff\": \"@@ -0,0 +1,12 @@\\n+{}\\n\", \"diff_truncated\": false\n      }\n    ]\n  }\n  ```\n\n  - This is the per-call counterpart of the turn aggregate, and the only\n    surface on which a **shell command's own writes** are attributable to that\n    command: a command produces no `metadata.mutation`, so nothing else names\n    what it wrote. The files a file tool changed are here too, read from the\n    same span.\n  - `change` is `created`, `updated` or `deleted` (git's `A`/`M`/`D`, with a\n    type change read as `updated`; the diff runs with `--no-renames`, so a move\n    is a delete plus a create). `added`/`removed` are `null` for a binary path.\n  - `size` and `revision` are the span's **end**, never the work tree as it is\n    now. `revision` is bare SHA-256 hex; pass `sha256:` followed by that hex as\n    `file-revert`'s `expected_hash`, or `absent` for a path deleted by the call.\n    Both are `null` when the path was deleted here or is too large to read.\n  - `diff` is the patch between the two restore points, cut at 64 KiB on a char\n    boundary (`diff_truncated` says so). It is `null` when there is nothing to\n    render: a binary path, a change with no content delta, or no patch.\n  - `state: \"pending\"` means the recorded call is queued or in progress and\n    its snapshot pair is incomplete. `reason` is `null` and `files` is empty;\n    read again after settlement. Once both receipts exist, the state is\n    `captured`, including when the call changed nothing.\n  - `state: \"unavailable\"` means the settled span cannot be resolved, and `reason`\n    says why: `call_not_bounded` (the call took no receipt — the engine judged\n    it read-only, or the turn predates receipts), `post_snapshot_missing` (the\n    opening receipt exists and the closing one was lost), `pre_snapshot_missing`,\n    or `snapshots_pruned` (the receipts are still on the turn, but the side repo\n    no longer holds the trees they name — snapshots are pruned to the newest few\n    while turn records are durable, so an older turn's span is regularly\n    unrecoverable, and a workspace whose store was deleted reads the same way).\n    This is **not** the same answer as an empty `files` list, which means the\n    span changed nothing, and the receipt's own `changed_paths` remain readable\n    on the turn record either way.\n  - `limit` (default 200, max 1000) caps the list; `truncated` says it was cut,\n    and how many paths were left off is not counted.\n  - Reading the span runs `git diff` inside the side repo. Neither the work\n    tree nor the index is touched.\n\n| Status | When |\n| --- | --- |\n| 404 | Unknown thread or turn, a turn of another thread, or a `tool_call_id` this turn has no item or receipt for. |\n| 400 | `limit` outside `1..=1000`. |\n| 500 | A runtime item record could not be read or parsed, or an operational failure occurred reading the snapshot repository; this is not evidence that the call was unbounded or snapshots were pruned. |\n\nThe artifact routes answer:\n\n| Status | When |\n| --- | --- |\n| 404 | Unknown thread, turn or artifact id, or a `revision` this turn never recorded. |\n| 409 | A file's recorded revision is in neither the workspace nor the snapshot store (snapshots are pruned after 50 per workspace or 7 days), or a session artifact's bytes no longer hash to the recorded revision. The message names the current revision. |\n| 410 | The turn deleted the file (restore it with `file-revert` and `restore_snapshot_id`), or a session artifact's bytes were pruned. |\n| 413 | Content over 16 MiB. |\n| 403 | A symlink, or a reference that leaves its root. |\n\nFleet receipt artifacts keep their own route\n(`GET /v1/fleet/runs/{run_id}/receipts/{task_id}/evidence`).\n\n### Runtime and account identity\n\n`GET /v1/runtime/info` reports `codewhale_version` plus the full 40-character\n`codewhale_commit` embedded by the shared CLI/TUI build. A source archive that\ncannot provide an exact commit reports `unknown`, allowing compatibility\nclients to fail closed rather than accepting an ambiguous binary pair.\n\nThe same response advertises `capabilities.account_session: true` and\n`capabilities.turn_operation_idempotency: true`. A client must require the\nlatter before relying on `operation_key`; do not infer support from a 2xx turn\nresponse because an older tolerant reader may ignore an unknown request field.\n`capabilities.turn_operation_lookup: true` separately advertises the read-only\noperation lookup below; clients must require it before relying on GET-based\nrecovery of a lost turn response.\nThe response also includes a token-free account receipt:\n\n```json\n{\n  \"account\": {\n    \"schema_version\": 1,\n    \"state\": \"authenticated\",\n    \"api_base\": \"https://api.codewhale.net\",\n    \"account_id\": \"acct_...\",\n    \"session_id\": \"session_...\",\n    \"scopes\": [],\n    \"expires_at\": \"2026-08-01T20:00:00Z\"\n  }\n}\n```\n\nThe Runtime reads this receipt from the exact profile- and API-origin-scoped\nsecure record written by `codewhale account login`; it does not run a second\nlogin flow. States are `signed_out`, `authenticated`, `offline_cached`,\n`expired`, or `revoked`. Scopes are copied only from explicit stored session\ngrants and are never inferred from account identity. Access/refresh tokens,\nemail, provider profile, and provider credentials are never returned.\n`account_id` and `session_id` are included only for a request authorized with\nthe Runtime token (or an explicitly insecure loopback server); the public\nbootstrap response remains usable but reports `signed_out`. Signed-out local\nWork remains supported and never allocates cloud compute implicitly.\n\nThe `--stdio` control transport is newline-delimited JSON-RPC 2.0. Probe it\nwithout spending model tokens:\n\n```bash\nprintf '%s\\n' \\\n  '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"healthz\"}' \\\n  '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"capabilities\"}' \\\n  '{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"shutdown\"}' \\\n  | codewhale app-server --stdio\n```\n\n`capabilities` returns the advertised method families (`thread/*`, `app/*`,\n`prompt/*`) and the full method list; `thread/capabilities`,\n`app/capabilities`, and `prompt/capabilities` scope it per family. The method\nset is pinned by a drift test in `crates/app-server/src/lib.rs`, so SDK and\nlocal integration clients can rely on it not changing silently.\n\n### Daemon socket: `codewhale app-server --socket`\n\nThe desktop shell (DESKTOP-APP-BRIEF §2) attaches to a long-lived daemon over\na unix domain socket. The wire is the `--stdio` transport verbatim — the same\nnewline-delimited JSON-RPC 2.0 methods, dispatched by the same code — with one\nhandshake in front of it.\n\n> Note (2026-09-14): the Tauri desktop shell named above is retiring under the\n> 2026-09-14 product-client transition, and the DESKTOP-APP-BRIEF reference is\n> a dangling pointer (that brief does not exist in this repo). The GPUI client\n> in the private `codehwhale-gpui` repo is the successor daemon consumer over\n> this HTTP runtime API; the socket protocol described here is unchanged.\n\n**Endpoint.** `--socket-path` if given; else `$CODEWHALE_HOME/run/daemon.sock`\nwhen `CODEWHALE_HOME` is set (an explicit home is an isolation boundary); else\n`$XDG_RUNTIME_DIR/codewhale/daemon.sock`; else\n`~/Library/Application Support/codewhale/daemon.sock` on macOS or\n`~/.codewhale/run/daemon.sock` elsewhere. The directory is created `0700`, the\nsocket is `0600`, and every accepted peer must present the daemon's own uid.\nOn start, a socket file nobody answers on is removed; a live one makes the new\ndaemon exit with `a live listener already answers on ; refusing to\nreplace it`; a non-socket file at the path is never touched. On Windows `--socket` fails with a typed\n`UnsupportedPlatform` error naming the reserved pipe `\\\\.\\pipe\\codewhale-daemon`\n— there is no silent TCP fallback. The daemon prints\n`codewhale daemon: listening on ` to stderr once it is accepting.\n\n**Handshake.** The first request on a connection must be `daemon/attach`\n(`healthz` is also allowed beforehand, so a shell can probe liveness). Every\nother method is refused with `-32010 attach_required` until then.\n\n```json\n{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"daemon/attach\",\"params\":{\n  \"client\":{\"name\":\"codewhale-desktop\",\"version\":\"1.2.3\",\"pid\":4242},\n  \"mode\":\"claim\",\n  \"expect_daemon_version\":\"0.9.11\"}}\n```\n\n`mode` is `\"claim\"` (this client spawned the daemon and manages its lifetime)\nor `\"attach\"` (default: a guest that found a healthy daemon). A claim while\nanother connection owns the daemon fails with `-32011 daemon_already_claimed`\n(`data.owner` names the holder) and the client should retry with `attach`.\n`expect_daemon_version`, when present, must equal the daemon's crate version\nor the attach fails with `-32013 daemon_version_skew` (the bundle-skew guard).\nThe reply reports the granted `role` (`owner` / `attached`), the daemon's\n`pid`, `version`, `socket_path`, and `uptime_ms`, the current `owner`, and the\nlive `connections` count. A second `daemon/attach` on an attached connection\nis `-32014 already_attached`.\n\n**Capabilities.** On this transport `capabilities.methods` is the pinned stdio\nset plus `daemon/attach` (second entry, after `healthz`); `transport` reads\n`unix-socket`. `shutdown` is advertised to every connection because the method\nexists, but only the owner may call it (below).\n\n**Ownership.** Only the owner may `shutdown`; a guest's `shutdown` is refused\nwith `-32012 not_daemon_owner` and does not interrupt anyone's turn. When the\nowner disconnects the slot frees, so a relaunched shell re-claims the daemon it\nleft running. The owner's `shutdown` stops the listener, closes every\nconnection, and removes the socket file. Journal replay from a client's\nlast-seen `seq` is not part of this transport yet.\n\n### Interrupting a turn\n\n`thread/message` streams until the turn reaches a terminal state, which can\ntake minutes. The read loop keeps polling stdin while a turn streams, so a\nclient can send:\n\n```json\n{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"thread/interrupt\",\"params\":{\"thread_id\":\"thr_...\"}}\n```\n\nand the runtime is asked to interrupt that turn\n(`POST /v1/threads/{id}/turns/{turn_id}/interrupt`). The reply carries\n`interrupted: false` when no turn is streaming for that thread — this is not\nan error, just nothing to stop. The interrupted `thread/message` then fails\nwith a `turn interrupted` error, and its reply is written before the\ninterrupt's own reply, since the turn owns the writer until it unwinds.\n\n`shutdown` sent during a live turn also interrupts first: it needs the same\nbridge that the turn holds, so without that it would wait for the very turn\nit was meant to stop. Other requests that arrive mid-turn are queued and run\nin order once the turn finishes.\n\n### Running a prompt\n\n`prompt/request` and `prompt/run` (byte-identical aliases) and the legacy\nHTTP `POST /prompt` all execute a **real turn** on the runtime, through the\nsame bridge `thread/message` uses. There is no local fallback: nothing else\nin the app-server can produce model output, so a prompt either runs or fails.\n\n- `params.prompt` is required and must be non-empty (`-32602` otherwise).\n- `params.thread_id` is optional. With one, the prompt runs on that thread and\n  its history. Without one, the runtime gets a fresh thread for that single\n  turn; the mapping is dropped when the turn ends, so a one-shot prompt is not\n  addressable by `thread/interrupt`. Use `thread/message` when you need to be\n  able to interrupt.\n- `params.model` selects the model only when the call is the one that creates\n  the runtime thread; an existing thread keeps the model it was created with.\n- The response carries what the model actually said: `output` is the\n  concatenated `agent_message` text, `model` is the model the runtime reports\n  for the thread that ran it, and `events` are the real\n  `response_start`/`response_delta`/`response_end` frames. Over stdio the same\n  frames are also streamed to stdout while the turn runs, exactly as for\n  `thread/message`.\n- If the runtime cannot be reached, the call fails with `-32005`\n  (`runtime_unavailable`) on stdio, or HTTP `503` with\n  `{\"error\":{\"code\":\"runtime_unavailable\", ...}}` on `POST /prompt`. Failures\n  are never shaped like a successful `PromptResponse`.\n\n`POST /thread` with a `Message` body behaves the same way — it runs the turn\nand replies `status: \"completed\"` with the streamed frames in `events` — where\nit previously replied `accepted` without doing anything.\n\n### Thread ids and restarts\n\n`thread/message`, `thread/request` messages, and HTTP `POST /thread` messages\ntake a thread id from `thread/create` (or `thread/fork`). An id that was never\ncreated fails with `-32004` (`thread_not_found`) on stdio, or HTTP `404` on\n`/thread`, before any runtime thread is started. `/prompt`, `prompt/request`,\nand `prompt/run` are different: their optional `thread_id` is any key the\ncaller chooses, and a new key starts a new conversation.\n\nThe authenticated canonical owner keeps the full saved conversation graph,\nits selected branch and the thread/session binding. Compatibility controls use\nthat owner; the old SQLite history remains a protected, read-only import source.\nImport compares the complete source graph and selected leaf before publishing\nthe canonical alias. A failed alias publication retains the source and the\nactual canonical result so recovery can report what completed.\n\nExisting validated legacy goals are imported into the owner goal store. Active\ngoals are paused during import; historical data never starts a provider call.\nSource goal fields participate in the same protected source comparison.\n\nSaved-session forks preserve the complete journal, including inactive branches,\nand copy a validated local session-goal sidecar into the new saved session.\nAn active local goal is copied as paused; the source remains unchanged.\nThat sidecar is separate from the public Runtime thread goal. A native Runtime\nthread fork does not automatically inherit the public thread goal.\n\n`thread/create`, `thread/start`, `thread/resume` and `thread/fork` carry a\nclient-generated `operation_key`. Capture one key for each user intent before\nsending it, retain it after an uncertain response, and reuse it when recovering\nthat same intent. Create carries the key in `metadata.operation_key`; Start,\nResume and Fork carry it in `operation_key`. Two intentional forks use different\nkeys. Recovery checks\nthe original operation in the existing owner store; it never creates another\nthread merely because the response was lost or the source later grew.\n\n`POST /v1/thread-history/operations/lookup` is read-only. Its closed request\ncontains `version: 1`, `operation_key`, `expected_data_dir`,\n`expected_execution_scope` and `workspace`; the response is `absent`, `pending`\nor `committed`. A pending or committed response includes the retained receipt\nand its exact action/source `association`.\n\n`POST /v1/thread-history/operations/recover` accepts that lookup request in\n`operation` and the expected `association`. It explicitly finishes an already\nprepared target under the same owner, after checking the saved document, full\ngraph, workspace, checkpoint and action/source identity. It does not rebuild\nthe original intent from a source that may already have changed. An unprepared\ntarget stays pending; a changed or unverifiable target refuses completion.\nRepeat recovery with the same key to observe the same committed result.\n\nThe selected workspace comes from the acknowledged owner or the explicitly\nadmitted request. History and historical receipts cannot supply permissions,\ncredentials, endpoints or a different owner. A missing, changed, busy or\nincompatible bound store is an explicit failure. A missing canonical target\ndoes not start an empty replacement conversation.\n\n`codewhale thread resume` and `codewhale thread fork` perform the durable\nowner control and print its committed thread, session and operation receipt.\nThey do not launch an interactive frontend.\n\nGlobal `--workspace` (also `--cd`), `--profile` and `--config` select an explicit\ncontrol scope. Relative paths are captured before attachment; the client\nauthenticates the owner, then admits that scope against the same owner receipt\nand its captured worker setting. An incompatible profile or config, missing\nscope facts, or a changed owner fails explicitly. Without these options, the\nacknowledged owner supplies the workspace. Thread listing remains store-wide.\n\nFor a fresh `thread resume` or `thread fork`, global `--provider`, `--model`,\n`--approval-policy` and `--sandbox-mode` supply proposals to the existing owner\ndecoder and permission checks. The corresponding `--set` keys are `provider`,\n`model`, `default_text_model`, `approval_policy` and `sandbox_mode`. Credentials\nand endpoints stay with the owner: `--api-key`, `--base-url` and other per-run\nsettings are refused for these controls. Configure and authenticate the owning\nRuntime before using them. With a retained `--operation-key`, newly supplied\nmodel, provider, policy or sandbox proposals are refused; recovery observes\nthe original admitted intent.\n\nInteractive `codewhale resume` and `codewhale fork` use the same canonical\nhistory operation. An inactive local owner is shut down and joined before the\nexisting TUI acquires the saved-session lease and store. A live owner or an\nuncertain handoff refuses attachment. `--operation-key ` recovers the\noriginal outcome, including completing a verified prepared target; an\nunprepared or unverifiable result preserves uncertainty.\n\n### Changing config\n\n`app/config/set` and `app/config/unset` write the change to the config file\nbefore replying: the `--config` path, or the default `config.toml` used by the\ncanonical owner when no `--config` is given. They change user settings that\noutlive the app-server, not just this session. The change is applied to the\nfile as it is on disk, so edits saved by other processes are kept. If the file\ncannot be read, parsed, or written, the reply is `ok: false` and nothing\nchanges; a file that does not parse is not rewritten, so fix it by hand (or\n`app/config/reload` after fixing it). Over HTTP `/app`, a rejected key or value\nis `400` and a read or write failure is `500`.\n\n### Answering a clarification question\n\nWhen a headless turn calls `request_user_input`, the runtime emits a\n`user_input.required` event carrying a `request_id`. Reply on the runtime API:\n\n```\nPOST /v1/user-input/{thread_id}/{request_id}\n```\n\nThe app-server control transport cannot accept that reply.\n`app/request` with `SubmitUserInput` returns `ok: false` and\n`error: \"user_input_reply_unsupported\"`. This is a property of the transport,\nnot an omission: while a turn is streaming, the stdio loop executes only\n`thread/interrupt` and queues everything else, so an answer sent there would\nwait on the very turn that is waiting for it.\n\n## SDK contract\n\nThe app-server exists so an external SDK can answer — without scraping TUI\noutput — *what route ran, which provider/model/reasoning/permission profile was\neffective, what events happened, how many tokens were used, and how the run\nfinished.* The durable Thread/Turn/Item data model already carries most of\nthis; the table maps each integration need to where a local client reads it.\n\n| Integration need | Where it comes from | Status |\n|---|---|---|\n| Route / effective model / billing surface | `TurnRecord` + thread `model`; per-run `--provider`/`--model` overrides | available |\n| Permission / sandbox / approval profile | thread `auto_approve`, sandbox + approval policy; `TurnRecord.permission_posture` + `TurnRecord.mode` for how *that* run was governed (the thread's own `mode` may have been switched since) | available |\n| Run / thread / turn IDs | `thread_id`, `turn_id`, SSE event envelope | available |\n| Event stream | `GET /v1/threads/{id}/events` (replay + live SSE) | available |\n| Turn status / terminal classification | `TurnRecord.status` + error summary | available |\n| Token usage | `TurnRecord.usage`; aggregate via `GET /v1/usage` | available |\n| Action receipt (files, commands, web/MCP calls, agents, approvals and who decided, failures) | `GET /v1/threads/{id}/receipt`, `GET /v1/threads/{id}/turns/{turn_id}/receipt` | available ([RECEIPTS.md](RECEIPTS.md)) |\n\nFor one-shot/headless automation, prefer `codewhale exec` with explicit\n`--provider  --model ` so a failure identifies the exact provider/model\npair. Use `app-server` when a local integration needs to start, resume, steer,\nor interrupt turns, list models/capabilities, follow the event stream, or read\nusage. Both paths share the same runtime, so route-effective model resolution\nand the event vocabulary match.\n\n### Release smoke\n\n`scripts/release/app-server-smoke.sh` is the committed pre-release check:\n\n```bash\nscripts/release/app-server-smoke.sh                 # stdio health/capabilities probe (no tokens)\nscripts/release/app-server-smoke.sh --matrix        # + print the configured provider/model matrix\nscripts/release/app-server-smoke.sh --matrix --real # + exec a cheap sentinel per provider\n```\n\nThe stdio probe runs against a throwaway config, so it never reads real keys.\nThe matrix discovers configured providers from `codewhale auth list`, skips\nunconfigured providers, and maps a provider to a cheap sentinel model only when\nit has a built-in cheap default. That built-in set is deliberately conservative\n(currently `deepseek`, `zai`, `moonshot`, and `openai`); every other provider —\nincluding `arcee`, `openrouter`, `xiaomi-mimo`, and `openai-codex` — is left\nunmapped on purpose and must be given a model per run via `SMOKE_MODEL_`\nrather than a guessed default (#3205). Any configured-but-unmapped provider\nfails loudly in `--real` mode. `auth list` reports presence flags only and exec\noutput is passed through a redactor, so secrets are never printed. The parser is\ncovered by `scripts/release/app-server-smoke.test.sh` against a fake `codewhale`\nbinary.\n\n## ACP stdio adapter: `codewhale serve --acp`\n\nACP JSON-RPC over newline-delimited stdio is a transport over the existing\nRuntimeThreadManager and Engine. It has no provider/tool round loop, executable\nregistry, prompt composer, or conversation writer of its own. The server loads\nthe selected config/profile and plugin discovery once; each prompt uses a\ncanonical thread, Core turn, event timeline, approval waiter, and full Engine\nsession snapshot.\n\nThe editor surface supports `initialize`, `session/new`, `session/list`,\n`session/load` (including durable ID prefixes), `session/prompt`, `session/cancel`,\nmodel discovery/selection, and the advertised mode/model config options.\n`session/new` creates a durable bare UUID and empty checkpoint without calling a\nprovider. At most 64 idle transport bindings are retained; eviction keeps the\ndurable conversation. Resume deduplicates against the existing thread binding.\nFull histories, tool pairs, signatures, media and partial-effect receipts are\nsaved under the same checkpoint guard and session write lease used by HTTP.\nACP display text may be shortened; the saved history is the full Core snapshot.\n\nThe trusted local ACP profile narrows Core to file/search/git/patch and admitted\nforeground shell tools. Shell requires both editor terminal support and operator\n`allow_shell`; a requested external sandbox that is unavailable removes shell.\nMCP, dynamic tools, task/PTY/background shell, interpreter, subagent and RLM\nlifecycles are unavailable on this transport. Built-in overrides remove the\nwhole compatibility alias family. Final dispatch rechecks the profile, so a\nfabricated alias, hook rewrite or omitted catalog entry cannot bypass it. Plan\nremains read-only under Full Access. Full Access and the ordinary approval\nposture are server-owned read-only options; the editor cannot widen them.\n\nTool updates begin `pending`. `in_progress` means Core reached final dispatch;\n`completed`/`failed` and typed image blocks come from the actual Core result.\n`session/request_permission` correlates a private JSON-RPC ID to the same\nRuntime-minted pending approval and Core execution ID. Only an exact live\n`allow-once` response can release that waiter; wrong IDs are ignored, invalid\noptions deny, and cancellation withdraws it. ACP grants neither remembered\npermissions nor Native capabilities. Core typed rules, strict hooks, repo law,\nHeadless Auto-Review and hard floors remain in force, including the absence of a\nworkspace-write carve-out; a guardian-only decision refuses on this host.\n\nReplay uses the existing bounded event reader and sequence deduplication. A\nreplay gap, closed owner or terminal store fault is an explicit error, never a\nre-execution. Input is serviced between replayed events; each transport write has a 30-second\ndeadline. Cancel, EOF and writer\nfailure interrupt only this transport's claimed Core turn; settlement uses its\nactual terminal receipt and preserves any completed effects. An unconfirmed\ncancellation does not fabricate success. `stopReason` is `end_turn`, `cancelled`,\nor typed `max_turn_requests`; Core failure remains an error. Prompts use at most\n50 model steps (a lower configured limit still wins) and Core's bounded final\nreport response; this profile does not dispatch autonomous goal continuations.\n\nACP presently claims its own exclusive canonical Runtime owner. If another\nprocess holds that store, startup refuses. A saved conversation bound to another\nRuntime store also refuses; authenticated cross-process owner attachment remains\nunqualified and ACP does not copy a live conversation into a random store. This\ndoes not expose every `/v1/*` steering, job or control method to an ACP editor.\nUse `codewhale app-server --http` for that full runtime API.\n\n\n## Capability endpoint: `codewhale doctor --json`\n\nReturns a JSON object describing the current installation's readiness state.\nSuitable for health-check polling from a macOS workbench. This command is\nstrictly structural and offline: it does not load workspace credential\n`.env` files, inspect credential environment values, open secret/OAuth files,\nprobe an OS keyring, contact providers, or start MCP processes.\n\n```bash\ncodewhale doctor --json\n```\n\n### Response schema (key fields)\n\n| Field | Type | Description |\n|---|---|---|\n| `version` | string | Installed version (e.g. `\"0.8.9\"`) |\n| `config_path` | string | Resolved config file path |\n| `config_present` | bool | Whether the config file exists |\n| `paths` | object | Canonical config, settings, state, sessions, logs, automations, and secrets paths |\n| `secret_backend` | object | Metadata-only file-store shape, or literal `unknown` / `not_probed` for system and unsupported backends |\n| `workspace` | string | Default workspace directory |\n| `legacy_state.primary_root` | string | Primary Codewhale state root inspected for known state paths |\n| `legacy_state.legacy_root` | string | Legacy `.deepseek` state root inspected for known state paths |\n| `legacy_state.needs_attention` | bool | Whether known `~/.deepseek` state paths need review or the read-only session recovery diagnostic found missing destination filenames / could not complete |\n| `legacy_state.legacy_only_count` | number | Count of known state paths present only under the legacy root |\n| `legacy_state.dual_present_count` | number | Count of known state paths present under both primary and legacy roots |\n| `legacy_state.entries` | array | Per-path migration status: `{name, primary_present, legacy_present, status}` |\n| `legacy_state.session_recovery.status` | string | `isolated`, `no_legacy_sessions`, `migration_pending`, `migration_incomplete`, `migration_complete`, or `scan_failed` |\n| `legacy_state.session_recovery.read_only` | bool | Always true; doctor never invokes session migration or modifies either session directory |\n| `legacy_state.session_recovery.chat_contents_read` | bool | Always false; comparison is based only on top-level `.json` filenames and filesystem metadata |\n| `legacy_state.session_recovery.checkpoint_internals_scanned` | bool | Always false; `sessions/checkpoints/` and all other directories are skipped |\n| `legacy_state.session_recovery.recoverable_files` | array | Bounded sample of up to 100 missing destination filenames with source and destination paths; no chat payloads |\n| `legacy_state.session_recovery.recoverable_file_count` | number | Total missing destination filename count, including entries beyond the bounded sample |\n| `legacy_state.session_recovery.recoverable_files_truncated` | bool | Whether more than 100 recoverable filenames were found |\n| `legacy_state.session_recovery.recovery_command` | string or null | `codewhale sessions` when additive automatic recovery is available; null for isolated, complete, empty, or failed scans |\n| `api_key.source` | string | Structural source state: `config_declared`, `env_declared`, `external_auth_declared`, `secret_store_unprobed`, `secret_store_unavailable`, `oauth_unprobed`, `external_consent`, `none`, `local_runtime`, or `unknown`; declarations are not availability proof |\n| `api_key.availability` | string | Literal `present`, `not_required`, `not_probed`, `unavailable`, or `unknown`; only `present` and `not_required` certify structural Setup/Fleet credential readiness |\n| `base_url` | string | Provider URL authority only (`scheme://host[:explicit-port]`); userinfo, path, query, and fragment are omitted |\n| `default_text_model` | string | Default model |\n| `memory.enabled` | bool | Whether the memory feature is on |\n| `memory.path` | string | Path to memory file |\n| `memory.file_present` | bool | Whether memory file exists |\n| `mcp.config_path` | string | MCP config file path |\n| `mcp.present` | bool | Whether MCP config exists |\n| `mcp.probe_scope` | string | `configuration`; doctor does not start MCP servers |\n| `mcp.live_health_checked` | bool | Always false for doctor JSON |\n| `mcp.servers` | array | Per-server structural result and counts plus separate `checks`; URL userinfo/path/query/fragment and command argv, environment, header, and token values are never emitted, and all live stages are `not_checked` |\n| `skills.selected` | string | Resolved skills directory |\n| `skills.global.path` / `.present` / `.count` | — | Codewhale global skills dir (`~/.codewhale/skills`, with legacy `~/.deepseek/skills` support) |\n| `skills.agents.path` / `.present` / `.count` | — | Workspace `.agents/skills/` dir |\n| `skills.agents_global.path` / `.present` / `.count` | — | agentskills.io global skills dir (`~/.agents/skills`) |\n| `skills.local.path` / `.present` / `.count` | — | `skills/` dir |\n| `skills.opencode.path` / `.present` / `.count` | — | `.opencode/skills/` dir |\n| `skills.claude.path` / `.present` / `.count` | — | `.claude/skills/` dir |\n| `tools.path` / `.present` / `.count` | — | Global tools directory |\n| `plugins.path` / `.present` / `.count` | — | Global plugins directory |\n| `sandbox.available` | bool | Whether sandbox is supported on this OS |\n| `sandbox.kind` | string or null | Sandbox kind (e.g. `\"macos_seatbelt\"`) |\n| `storage.spillover.path` / `.present` / `.count` | — | Tool output spillover dir |\n| `storage.stash.path` / `.present` / `.count` | — | Composer stash |\n\n### Example\n\n```json\n{\n  \"version\": \"0.8.9\",\n  \"config_path\": \"/Users/you/.codewhale/config.toml\",\n  \"config_present\": true,\n  \"workspace\": \"/Users/you/projects/codewhale-tui\",\n  \"api_key\": {\n    \"source\": \"secret_store_unprobed\",\n    \"availability\": \"not_probed\"\n  },\n  \"base_url\": \"https://api.deepseek.com\",\n  \"default_text_model\": \"deepseek-v4-pro\",\n  \"memory\": {\n    \"enabled\": false,\n    \"path\": \"/Users/you/.codewhale/memory.md\",\n    \"file_present\": true\n  },\n  \"mcp\": {\n    \"config_path\": \"/Users/you/.codewhale/mcp.json\",\n    \"present\": true,\n    \"servers\": [\n      {\"name\": \"filesystem\", \"enabled\": true, \"transport\": \"stdio\", \"args_count\": 2, \"env_count\": 0, \"status\": \"ok\"}\n    ]\n  },\n  \"sandbox\": {\n    \"available\": true,\n    \"kind\": \"macos_seatbelt\"\n  }\n}\n```\n\n## HTTP/SSE runtime API: `codewhale app-server --http`\n\n```bash\ncodewhale app-server --http [--host 127.0.0.1] [--port 7878] [--workers 2] [--auth-token TOKEN] [--insecure-no-auth]\ncodewhale app-server --mobile [--host 127.0.0.1] [--port 7878] [--auth-token TOKEN]\ncodewhale app-server --mobile --host ::1 [--port 7878] [--insecure-no-auth]\ncodewhale web [--port 7878]\n\n# Compatibility aliases — identical server, serve flag names:\ncodewhale serve --http   [...] [--insecure]\ncodewhale serve --mobile [...] [--insecure]\n```\n\nDefaults: host `127.0.0.1`, port `7878`, 2 workers (clamped 1–8).\n\nThe server binds to `localhost` by default. Configuration is via CLI flags —\nthere is no `[app_server]` config section.\n\n`/v1/*` routes require a bearer token unless `codewhale app-server` is started\nwith `--insecure-no-auth` on a loopback bind such as `127.0.0.1`. Mobile mode\nis loopback-only: non-loopback hosts are rejected until Runtime has a TLS or\nverified-overlay transport boundary. The `codewhale serve` compatibility aliases\nuse `--insecure` for the same loopback escape hatch.\nPass `--auth-token TOKEN` or set `CODEWHALE_RUNTIME_TOKEN=TOKEN` before starting\nthe server; `DEEPSEEK_RUNTIME_TOKEN` remains a compatibility alias. If neither\nis set, the process generates a Runtime token for that process and does **not**\nprint it. `/health`, `/v1/runtime/info`, and an enabled static client shell\nremain public; Runtime mutations and thread data stay behind `/v1/*`\nauthentication. `/mobile` returns 404 when mobile mode is disabled and serves\nthe unchanged static shell when it is enabled.\n\nAuthenticated clients can provide the token as `Authorization: Bearer TOKEN`,\n`X-Codewhale-Runtime-Token: TOKEN`, the legacy\n`X-DeepSeek-Runtime-Token: TOKEN`. Query-string and raw Runtime-token cookie\nauthentication are not supported.\n\n### Local browser client\n\n`codewhale web` starts the canonical Runtime API on `127.0.0.1`, serves\ndependency-free assets embedded in the binary, prints a single-use launch URL,\nand asks the operating system to open that URL in the default browser. If the\nbrowser does not open, the printed URL remains usable for ten minutes. The\ncommand cannot bind to a non-loopback host and cannot run with Runtime auth\ndisabled.\n\nThe browser-launch URL contains a random, short-lived, one-time bootstrap\ncapability, never the Runtime token. A loopback request exchanges that\ncapability for a\n`codewhale_web_session=…; HttpOnly; SameSite=Strict; Path=/` cookie backed by a\nsingle process-local server session that expires 12 hours after the server\nprocess starts, consumes the capability immediately, and redirects to `/`.\nReused, expired, malformed, or\nnon-loopback bootstrap attempts fail closed. The Runtime bearer token is not\nplaced in rendered HTML, browser storage, logs, URL queries/fragments, or\nbrowser-launch arguments. The one-time bootstrap capability is printed in the\nlocal terminal and transits the OS browser launcher's argument list. A same-user\nprocess could race the browser to the exchange, which is why the capability is\nsingle-use, loopback-only, and expires after ten minutes — and why a same-user\nattacker has strictly easier local avenues than this race.\nWeb fetches require the session cookie plus an origin-scoped request proof;\nstreams use a fresh single-use ticket. The initial redirect carries the proof\nin a fragment, which the client removes and saves in origin-scoped\n`sessionStorage`. On reload or in a second tab, an authenticated `GET /` also\nembeds the proof in a meta tag when `Sec-Fetch-Site` is `same-origin` or `none`\n(direct navigation). The page uses `no-store`, disallows framing, and grants no\ncross-origin read access. This lets a new tab recover without reusing the\nbootstrap URL, including when storage is unavailable. Clients without Fetch\nMetadata can only reuse the fragment or their existing stored proof; recovery\ndoes not extend the server session or replace an expired cookie.\nCross-origin Fetch Metadata or a mismatched Origin is rejected on web API\nrequests. Explicit bearer and Runtime-token header clients keep their existing\nbehavior. Transient stream-ticket failures retry with capped backoff; HTTP\n401/403 stops ticket retries until a fresh session is opened.\n\nThe embedded client provides a responsive thread/search rail, Runtime-owned\nsession facts, transcript and tool receipts, and a bottom composer. It can\ncreate, select, rename, and archive threads; choose a provider and model for a\nnew thread without changing Runtime defaults; start or steer turns; interrupt\nwork; resolve approvals; and answer Runtime user-input requests. Selection\nloads `GET /v1/threads/{id}` first, then opens the replayable event stream with\n`since_seq=latest_seq`; reconnection advances from the newest accepted sequence\nand drops duplicates or events from a stale selection. The thread detail\nsnapshot includes `pending_approvals`, `pending_user_inputs`, and\n`pending_dynamic_tool_calls`; clients must hydrate those fields before\nsubscribing so a reload cannot strand work whose request event is at or before\n`latest_seq`. Resolution is also published as `approval.decided`,\n`user_input.answered`, `user_input.canceled`, `tool_call.resolved`,\n`tool_call.canceled`, or `tool_call.timeout` for already-connected clients.\n\nAn existing thread's model, mode, permission posture, workspace, and branch are\ndisplay-only in this client. Files/Changes, PTY/terminal, preview, artifacts,\nprovider login or global-default switching, Fleet creation, and\nundo/retry/restore controls are not built into this page. The native desktop\nclient covers them through the workspace-file, turn-artifact, terminal and\nworkspace-restore routes documented here.\n\n### Mobile control page\n\n`codewhale serve --mobile` starts the same HTTP/SSE runtime API and serves a\nphone-friendly control page at `/mobile`. It binds only to loopback\n(`127.0.0.1` or `::1`); a non-loopback host is rejected because this Runtime\nsurface does not yet provide TLS or a verified overlay transport. The static\nHTML page contains no Runtime bearer and is not itself token-gated. When\nRuntime auth is enabled, the CLI prints a short-lived, single-use loopback\nbootstrap URL. That capability creates a 30-minute, process-local\n`Max-Age=1800; HttpOnly; SameSite=Strict` mobile session cookie plus origin-scoped browser\nproofs. A sibling port that receives the host-scoped cookie cannot use it by\nitself. The page can also exchange an explicitly entered bearer once, then\nclears it rather than storing it in browser storage or a cookie. EventSource\nconnections use separate short-lived, single-use stream tickets.\n\nThe mobile page can list/create threads, send prompts, follow live SSE events,\nsteer or interrupt an active turn, and resolve normal tool approvals through\n`POST /v1/approvals/{approval_id}`. It is a local-only convenience surface;\ndo not expose it directly to another device or public network until Runtime has\na TLS or verified transport boundary.\n\n### Endpoints\n\n**Health**\n- `GET /health`\n\n**Sessions** (durable session manager)\n- `GET /v1/sessions?limit=50&search=&include_archived=false&archived_only=false&workspace=&sort=recent|name|size`\n- `GET /v1/sessions/summary?…` (same query params; projected row shape)\n- `GET /v1/sessions/{id}` (add `?peek=true&entries=12` for a bounded, redacted\n  read-only peek instead of the full transcript). The full response carries\n  `turn_outcomes` when a turn ended `Failed`: `{ status, error, ended_at,\n  after_message_count }` per failure, oldest first, bounded to 64, with the\n  error text the transcript showed and secrets redacted\n- `PATCH /v1/sessions/{id}` (`{ \"title\"?: string, \"archived\"?: bool }`)\n- `DELETE /v1/sessions/{id}`\n- `POST /v1/sessions/{id}/resume-thread` returns the open thread that already\n  holds the whole saved session (`200`), or seeds a new thread from it (`201`)\n  when none does, including when the session grew after that thread opened it.\n- `GET /v1/sessions/{id}/artifacts` and `GET /v1/sessions/{id}/artifacts/{artifact_id}?offset=&limit=`\n  (see workspace files and session artifacts above; runtime-turn spills are\n  read through `GET /v1/threads/{id}/turns/{turn_id}/artifacts/{artifact_id}`)\n- `POST /v1/sessions` (`{ \"thread_id\": string, \"title\"?: string }`) exports a\n  thread as a saved session. It is idempotent: it writes only the document\n  whose id is derived from the thread, creating it the first time (`201`) and\n  updating it after (`200`), so a retry never makes a duplicate. A session the\n  thread was resumed from is left unchanged.\n- `PUT /v1/sessions` (`{ \"thread_id\"?: string, \"session_id\"?: string }`) saves\n  a thread's live conversation. Naming a `session_id` that another thread is\n  bound to returns `409 Conflict`.\n- `GET /v1/sessions/repair` returns the last session-store repair summary, or\n  `null` if none has run\n\nSessions and threads answer the same `include_archived` / `archived_only` pair\nwith the same meaning, and `search` is the same fuzzy match (title, id,\nworkspace — substring, then subsequence) the TUI session picker and the workbar\nSessions list use. All three surfaces run one projection\n(`crates/tui/src/session_projection.rs`), so a listing cannot differ between\nthe terminal and the dashboard.\n\n`GET /v1/sessions/summary` returns rows that are field-compatible with\n`GET /v1/threads/summary` — `id`, `title`, `preview`, `model`, `mode`,\n`workspace`, `archived`, `updated_at` — plus `message_count`, `total_tokens`,\n`created_at`, `parent_session_id`, and `is_current`. One caveat stated plainly:\n`preview` is the session's recorded **title**, not its last message. Session\nmetadata does not store a last message, and reading every transcript to\nsynthesise one would make a list view an unbounded read. Full transcript\npreview lives in the TUI session picker, which reads one selected session.\n\n`PATCH /v1/sessions/{id}` renames and/or archives a saved session and returns a\nlifecycle receipt shaped like the thread patch receipt:\n\n```json\n{\n  \"session\": { \"id\": \"…\", \"title\": \"Renamed\", \"archived\": true, \"…\": \"…\" },\n  \"changes\": { \"title\": \"Renamed\", \"archived\": true }\n}\n```\n\n`changes` lists only what actually moved, so a no-op patch is distinguishable\nfrom an applied one. Archiving is durable and reversible: an archived session\nstays on disk and stays loadable, disappears from default listings, and is\nnever chosen by `--continue` or by auto-resume. The route is the same writer\nthe TUI picker (`e`) and `/sessions archive ` use — there is no second\narchive notion.\n\nWhile a session is open in an interactive Codewhale process, that process holds\nthe authoritative copy in memory and rewrites the whole document on its next\nautosave. `PATCH`, `PUT` and `DELETE` therefore fail closed on it with\n`409 Conflict` rather than writing something that would be silently reverted.\nChange it in the terminal instead. The open process holds a lock on the\nsession (`sessions/.late-usage/.live`), so this holds whether the request\nreaches the API inside that process or a separate `codewhale serve`.\n\nThe session store is repaired in the background at each launch and each\n`codewhale serve` start. The repair gives a \"Recovered:\" session to each thread\nin a Runtime store that no session is bound to. It unbinds threads whose session\ndocument is gone; they then load from their own turns. It moves unreadable\ndocuments, empty unbound stores, and old artifact directories that no session\nnames to `sessions/.set-aside//`, and writes a `MANIFEST.jsonl` there.\nNothing is deleted. `GET /v1/sessions/repair` and `codewhale doctor` report the\nlast run; `codewhale doctor --repair-sessions [--dry-run]` runs one on demand.\n\n`GET /v1/sessions/{id}?peek=true` returns a bounded, redacted, read-only view\ninstead of the transcript: at most 12 entries of at most 400 characters each\n(`&entries=N` lowers the budget, never raises it past the cap), tool calls and\nresults summarised to a name and a size rather than inlined, and\ncredential-shaped substrings masked. `omitted_before` reports how many earlier\nmessages were dropped. The payload carries `\"live\": false` and deliberately has\nno turn status, `running`, or `active` field — a saved session is a recording,\nand live state comes only from a resumed thread's SSE stream.\n\n**Threads** (durable runtime data model)\n- `GET /v1/threads?limit=50&include_archived=false&archived_only=false`\n- `GET /v1/threads/summary?limit=50&search=&include_archived=false&archived_only=false&thread_ids=,`\n- `GET /v1/threads/running`\n- `GET /v1/goals?limit=50&cursor=&status=` — read-only index of\n  the per-thread goal records across threads, newest update first. Returns\n  `{\"goals\": [ThreadGoal...], \"next_cursor\": \"\"}`; `next_cursor` is\n  absent on the last page. `limit` is 1-200 (default 50); `status` is one of\n  `active`, `paused`, `blocked`, `usage_limited`, `budget_limited`, `complete`;\n  goals whose thread no longer exists are omitted; a bad `status` or `cursor`, or a cursor issued under a different `status`, is `400`. Same auth as every other `/v1` read.\n  Goal creation, completion and removal stay on `/v1/threads/{id}/goal`.\n- `GET /v1/threads/{id}/notices`\n- `DELETE /v1/threads/{id}/notices/{notice_id}`\n- `POST /v1/threads`\n- `GET /v1/threads/{id}`\n- `PATCH /v1/threads/{id}` (see body shape below)\n- `POST /v1/threads/{id}/resume`\n- `POST /v1/threads/{id}/fork`\n- `GET /v1/threads/{id}/receipt` — what the thread did, one entry per action\n  (read-only; shape in [RECEIPTS.md](RECEIPTS.md))\n- `GET /v1/threads/{id}/turns/{turn_id}/receipt` — the same, for one turn;\n  `404` for an unknown thread or a turn that is not this thread's\n\n`thread_ids` is a comma-separated list of up to 200 IDs (each at most 128 bytes).\nThe route filters exact IDs before applying `limit`, preserving newest-first\norder. This is a selection filter, not an ownership or authorization check;\nthe Runtime bearer token remains the access boundary.\n\n`POST /v1/threads` accepts optional execution defaults in addition to the\nprovider, model, workspace, and permission fields:\n\n```json\n{\n  \"model_provider\": \"openai-codex\",\n  \"model\": \"gpt-5.6\",\n  \"reasoning_effort\": \"high\",\n  \"allowed_tools\": [\"read_file\", \"search\"]\n}\n```\n\n`reasoning_effort` uses the canonical Runtime vocabulary (`auto`, `off`,\n`low`, `medium`, `high`, `xhigh`, `ultra`, or `max`; documented compatibility\naliases are accepted and persisted canonically). `allowed_tools` is a\nmodel-visible allowlist. Omitting it keeps the normal configured catalog; an\nexplicit empty array (`\"allowed_tools\": []`) exposes no tools to the model.\nBoth fields are additive: older thread records and clients that omit them\nretain their previous behavior.\n\n`GET /v1/threads/summary` is the read-only summary surface used by the VS Code\nAgent View. `search` matches thread `id`, `title`, and `model` (and, when the\ntitle is unset, the latest turn's input summary — the displayed title). It\ndoes not scan turn or item bodies: `preview` is filled only after a match, so\na dashboard keystroke is not a whole-store read per thread. Each item includes\n`id`, `title`, `preview`, `model`, `mode`, `archived`, `updated_at`,\n`latest_turn_id`, `latest_turn_status`, plus workspace metadata:\n\n```json\n{\n  \"id\": \"thread_...\",\n  \"title\": \"Implement MCP status count\",\n  \"preview\": \"The TUI footer should count project MCP servers...\",\n  \"model\": \"deepseek-v4-pro\",\n  \"mode\": \"agent\",\n  \"branch\": \"feature/runtime-api\",\n  \"head\": \"abc1234\",\n  \"dirty\": false,\n  \"workspace\": \"/Users/you/projects/codewhale\",\n  \"archived\": false,\n  \"updated_at\": \"2026-06-06T05:43:00Z\",\n  \"latest_turn_id\": \"turn_...\",\n  \"latest_turn_status\": \"completed\"\n}\n```\n\n`branch` is resolved from the thread workspace at request time and may be\n`null` when the workspace is not a Git repository or the branch cannot be read.\n`head` is the current short Git commit for that workspace when available.\n`dirty` is true when the workspace has staged, unstaged, or untracked changes.\n`workspace` is included so editor clients can show when an agent lane is working\noutside the current VS Code folder.\n\nThread forks are sibling runtime threads, not an in-place tree projection.\n`thread.forked` events include `source_thread_id`; internal backtrack-aware\nforks may also include `backtrack_depth_from_tail` and `dropped_turn_id`, and\na fork anchored to a named turn (`/fork-at-turn`) reports them with the depth\nresolved from that turn, and names the first user turn it dropped (not the\nanchor, which a named-turn fork keeps, and not a prompt-less turn such as a\nmanual compaction that sits between them).\nThread list and summary responses remain flat in v0.8.40, so clients that need\na graph should reconstruct it from events instead of assuming list order is a\ncomplete tree.\n\n`GET /v1/threads/running` is the running-work accounting surface\n(#6180): threads with at least one queued or in-progress turn, each with\n`thread_id`, `model`, `title`, and `active_turns` (`turn_id` + `status`).\nBackground-capable clients use it for quit/background decisions — one call,\nno inference from latest-turn status. Archive state is ignored (archiving\nhas no quiescence gate); an empty array means no owned work is live.\n\n`GET /v1/threads/{id}/notices` is the per-thread active-notice surface\n(#6180): the TUI-visible conditions a watch-only client must surface —\n`subagent-terminal` (a child settled), `elevation-needed` (a tool call is\nblocked on elevation), `model-notify` (the model asked the user to come\nback) — each with `turn_id` and a `subject` id for targeting. Notices are\nin-memory session state, bounded to 32 per thread (oldest evicted), and\nnever persisted. Clearing: elevation auto-clears when its tool call\ncompletes; terminal/notify clear on `DELETE .../notices/{notice_id}`\n(204, unknown ids 404). Unknown threads 404 on both endpoints.\n\n`archived_only=true` returns archived threads only (mutually overrides\n`include_archived`). Default behavior is unchanged: `include_archived=false`\nand `archived_only=false` returns active threads. Added in v0.8.10 (#563).\n\n`PATCH /v1/threads/{id}` body — every field is optional, missing means\n\"no change\". At least one field must be present. `title` and `system_prompt`\naccept an empty string to clear a previously-set value. Added in v0.8.10 (#562):\n\n```json\n{\n  \"archived\": true,\n  \"allow_shell\": false,\n  \"trust_mode\": false,\n  \"auto_approve\": false,\n  \"model\": \"deepseek-v4-pro\",\n  \"mode\": \"agent\",\n  \"title\": \"User-set thread title\",\n  \"system_prompt\": \"You are a useful assistant.\",\n  \"model_provider\": \"custom\",\n  \"model_provider_id\": \"lm-studio\"\n}\n```\n\n`model_provider` switches the provider the thread's future turns use. It\ntakes a built-in kind (`deepseek`, `xai`, ...) or a configured route name, as\n`/provider` does. `model_provider_id` names one exact `[providers.]` table\nand wins over a route name. The target route is resolved and its client\npreflighted before anything is saved, so an unknown or credential-less\nprovider is refused and nothing changes. Without `model`, the thread takes the\nnew provider's default model; an `auto` thread stays `auto`. The loaded\nengine and conversation history are kept, and the next turn installs the new\nroute.\n\nA change of effective permission adds a completed `status` item to the thread's\nlatest turn and publishes a `thread.permissions_changed` event (`from`, `to`,\n`source`).\n\n**Turns** (within a thread)\n- `POST /v1/threads/{id}/turns`\n- `POST /v1/threads/{id}/turns/{turn_id}/steer` - inject guidance into the running turn. The response is a receipt for what actually happened, not for what was attempted; see [Steer delivery](#steer-delivery).\n- `POST /v1/threads/{id}/turns/{turn_id}/interrupt`\n- `GET /v1/threads/{id}/turns/{turn_id}/artifacts` - what the turn produced: typed references plus the workspace-delta state. See [Turn artifacts](#turn-artifacts).\n- `GET /v1/threads/{id}/turns/{turn_id}/artifacts/{artifact_id}?offset=&limit=&revision=` - read one reference from the workspace, the post-turn snapshot, or the session artifact directory.\n- `POST /v1/threads/{id}/compact` (manual compaction)\n- `POST /v1/threads/{id}/undo` - fork the thread with the last N turns removed (`{\"depth\": N}`, default 0 = last turn only); returns the forked thread plus `original_user_text` so a GUI can pre-populate the input box\n- `POST /v1/threads/{id}/fork-at-turn` - fork at one named user turn (`{\"turn_id\": \"turn_…\"}`, as `GET /v1/threads/{id}` reports it). The fork *keeps* that turn and every turn before it, and drops the turns after it; naming the last turn therefore keeps the whole conversation. The receipt is `/undo`'s (`thread`, `original_user_text`, `original_user_images`), carrying the *first dropped* user turn's prompt — what was asked next, even when a prompt-less turn such as a manual `/compact` sits between — so a client can put it back in the composer for editing. The source thread, its session document and the workspace are untouched, and there is no file rollback: a fork is a sibling conversation, and rewinding the workspace would rewind the branch left behind with it. Clients should name the turn instead of computing a `depth` — the transcript they render and the turn list this cuts are not the same list (steers, image-only prompts and injected handoffs each sit on one side only), and a client-side count that is off by one forks the wrong prefix while answering `201`. `400` when the turn is not a user turn of that thread.\n- `POST /v1/threads/{id}/patch-undo` - rolls back the files the dropped turns changed, then the same fork (`{\"depth\": N}`); returns `patch_result` (`files_restored`, `summary`, `snapshot_label`) alongside the forked thread. See [Workspace restore endpoints](#workspace-restore-endpoints) for ownership, the trust, admission and abort rules, and the `error.code` values of a refusal.\n- `POST /v1/threads/{id}/file-revert` - restore exactly one file from one restore point the thread owns (`{\"path\", \"snapshot_id\", \"expected_hash\"}`); never forks the conversation. See [Workspace restore endpoints](#workspace-restore-endpoints).\n- `POST /v1/threads/{id}/retry` - fork with the last N turns removed and immediately start a new turn (`{\"depth\": N, \"prompt\": \"...\"}`; `prompt` overrides the original user text, which is re-used when omitted)\n\n`POST /v1/threads/{id}/turns` accepts the same optional\n`reasoning_effort` and `allowed_tools` fields as per-turn overrides:\n\n```json\n{\n  \"prompt\": \"Review this change without running tools.\",\n  \"operation_key\": \"cwc-request-01J7Y6Q9W4\",\n  \"reasoning_effort\": \"max\",\n  \"allowed_tools\": []\n}\n```\n\nThe same `model_provider` / `model_provider_id` fields on a turn route that\none turn through another provider. The saved thread keeps its provider.\nWithout `model`, the turn uses that provider's default model (an `auto` thread\nstays `auto`). The override is always preflighted and is part of the\n`operation_key` fingerprint.\n\nAccount-owned model turns require `capabilities.account_model_owner: true` before\nsubmission. Set `account_model_owner` to the signed-in account ID, both\n`model_provider` and `model_provider_id` to `codewhale`, and `model` to the exact\n`provider/model` account route. The Runtime requires current model access for\nthat same secure account session and uses only that account credential; missing,\nexpired or revoked access is refused. Local keys and other configured routes are\nnot substitutes. A supplied profile constitution must belong to the same account.\nThe owner participates in the durable operation fingerprint; replay of an\nalready completed exact operation returns its receipt before fresh credential\nadmission. The account gateway resolves the selected provider's current saved\nAPI key at use. Ordinary turns and saved Chat defaults retain their existing\nbehavior. The Runtime Chat relay carries the same assertion as `accountModelOwner`.\n\nResolution is deterministic: a turn override wins over the thread default,\nwhich wins over the Runtime's normal configuration. For tools, reaching normal\nconfiguration means the ordinary configured catalog; `[]` is never treated as\nmissing. Reasoning is normalized only after the exact provider/model route is\nresolved, and `auto` remains a per-prompt reasoning decision even when the\nthread uses a fixed model. The request still enters the existing\n`Op::SendMessage` path and the single `Engine::run_turn` loop.\n\nImage input uses the same turn path: `\"images\": [{\"mime\": \"image/png\",\n\"dataBase64\": \"...\"}]`. Clients must first observe\n`capabilities.turn_image_inputs: true` in `/v1/runtime/info` (or the isolated\nRuntime Chat relay catalog). Older HTTP runtimes ignore unknown fields, so a\nsuccessful text response is not evidence that an attachment was accepted.\nThe field is omitted when empty. It is also accepted by app-server\n`thread/message`, `thread/request` messages, and prompt requests; that bridge\nchecks the underlying Runtime capability before forwarding image bytes.\nLegacy remote Work commands do not support images and explicitly refuse them.\n\nNew inline images require a named model whose exact resolved route reports\n`image_input: \"supported\"`; Auto and unknown/unsupported image routes are\nrefused before classifier or provider dispatch. This does not change the\nexisting trusted-local attachment behavior for routes with unknown capability.\nA nonempty prompt is required. Inputs are limited to 10 images, 4 MiB decoded\nbytes per image, 5 MiB total, and an 8 MiB JSON body. PNG, JPEG, GIF and WebP\nmust have matching MIME, canonical padded base64 and valid bounded image\ncontent: at most 8192 pixels per dimension, 33,554,432 pixels total and 64 MiB\ndecoder allocation. The Runtime does not fetch paths or URLs from this field.\nMalformed images refuse the whole turn; callers can retain the draft for\ncorrection. Relay command polling uses an 8 MiB response budget; the sender\nmust paginate by serialized bytes without advancing past unserved commands.\n\nAccepted image bytes and order are retained in the existing turn records and\nreconstructed after restart, import and fork. Retry retains those images even\nwhen its optional `prompt` changes the text; undo responses include\n`original_user_images` when present. Image-bearing records require schema v3,\nwhich older readers refuse. Text-only records and operation fingerprints retain\ntheir prior representation. Validated stored local images retain the existing\n5 MiB per-image ceiling and prior aggregate/count semantics on import/retry;\nthis internal storage authority does not\nrelax exact model or permission checks. Image bytes, MIME and order participate in request\nidentity, so changing an image under the same operation key conflicts.\nCompaction can summarize older context; retaining the original attachment does\nnot promise that every later model request includes it. Image pixels are not\nsubject to text-secret redaction.\n\n`operation_key` is an optional idempotency key for clients that may lose an\nHTTP response after the Runtime accepted a turn. It is scoped to the current\nRuntime store and thread, may contain at most 128 UTF-8 bytes, and may not be\nempty or contain surrounding whitespace or control characters. Omitting it\npreserves the legacy create-a-new-turn behavior.\n\nThe first accepted request durably binds a SHA-256 fingerprint of the key to\nthe Runtime turn id and a canonical request fingerprint before sending the\nexisting `Op::SendMessage`. An exact retry returns that original turn in the\nnormal `{ \"thread\": ..., \"turn\": ... }` response and emits no second engine\noperation, item, or lifecycle sequence. Reusing the key on the same thread\nwith a different provider/model, prompt, reasoning policy, tool allowlist or\ndynamic-tool schema, environment, or permission policy fails closed with\n`409 Conflict`. The same caller key may be used independently on another\nthread.\n\nOnly the scoped key fingerprint, request fingerprint, thread id, and turn id\nare stored in the Runtime's private turn-operation index. The raw key is never\npersisted or logged, and request bodies, credentials, and attachments are not\ncopied into that index. Existing thread/turn persistence remains the source of\nthe returned turn after a process restart.\n\n**Exact accepted-turn lookup**\n\n`GET /v1/threads/{id}/turn-operations/{operation_key}` uses the same Runtime\nauthentication as turn submission. URL-encode each path segment. It returns\n`200 OK` with the existing bare `TurnRecord` (the `turn` object in the POST\nresponse), identified by that exact thread and operation key. It does not use\nthe thread's latest turn or require the original request body or current route\nsettings to match.\n\n- `404 Not Found`: no binding exists for that thread/key, or persisted identities\n  do not match. These cases share a generic response.\n- `409 Conflict`: admission holds the operation claim, or its durable binding\n  is incomplete. Retry the lookup; this response does not authorize another turn.\n- `400 Bad Request`: the thread ID or operation key is malformed. The key uses\n  the same 128-byte and whitespace/control-character rules as POST.\n- `500 Internal Server Error`: storage or the existing claim lock cannot be\n  checked safely. This is not evidence that the operation is absent.\n\nThe lookup holds a shared read lock on the existing operation claim while\nreading the binding and turn. It creates no files, starts no engine, emits no\nevents, and performs no replay or recovery. Normal Runtime startup may recover\nan incomplete admission before a later lookup, but GET itself never does so.\n\n**Approvals**\n- `POST /v1/approvals/{approval_id}` with body\n  `{ \"decision\": \"allow\" | \"deny\", \"remember\": false }`\n- `GET /v1/approvals?limit=` (1-500, default 100) is the read-only history:\n  decided and still-pending approvals across all sessions, newest ask first.\n\nEach history row carries `approval_id`, `tool_name`, `outcome`, `asked_at` and\n`decided_at` (null while pending), plus optional `decided_by` (`user`,\n`session_rule`, `posture`, `host`), `target` and `summary`. `outcome` is one of\n`allowed_once`, `denied`, `timeout`, `cancelled`, `unavailable`,\n`retry_with_policy`, or `pending`. `standing_rule` (`allow` or `deny`) is present\nonly when a remembered session rule or grant answered rather than a person\n(`decided_by: session_rule`); `outcome` is unchanged. `target` is the host of a\nURL or a workspace-relative path (absolute paths outside the workspace are\nreduced to `.../`); `summary` is a one-line description of what was\nasked. Both are redacted conservatively when the ask is recorded: a shell\ncommand keeps only the program name and plain arguments, and any quoted,\n`KEY=VALUE`, credential-like or unrecognised argument replaces all arguments\nwith `[args redacted]`. Whitespace is collapsed and text is capped at 120\ncharacters. Rows recorded before these fields existed\nomit `target` and `summary`.\n\n`approval_id` is minted by the Runtime, not by the model or the provider. It is\nan opaque `approval_<32 hex>` capability, unique per prompt, bound to the thread\nthat raised it, and single-use: the Runtime removes it when the decision is\ndelivered, when the prompt times out, or when the turn abandons it. Clients echo\nthe value they were given and must not construct, derive, or guess one.\n\nIt is deliberately **not** the provider's tool-call ID. Providers restart their\ncall-ID counters per response, so two threads can gate calls whose raw IDs are\nbyte-equal; keying approvals by that value let one thread's decision settle\nanother thread's call. The endpoint therefore performs one exact match on the\nminted ID and has no fallback: a raw tool-call ID, an expired ID, or a replayed\nID that has already been settled all return `404` and reach no engine. A `404`\nmeans the capability is not pending — it is not evidence about how the approval\nwas resolved; read `approval.decided` for that.\n\nThe raw provider call ID travels separately as `tool_call_id` on\n`pending_approvals[]` and on the approval events. It is a correlator for\nattaching a prompt to the tool row it gates, and never accepted as a decision.\nEach thread-detail `pending_approvals[]` entry is\n`{ \"id\", \"turn_id\", \"tool_name\", \"description\", \"intent_summary\"?, \"tool_call_id\"?, \"summary\"? }`,\nwhere `id` is the capability above. `summary` (also on `approval.required`) is\na one-line description of the gated call built from the tool name and its\narguments only, never from model text (\"Search the web for 'espresso'\",\n\"Write notes/espresso.md\"); paths inside the workspace are workspace-relative.\nClients show it first and keep the raw arguments behind it. For task and\nautomation create/update, `summary` also names the requested trust mode,\nshell, auto-approve, mode and workspace.\n\n`\"remember\": true` on an `allow` records a **session grant** for that tool and\nargument class (the approval grouping key: a shell command family for a\nsimple, known command such as `git status` whose only options are value-free\nones like `-s` or `--porcelain` — a compound, wrapper, interpreter or\nunrecognised command, one with any other option, or one whose arguments are\nwhat runs or is installed (`go run`, `make`, `git bisect`, package installs)\nis granted as its full normalized command, and a\nshell interact or wait call as the exact call — a patch's file set, a `fetch_url` host, an MCP tool, a `web.run` action kind — for\n`open`, the hosts it opened). Computer Use consent and `app_script` calls, and\nany tool without a class, are granted for the exact call only. A grant never\nchanges the thread's permission posture. Later matching calls on the thread are\napproved without a prompt: they still emit `approval.required`, then\n`approval.decided` with `\"auto\": true` and the `grant_id`. Creating a grant\nemits `approval.grant_added` with `{ \"grant\": { \"grant_id\", \"tool_name\",\n\"scope\", \"summary\", \"granted_at\" } }`; thread detail lists live grants in\n`approval_grants[]`. `DELETE /v1/threads/{id}/approval-grants/{grant_id}`\nrevokes one (emitting `approval.grant_revoked`); the next matching call\nprompts again. Archiving or deleting the thread ends all of its grants\n(archiving emits `approval.grant_revoked` for each; unarchiving does not\nrestore them). Grants live in memory for the Runtime process: a restart\nforgets them, and a forced (non-bypassable) prompt is never answered by one.\n\n**User input**\n- `POST /v1/user-input/{thread_id}/{input_id}` with body\n  `{ \"answers\": [{ \"id\": \"question-id\", \"label\": \"Choice\", \"value\": \"Choice\" }] }`\n\nSubmitted values are delivered to the active model turn but are deliberately\nexcluded from durable Runtime items and events. The settled tool item contains\nonly a neutral receipt and a machine-readable `response_redacted` marker. The\nRuntime accepts only an exact pending `(thread_id, input_id)` request; an\nunknown, concurrently settling, or already settled id returns 404 and is never\nplaced in the engine mailbox. It commits the secret-free\n`user_input.answered` receipt before removing the snapshot-authoritative prompt\nor delivering the answer to the engine. That settlement runs independently of\nthe HTTP connection, so disconnecting after submission cannot leave a prompt\nhalf accepted. Terminal-turn cancellation follows the same receipt-before-\nremoval ordering through `user_input.canceled`.\n\n**Client-executed dynamic tools**\n- `POST /v1/threads/{thread_id}/turns/{turn_id}/tool-calls/{call_id}/result`\n\nThe thread and turn in the result route must match the pending call. A call is\nsettled at most once; wrong-route and duplicate results return 404. Terminal\nlifecycle events carry identifiers and status only, never tool result content.\nThe Runtime commits the terminal lifecycle event before making a submitted\nresult available to the model. Result delivery, timeout, and terminal-turn\ncancellation race through one settlement owner, so exactly one of these events\nis durable for a call:\n\n- `tool_call.requested` — the typed client-executed call became pending;\n- `tool_call.resolved` — a result was durably accepted by the Runtime\n  (`result_accepted: true`; `success` is result metadata, but result content is\n  excluded);\n- `tool_call.timeout` — no result won before the bounded wait expired;\n- `tool_call.canceled` — the turn terminated before a submitted result won.\n\nHTTP `202 Accepted` and `tool_call.resolved` share that durable-acceptance\nmeaning. Neither claims that the model consumed the result: a concurrent turn\nshutdown may close the model receiver after acceptance. Once the Runtime has\naccepted the result, that call is terminal and a duplicate result returns 404.\n\n**Events** (SSE replay + live stream)\n- `GET /v1/threads/{id}/events?since_seq=&replay_limit=&progress=true`\n\nCursors:\n\n- `since_seq` is the per-thread cursor: events with `seq > since_seq` are sent.\n  Omitted (and no `Last-Event-ID`), the stream starts from the beginning of the\n  thread's history.\n- Every journal frame carries `id: `, so a browser `EventSource` can\n  resume through the `Last-Event-ID` header it sends on reconnect. An explicit\n  `since_seq` wins over the header, so a deliberate replay from `0` is never\n  overridden by a stale id. A header value that is not a decimal integer is\n  ignored.\n- `replay_limit` (at most 4096) returns only the newest tail of the requested\n  history; `previous_seq` on the first returned event advances past exactly the\n  omitted history.\n\nDurable history parsing runs off the async server workers and reaches SSE in\nbounded batches of at most 256 events through a backpressured channel. Broadcast\ndelivery is only a wake-up optimization: a lagged receiver opens the same\nbounded durable replay from its last accepted cursor.\n\n`progress=true` adds `stream.progress` transport frames\n(`{schema_version, event, kind, thread_id, seq, state}`, `state` is\n`replaying` or `live`) at the current cursor and advertises them with\n`x-codewhale-event-progress: 1`. The stream reports `live` only after durable\nhistory and the already-queued live tail are both drained; broadcast-lag\nrecovery returns it to `replaying`. Progress frames never carry a new sequence\nnumber.\n\nFailures before the stream opens are ordinary HTTP errors with the JSON error\nbody, never SSE:\n\n| Status | When |\n| --- | --- |\n| `401` / `403` | Missing or wrong Runtime credential |\n| `404` | Unknown thread |\n| `400` | `replay_limit` above 4096 |\n| `500` | The durable history could not be opened (including a replay worker crash before the first cursor) |\n\nOnce the response is `200`, every end the server chooses is a final\n`stream.end` frame; see [Ending and resuming a thread\nstream](#ending-and-resuming-a-thread-stream).\n\n**Snapshots** (side-git restore point listing + restore)\n- `GET /v1/snapshots?limit=20`\n- `POST /v1/snapshots/{id}/restore`\n\n`/v1/snapshots` lists recent side-git restore points for the runtime workspace.\n`limit` defaults to `20` and must be between `1` and `100`. `POST\n/v1/snapshots/{id}/restore` restores workspace files from the snapshot and\nreturns `{\"restored\": \"\"}`. It is the direct operator surface for\nthe server's own workspace (the same action as the TUI's `/restore `): it is\ngated by the Runtime API bearer token, not by any thread's trust flag, and it\nis refused with `409` while a turn is active in an overlapping workspace (see\nbelow). A `pre-restore:` safety snapshot is taken first.\n\n```json\n[\n  {\n    \"id\": \"snap_...\",\n    \"label\": \"post-turn:1\",\n    \"timestamp\": 1780730580\n  }\n]\n```\n\n### Workspace restore endpoints\n\nThree routes mutate workspace files from side-git snapshots. They share one\nadmission rule and one safety net, and they differ in scope and trust.\n\n| Route | Scope | Trust | Forks the thread |\n| --- | --- | --- | --- |\n| `POST /v1/snapshots/{id}/restore` | whole server workspace | bearer token only (operator action) | no |\n| `POST /v1/threads/{id}/patch-undo` | the files the dropped turns changed | thread `trust_mode` or `auto_approve` when files would change | yes |\n| `POST /v1/threads/{id}/file-revert` | exactly one regular file | thread `trust_mode` or `auto_approve`, always | no |\n\n**Admission.** A restore reserves the same admission the Runtime uses for\nconfig reloads and session checkpoints, so no new turn starts and no saved\nhistory changes while files are being rewritten. If any thread already has an\nactive turn in the same workspace, a nested checkout of it, or a parent of it,\nthe request is refused with `409` and the message `already has an active turn`.\nThe reservation is owned by the worker performing the Git mutation, so a client\nthat disconnects mid-request cannot release it early; the operation completes\nor fails as a whole. Concurrent restores serialize. The reservation is\nruntime-wide: while a restore's safety snapshot and checkout run, new turns,\nsteering, compaction and user-input delivery on every thread wait for it to\nfinish, so a large workspace can add seconds of latency elsewhere during a\nrestore. A thread whose workspace directory is not available (unmounted\nvolume, disconnected share, missing directory) is refused with `409` rather\nthan treated as having nothing to restore.\n\n**Ownership.** A thread owns exactly the workspace restore points recorded on\nits own turns. While a turn runs, the engine reports each snapshot it takes —\n`pre_turn` before the turn, `tool` before and `post_tool` after each tool call\nthat may write (every call that is not read-only: file tools, shell commands,\nprograms, write-capable MCP tools), `post_turn` when it ends (always before\nthe turn settles) — and the Runtime appends it to the turn record's\n`workspace_snapshots`, in order:\n\n```json\n\"workspace_snapshots\": [\n  { \"kind\": \"pre_turn\", \"snapshot_id\": \"\", \"tree_id\": \"\", \"session_id\": \"thr_1a2b3c4d\" },\n  { \"kind\": \"tool\", \"snapshot_id\": \"\", \"tree_id\": \"\", \"session_id\": \"thr_1a2b3c4d\", \"tool_call_id\": \"call_…\", \"write_paths\": [\"src/lib.rs\"], \"changed_paths\": [] },\n  { \"kind\": \"post_tool\", \"snapshot_id\": \"\", \"tree_id\": \"\", \"session_id\": \"thr_1a2b3c4d\", \"tool_call_id\": \"call_…\", \"changed_paths\": [\"src/lib.rs\"] },\n  { \"kind\": \"post_turn\", \"snapshot_id\": \"\", \"tree_id\": \"\", \"session_id\": \"thr_1a2b3c4d\", \"changed_paths\": [] }\n]\n```\n\n`changed_paths` lists the workspace-relative paths whose content changed since\nthe turn's previous receipt — what happened in the span the receipt closes; it\nis absent on `pre_turn` and when it could not be computed (a snapshot in\nbetween failed). `write_paths` is set on the `tool` receipt of a file tool\n(`write_file`, `edit_file`, `apply_patch`) to the paths the call declared, as\nit named them; a tool without it (a shell command) may write any path. On a\nuser shell turn the `pre_turn` receipt carries the command's `tool_call_id`,\nsince the command runs from it to `post_turn`.\n\nEach receipt is also published as a `turn.workspace_snapshot` event (payload:\nthe receipt). The engine runs every Runtime thread under the thread's own id,\nacross restarts and engine eviction, so `session_id` is the thread id for turns\nthe thread ran itself; it does not follow the thread's saved-session binding\n(`PUT`/`POST /v1/sessions`, resume), which only names a document. A fork clones\nits source's turn records and so owns the restore points of the turns it\ninherited. Another thread's, or a TUI session's, snapshots in the same\nworkspace are never candidates. `tree_id` is the durable identity: a prune\nrebuilds the side repo and rewrites every commit id but keeps each tree, and a\nrestore point resolves only to a stored snapshot with the same tree, session\ntag and kind. The count prune after each snapshot keeps the newest 50\nsnapshots plus the newest 50 turn boundaries (`pre-turn:`/`post-turn:`), so a\nturn with more tool calls than that, or a burst from another thread, never\npushes out a recent turn's own restore points. Turns recorded before receipts existed, turns imported by\n`resume-thread`, and turns run with snapshots off or unavailable have none.\n\n**Safety net.** Every restore first records a `pre-restore:` snapshot\nof the current workspace. That label is never a `/undo`, `patch-undo` or\n`file-revert` candidate, so the net does not change what later undos select.\nFor `file-revert` the backup is mandatory: if it cannot be written, or the\nrequested file is excluded from it (for example by `.gitignore`), the request\nfails and nothing is changed.\n\n**`patch-undo`.** Undoes whole turns. For each dropped turn's `pre_turn` →\n`post_turn` window, the paths that differ between the two snapshots must all be\nthe turn's own: changed only in the span of one of the turn's tool calls (a\n`tool` → `post_tool` span, or a shell turn's whole window), and, inside a file\ntool's span, a path that call declared. A path that changed while none of the\nturn's tools could have written it — another thread, an editor, a background\nprocess — is someone else's change, and the undo is refused rather than revert\nit. Each of the turn's paths goes back to its content before the first dropped\nturn that changed it, and nothing else is touched, so later work by the user or\nanother thread in the same workspace survives. Then the conversation forks\nexactly as `/undo` does.\n\nSnapshots never hold paths excluded by the workspace's `.gitignore` files or\nthe built-in snapshot exclusions (`node_modules/`, `target/`, `dist/`, build\ncaches, binary artifacts), nor paths outside the workspace. A dropped file-tool\ncall that declared such a path is refused with `path_not_snapshotted`, because\nno snapshot can put it back. A shell command declares no paths: what it writes\nunder an excluded path (build output, dependency installs) is outside what\n`patch-undo` restores and is not reported. `201` means either files were restored (`files_restored: true`, one\n` ` line per file in `summary`, `snapshot_label` naming the\npre-turn snapshot) or there was provably nothing to restore\n(`files_restored: false`): every dropped turn ran here without a tool call,\nchanged no file, or its files are already back at their pre-turn content.\nAnything that cannot be restored aborts the whole undo with `409`, nothing is\nchanged and no fork is published; `error.code` says why:\n\n| `error.code` | Meaning |\n| --- | --- |\n| `restore_point_unavailable` | a dropped turn that may have changed files has no complete recorded restore point (older record, imported by `resume-thread`, snapshots off, or the snapshot failed) |\n| `restore_point_pruned` | the restore point is no longer in the snapshot store |\n| `path_not_snapshotted` | a dropped file-tool call wrote a path snapshots do not hold (ignored, built-in exclusion, or outside the workspace) |\n| `workspace_changed_since_turn` | a path the turns changed was changed afterwards (or between two dropped turns), a path changed while a dropped turn ran but outside its own tool calls, or a path that is not a regular file |\n| `restore_requires_trust` | there is something to restore and the thread is not in trusted mode or Full Access |\n| `workspace_unavailable` | the workspace directory is not available |\n\nFor the first four a client can offer the conversation-only\n`POST /v1/threads/{id}/undo` instead, and `file-revert` for individual files.\nSnapshot repository, listing or comparison failures abort with `500` and also\npreserve the conversation, so a turn is never dropped while its file changes\nstay on disk. Depth and history are validated before any file changes. If the\nfork cannot be persisted after files were restored, the response is a `500`\nthat names the restored snapshot; the original thread still holds the turn and\nthe `pre-restore:` snapshot holds the previous files.\n\n**`file-revert`.** Request body:\n\n```json\n{\n  \"path\": \"src/lib.rs\",\n  \"snapshot_id\": \"3f2a…40-or-64 hex…\",\n  \"expected_hash\": \"sha256:<64 lowercase hex digits>\"\n}\n```\n\n- `path`: workspace-relative, or absolute inside the thread workspace. The\n  name is literal (brackets, spaces and glob characters are filename bytes;\n  Git runs with `--literal-pathspecs`). It must name a regular file: directories,\n  symlinks anywhere in the path, and `.git` components are `400`.\n- `snapshot_id`: the exact `tool` or `pre_turn` restore point of the change\n  the user selected, from the thread's own turn records: a receipt's\n  `snapshot_id` or `tree_id` (for a tool call, the receipt whose\n  `tool_call_id` matches), or the current commit id `GET /v1/snapshots` lists\n  for it. It must be a restore point recorded on one of this thread's turns;\n  the server never picks \"the newest snapshot that differs\", because an\n  unrelated newer snapshot can erase later user edits while leaving the tool's\n  change.\n- `expected_hash`: `sha256:` of the current file bytes the client displayed,\n  or `absent` when the client saw the file as deleted. It is checked before the\n  safety backup and again immediately before the mutation.\n\nResponses:\n\n- `200 {\"path\", \"action\", \"snapshot_id\", \"snapshot_label\"}` — `action` is\n  `modified`, `recreated` (file was missing) or `removed` (the snapshot does\n  not contain the file, so the file the tool created is deleted; its parent\n  directories are left in place).\n- `400`: malformed `snapshot_id`/`expected_hash`, path outside the workspace,\n  or a path that is not a regular file on either side.\n- `404`: unknown thread.\n- `409`: thread not in trusted mode or Full Access; active turn in an\n  overlapping workspace; workspace directory not available; snapshot unknown,\n  pruned, not recorded on this thread's turns (another thread's or a TUI\n  session's), or not a restore point (refresh the change record); file already\n  matches the\n  snapshot (nothing to revert); or the file changed after the reviewed\n  `expected_hash` (refresh and review again). Nothing is changed in any of\n  these cases.\n- `422`: missing or mistyped body fields.\n- `500`: Git or filesystem failure; a failure after the safety snapshot names\n  that snapshot so the previous bytes can be recovered with\n  `POST /v1/snapshots/{id}/restore` or `/restore`.\n\nCapability probe: `GET` on the route returns `405` where the endpoint exists\nand `404` on an older engine; clients treat any non-`404` as available and\ndegrade with an explanation otherwise.\n\n**Compatibility stream** (one-shot, backwards-compatible)\n- `POST /v1/stream`\n\n**Tasks** (durable background work)\n- `GET /v1/tasks`\n- `POST /v1/tasks`\n- `GET /v1/tasks/{id}`\n- `POST /v1/tasks/{id}/cancel`\n\n**Automations** (scheduled recurring work)\n- `GET /v1/automations`\n- `POST /v1/automations`\n- `GET /v1/automations/{id}`\n- `PATCH /v1/automations/{id}`\n- `DELETE /v1/automations/{id}`\n- `POST /v1/automations/{id}/run`\n- `POST /v1/automations/{id}/pause`\n- `POST /v1/automations/{id}/resume`\n- `GET /v1/automations/{id}/runs?limit=20`\n\nCreate and update requests accept an optional `model`. When present, each\nscheduled or manually triggered run uses that model; omitting it keeps the\nruntime's default task model.\n\n**Automation mutation preconditions.** `GET /v1/runtime/info` advertises\n`capabilities.automation_mutation_preconditions: true`; require it before\nrelying on the behavior below (older Engines ignore both inputs).\n\n- Every `AutomationRecord` carries an integer `revision`: `1` on create, `+1`\n  on each `PATCH`, `pause` and `resume`; legacy records read as `0`. Scheduler\n  bookkeeping (`next_run_at`, `last_run_at`) does not change it.\n- `expected_revision` is an optional precondition on `PATCH`, `run`, `pause`\n  and `resume`: a JSON body field on `PATCH`, or the `?expected_revision=N`\n  query parameter on any of the four. On mismatch nothing is written and the\n  response is `409` with\n  `{\"error\":{\"code\":\"revision_conflict\",\"current_revision\":N,...}}`. Omitting\n  it behaves as before.\n- `Idempotency-Key` (1-255 visible ASCII characters) is optional on `POST\n  /v1/automations`, `PATCH`, `run`, `pause` and `resume`. The first successful\n  response (2xx) is persisted in `idempotency.json` beside the automation\n  store, bounded to 1024 entries and a 24 hour TTL. A retry with the same key\n  and identical request returns the stored status and body with\n  `Idempotency-Replayed: true` and performs no second create or run. The same\n  key with a different request is `422` `idempotency_key_reuse`. Failed\n  attempts are not stored, so they can be retried. Keys are scoped to the\n  route and automation id. If the record cannot be persisted after the\n  mutation committed, the success response is still returned (the failure is\n  logged), so a retry of that request is not deduplicated. An unparseable\n  `idempotency.json` is renamed to `idempotency.corrupt-.json` and\n  that request fails with `500`; later requests start from an empty store.\n  `DELETE` is not covered.\n\n**Operate** (always-on named operation; same `OperateRecord` as CWC\n`20de981` / PR #284)\n\n- `GET /v1/operate` — current operation + plan board\n- `POST /v1/operate` — create (`direction`, optional `burnRate`)\n- `PATCH /v1/operate` — steer direction, `burnRate`, or `leadPlan`\n- `PUT /v1/operate/plan` — set `leadPlan` (`{ slices: [...] }`)\n- `POST /v1/operate/keepalive` — observe spend / burn; never stops\n- `POST /v1/operate/cancel` — explicit cancel (`/v1/operate/stop` aliases);\n  also pauses the `cw-operate` keepalive so nothing keeps spending after cancel\n- `POST /v1/operate/auto-merge/check` — call landed\n  `scripts/check-auto-merge.py --repo --pr --agent` (does not merge)\n\nThe operation record (`current.json`) is persisted under a cross-process\nfile lock with atomic temp+rename writes; every PATCH / keepalive / plan\nsave reloads the latest state inside the lock, so concurrent saves merge\ninstead of losing writes. A `PATCH` that changes `direction` invalidates\nthe recorded `leadPlan` (workers stop executing superseded slices) and\npulls the keepalive lead run forward to re-plan. `POST /v1/operate`\ninstalls the hourly `cw-operate` keepalive and kicks its first lead-plan\nrun immediately instead of waiting out the first recurrence; credentials\nresolve through the normal Z.ai provider resolution (config, `api_key_env`,\nsecret store, or provider env vars — blank values count as missing).\n\n`burnRate` is `{ \"kind\": \"usd_per_hour\", \"amountUsdPerHour\": number }`,\na positive number, or `null` (unbounded). Status is\n`planning | running | idle_blocked | cancelled`. Pace\n(`unbounded | hold | throttle | widen`) is not a status: over-target\nthrottles, under-target widens, no wallet-cap stop. Idle-blocked only\nfor empty direction, awaiting lead plan, missing credentials, or a\nhuman gate. Auto-merge is `scripts/check-auto-merge.py --repo … --pr …\n--agent …` from codewhale-ops `origin/main` (exit 0), then\n`scripts/auto-merge-pr.py`. Do not invent a second checker.\n\n**Introspection**\n- `GET /v1/workspace/status`\n- `GET /v1/workspace/files/search?query=&limit=<1-100>` (see workspace file suggestions above)\n- `GET /v1/workspace/files?path=&limit=<1-2000>`, `GET /v1/workspace/files/read?path=&offset=&limit=`\n  and `PUT /v1/workspace/files` (see workspace files and session artifacts above)\n- `GET /v1/skills`\n- `GET /v1/skills/{name}` — one skill's routing metadata (`source`,\n  `invocation`, `aliases`, `bundled_tier`, `enabled`) plus its full\n  `SKILL.md` body, so a client can compose an activation instruction for its\n  own next turn the way TUI's `/skill ` does. `404` for a name no\n  discovery root holds; `403` for a plugin snapshot whose authority is no\n  longer current; native rows whose file has since been deleted also `404`\n  rather than serving the stale body. Advertised as\n  `capabilities.skill_detail` on `GET /v1/runtime/info`, which is the source a\n  client should use rather than probing this path: a `404` here means \"no such\n  skill\" and is indistinguishable from \"no such route\".\n- `GET /v1/apps/mcp/servers`\n- `GET /v1/apps/mcp/tools?server=`\n\nSkill activation toggles are persisted under a cross-process transaction lock.\nEach mutation reloads and merges the latest exact-name state before an atomic\nwrite, and `GET /v1/skills` refreshes that shared state so another Codewhale\nprocess's successful toggle is visible without restarting the Runtime API.\n\nSkill rows on `GET /v1/skills` carry `invocation`, `aliases`, and\n`bundled_tier` alongside the fields they always carried, so a client can build\na picker, autocomplete, or activation gate without a second request per row.\n\n**Usage** (token/cost aggregation across threads)\n- `GET /v1/usage?since=&until=&group_by=`\n\n`since` / `until` are inclusive RFC 3339 timestamps and may be omitted (no\nbound). `group_by` defaults to `day`. Buckets are sorted by ascending key.\nEmpty time ranges produce empty `buckets` (never a 404). Cost is computed via\nthe model→pricing map; turns whose model has no pricing entry contribute\ntokens but `0.0` cost. Added in v0.8.10 (#564).\n\n```json\n{\n  \"since\": \"2026-04-01T00:00:00Z\",\n  \"until\": \"2026-04-30T23:59:59Z\",\n  \"group_by\": \"day\",\n  \"totals\": {\n    \"input_tokens\": 12345,\n    \"output_tokens\": 6789,\n    \"cached_tokens\": 0,\n    \"reasoning_tokens\": 0,\n    \"cost_usd\": 0.012,\n    \"turns\": 42\n  },\n  \"buckets\": [\n    {\n      \"key\": \"2026-04-30\",\n      \"input_tokens\": 1234,\n      \"output_tokens\": 678,\n      \"cached_tokens\": 0,\n      \"reasoning_tokens\": 0,\n      \"cost_usd\": 0.001,\n      \"turns\": 3\n    }\n  ]\n}\n```\n\n### Native client routes (GPUI desktop)\n\nThese families serve the GPUI desktop client over the same bearer-token\ntransport. They reuse the runtime's existing authorities — the engine's shell\nmanager, the durable thread store, the workspace confinement layer, the\nconfig's credential plumbing — and add no second runtime, session store,\nscheduler, or credential store.\n\n**Terminal sessions** (the persistent Engine-owned shell)\n\nThe jobs family above runs one command per job. A terminal pane needs the\n*other* authority: the stateful PTY-backed shell the agent's own terminal\ntools drive, which keeps cwd and environment across inputs. These routes\nattach to that session and never create one — a name with no live session is\n`404`, because conjuring a shell from an HTTP request would give the client a\nterminal the Engine does not know about. Input is attributable by route:\n`input` is the client's writer, `terminal_send` is the agent's.\n\n- `GET /v1/terminal/{name}/output?cursor=&max_bytes=<1-64KiB>&format=\n  ` — the resumable byte stream. `{name, offset, next_cursor,\n  total, dropped, encoding, data, running, exit_code}`: pass `next_cursor`\n  back to continue; reads never consume, so several clients may hold\n  independent cursors; `dropped` reports bytes the 512 KiB ring discarded,\n  and a cursor past `total` is answered from `total` rather than echoed back\n- `POST /v1/terminal/{name}/input` — `{ \"data\", \"encoding\"? }`, `base64` by\n  default (exact bytes) or `text` for UTF-8 → `{ \"name\", \"written\" }`\n- `POST /v1/terminal/{name}/resize` — `{ \"rows\", \"cols\" }` → the kernel\n  window the child draws for\n- `POST /v1/terminal/{name}/kill` — end the shell; observe the exit through\n  `output` (`running` / `exit_code`) rather than the acknowledgement\n\n`GET /v1/runtime/info` advertises `terminal_stream`, `terminal_input`,\n`terminal_resize` and `terminal_kill`. All four are `false` on Windows and OpenHarmony builds\ntoday: the owner is Unix-only, those routes answer `501`, and a client\nshould gate its terminal controls on these flags rather than discovering it\nfrom a failed request. Known limitations, stated because a reader would\notherwise assume them: there is no `wait_ms` long poll (poll the cursor),\nscrollback dropped by the ring is gone with the process, a restarted Engine\nreports no session rather than pretending to reattach, and the\n`@codewhale/runtime-sdk` package has no terminal client wrapper yet — the raw\nroutes are the contract for now.\n\n**Jobs** (operator-scoped shell jobs; the terminal surface)\n- `GET /v1/jobs` — every live and known-stale job across all threads\n- `GET /v1/threads/{id}/jobs` — jobs owned by one thread's manager:\n  model-launched, subagent-launched, and client-launched together\n- `POST /v1/threads/{id}/jobs` — `{ \"command\", \"cwd\"?, \"timeout_ms\"?,\n  \"tty\"?, \"env\"? }` → `201 { \"job\" }`; runs as a background shell under the\n  thread's projected sandbox policy. `tty: true` merges stderr into stdout\n  and gives the command a terminal (required for interactive programs);\n  background jobs are never killed at `timeout_ms`. A relative `cwd`\n  resolves against the thread workspace. Outside trust mode `cwd` must stay\n  inside it after symlinks resolve (`403` otherwise): unlike the shell tool,\n  this route does not follow `workspace_follow_symlinks` or `/trust add`\n  roots, so a symlink leading out of the workspace is refused. The job runs\n  in the resolved directory that was checked (a later symlink retarget does\n  not move it); a `cwd` that resolves to a non-UTF-8 path is a `400`\n- `GET /v1/threads/{id}/jobs/{job_id}` — one job's status + metadata\n- `GET /v1/threads/{id}/jobs/{job_id}/output?stream=&cursor=\n  &max_bytes=<1-512KiB>&wait_ms=<0-30s>&format=` — the\n  resumable byte stream. `{job_id, stream, offset, next_cursor, total,\n  dropped, encoding, data, status, exit_code, done}`: pass `next_cursor`\n  back to continue; `wait_ms` long-polls for new bytes on a running job;\n  `done` means a terminal status and nothing left past the cursor\n- `POST /v1/threads/{id}/jobs/{job_id}/stdin` — `{ \"data\", \"encoding\"?,\n  \"close\"? }`: `data` is UTF-8 text by default or `base64`, `close: true`\n  sends EOF; works for PTY and piped jobs → `204`\n- `POST /v1/threads/{id}/jobs/{job_id}/kill` — bounded SIGTERM → SIGKILL\n  escalation on the process group → `{ \"job\", \"result\" }` with the final\n  snapshot\n\nReads are non-consuming: several clients may hold independent cursors, and\npolling never steals output from the engine's own delta consumer. The\nbuffer is bounded with exact drop accounting — a reader whose `cursor`\nfalls behind the retained window gets `offset` past it and `dropped > 0`,\nand must re-anchor. Evicted jobs keep a tail snapshot which the output\nroute serves as the final retained window. Jobs are scoped to the thread\nthat created them and are killed when that thread is removed; the engine's\nbackground commands use the same per-thread manager, so `GET /v1/jobs` is\nalso how a client sees model-spawned work.\n\n**Commands** (typed command catalog, APPS-28)\n- `GET /v1/commands` — `{commands: [...]}`: every registered slash command,\n  builtin and user, as the TUI's own registry holds it. Per entry: `name`,\n  `aliases`, `summary` and `usage` (English source text — localizing is the\n  client's surface), `subcommands` (the literal verbs the usage line\n  declares), `takes_arguments`, `kind` (`builtin` registered code, or `user`\n  expanding a stored template), `binding` (`host` runs locally and never\n  reaches the model; `prompt` expands into the request the model sees),\n  `discovery` (`primary` / `advanced` / `compatibility`, builtins only),\n  `hidden` for rows the product does not advertise, and `shadowed_by` /\n  `shadowed_aliases` where a user command has taken a builtin's spelling.\n\n  Each entry also carries the composer argument shape, computed the way the\n  TUI composer computes it, so a client does not re-derive it from `usage`\n  (#6230):\n  - `requires_argument` — the usage line mentions any argument, required or\n    optional.\n  - `requires_required_argument` — the usage line has a `` argument\n    outside every `[optional]` group.\n  - `composer_wants_trailing_space` — accepting the command leaves a trailing\n    space for its arguments.\n  - `palette_runs_directly` — the palette runs the command on selection\n    instead of pasting it into the composer.\n  - `show_in_empty_discovery` — the command is listed when the slash menu\n    opens with no filter text.\n\n  User commands derive these from `takes_arguments`: their arguments are\n  never required, a template that takes arguments waits in the composer and\n  one that does not runs directly, and a `hidden` template stays out of empty\n  discovery.\n\n  The same registry the TUI palette reads, so a desktop palette can be\n  checked against it instead of drifting from it. Two rules a client must\n  respect: a `binding: \"host\"` row is never submitted as a model prompt, and\n  a user command shadowing a builtin name wins that spelling.\n\n**Hooks**\n- `GET /v1/hooks[?thread_id=...]` — `{workspace, enabled, hooks: [...],\n  problems: [...]}`: the hook set Runtime API threads run for that workspace\n  (the server workspace, or the named thread's). Per entry: `name`, `event`,\n  `command` (credential-shaped values masked; every URL keeps only its scheme and host), `background`, `timeout_secs`,\n  and `source` (`global` user config, `plugin` reviewed plugin, `project`\n  trusted and approved `.codewhale/hooks.toml`). `problems` lists hooks\n  rejected or warned about at load, one line each.\n\n  Every Runtime thread builds its engine with this set: `tool_call_before`\n  can deny a call, `shell_env` applies to shell tools, and `tool_call_after`\n  and `on_error` (for a failed tool) fire as observers, as they do in the TUI.\n  Clients read this route instead of keeping a hook table of their own.\n\n**Context** (per-thread context pressure, APPS-90)\n- `GET /v1/threads/{id}/context` — `input_tokens` (the conservative live\n  estimate the visible meter uses), `billed_input_tokens` (last\n  provider-counted prompt size when one exists), `window_tokens`,\n  `output_cap_tokens`, `input_budget_ceiling`, `available_input_tokens`,\n  `compaction_trigger_tokens`, `usage_percent` and `pressure`. Served by the\n  live engine via `Op::GetContextBudget`. Every numeric field is nullable —\n  a route that cannot express a bounded window reports `null` rather than an\n  invented number — and `live: false` marks responses where the engine\n  could not be loaded and only the store-recorded route's static window\n  resolved.\n\n**Git** (workspace repository operations, APPS-106)\n- `GET /v1/git` — status detail: `git_repo`, `branch`, `head`\n  (abbreviated, for display), `head_oid`, `index_token`, `revision`,\n  `ahead`/`behind`, counts, per-file porcelain `files[]`\n  (`{path, index, worktree, staged, status, old_path?, rev}`), `branches`,\n  `remotes`. `files[].path` and `old_path` are workspace-relative, the same\n  frame the write routes take; in a workspace that is a subdirectory of its\n  repository, rows outside the workspace are not listed (the counts stay\n  repository-wide). A rename out of the workspace appears as its source\n  deletion. Normal Git filters and untracked settings apply. Untracked\n  directories stay collapsed; only a row naming the workspace itself is\n  expanded to individually addressable files. The precondition tokens are\n  opaque:\n  - `head_oid` — the full HEAD commit id; `null` on an unborn branch or when\n    HEAD cannot be read\n  - `index_token` — the whole index (mode, blob, stage and path of every\n    entry, repository-wide). A stat-only refresh by `git status` does not\n    change it. Status remains best-effort on repository read failures;\n    unavailable tokens are `null`, and broken HEADs cannot satisfy guards\n  - `files[].rev` — one row: its index entries plus the working-tree state\n    of every file it covers (including a rename's in-workspace source).\n    Content tokens (`c-…`) include file bytes, the executable bit on Unix,\n    symlink targets, and submodule HEAD/status. Submodule dirty contents\n    are summarized by porcelain status, not recursively hashed; ordinary\n    stage/discard do not write those contents. A read hashes at most\n    64 MiB / 4,096 files; rows past that budget or containing a file over\n    16 MiB carry a display-only size-and-mtime token (`s-…`). Those tokens\n    cannot guard writes. Unreadable paths, paths beneath symlinked\n    directories, special files and broken nested repositories have `rev: null`;\n    other rows retain their tokens. If a broken tracked submodule aborts\n    porcelain, ordinary rows are recovered without submodule recursion and\n    the unreadable submodule is shown with `status: \"unknown\"`, `worktree: \"?\"`\n  - `revision` — the whole tree: `head_oid`, `index_token`, every row's\n    `rev`, including the working-tree state of rows outside a subdirectory\n    workspace. `null` when any row is stat-only or unreadable, a repository\n    read is incomplete, or untracked paths are hidden; whole-tree guarded\n    writes are unavailable in that case. Clients must not silently omit a guard\n- `GET /v1/changes` — the same porcelain `files[]` projection plus\n  `head_oid`, `index_token` and `revision`, minus repo chrome\n  (branches/remotes): one authority, so the change list can never disagree\n  with the status read\n- `GET /v1/diff?path=` — one file's unified `diff` against `base` (`HEAD`,\n  or the empty tree on an unborn branch — which reads staged adds as new\n  files). Covers staged+unstaged in one patch; `truncated` reports the\n  512 KiB cap. An untracked file answers `untracked: true` with an empty\n  diff — the client reads the file itself rather than mistaking it for\n  unchanged\n- `GET /v1/workspace/diff?limit=` — whole-tree patch (default 256 KiB,\n  max 4 MiB) plus a complete `--numstat` `files[]` inventory\n  (`{path, added, deleted}`) so every changed row renders even when the\n  patch is truncated\n- `GET /v1/git/graph?limit=` — bounded commit rows (`id`, `short`,\n  `parents`, `author`, `timestamp`, `refs`, `subject`); an unborn branch is\n  an empty graph, not an error\n- `POST /v1/git/stage` `{ \"paths\": [...] }` or `{ \"all\": true }`;\n  `POST /v1/git/unstage` same; `POST /v1/git/discard` `{ \"paths\": [...] }`\n  (tracked paths only — no `all`, an untracked path fails closed);\n  `POST /v1/git/commit` `{ \"message\", \"all\"? }`; stage, unstage, discard\n  and commit also take an optional `expect` (below); `POST /v1/git/push`\n  `{ \"remote\"?, \"set_upstream\"? }` (`remote`, or `origin` when only\n  `set_upstream` is given, must name a configured remote);\n  `POST /v1/git/branch`\n  `{ \"name\", \"create\"? }`\n\nDiffs and precondition token reads run through the hardened review command\n(filters, fsmonitor, hooks, lazy fetches and replace-objects neutralized).\nPorcelain status uses normal Git, matching the workspace counts and filters;\nwrites run through the non-interactive command path\n(`GIT_TERMINAL_PROMPT=0`, BatchMode ssh) so a credential or host-key prompt\ncan never hang a request. Path lists are workspace-relative under the same\nconfinement as the file routes (traversal → 400, `.git` → 403), passed after\n`--` under `--literal-pathspecs`, so `src/*` names a file called `*` and is\nnever a glob. (Unstage of the whole tree uses the `:/` root pathspec.)\nMutations answer `{ok, output, status, current}`: the refreshed status and\nthe full `GET /v1/git` detail, so a client re-reads nothing after an\noperation and can chain the next write from fresh tokens. A workspace that\nis not a repository answers `404`.\n\n*Preconditions.* Stage, unstage, discard and commit accept\n`expect: { head?, index?, revision?, files? }`, built from the last\n`GET /v1/git`. Each present field is checked and an absent one is not;\n`head: null` means \"HEAD must still be unborn\". Without `expect` (or with\n`expect: {}`) a write behaves exactly as before. Recommended use:\n\n| Operation | `expect` |\n| --- | --- |\n| stage / unstage `paths` | `{head, files: {path: rev}}` |\n| stage / unstage `all` | `{head, revision}` |\n| discard (always — it destroys edits) | `{head, files: {path: rev}}` |\n| commit | `{head, index}` |\n| commit `all` | `{head, revision}` |\n\nMalformed preconditions answer `400` before anything runs: `head` must be a\n40- or 64-hex id or `null`; `index` and `revision` 64 hex (an explicit\n`revision: null` is refused); `files` values a content-safe `c-` rev (`s-`\ntokens answer `400`). `files` keys\n(workspace-relative; `dir/` and `dir` are the same key)\nmust name exactly the requested paths, so no path is left unguarded by\naccident. `files` is refused with `all: true` (use `revision`) and on\ncommit. An unknown key inside `expect` is rejected like any other unknown\nfield. When the repository no longer matches, the route writes nothing and\nanswers `409`:\n\n```json\n{ \"error\": { \"message\": \"The repository changed since it was read (HEAD moved; src/a.rs changed). Nothing was written; refresh and review again.\",\n             \"status\": 409, \"code\": \"git_state_changed\" },\n  \"stale\": [\"head\", \"files\"], \"stale_paths\": [\"src/a.rs\"],\n  \"current\": { \"...\": \"the GET /v1/git detail\" } }\n```\n\n`stale` lists the components that moved (`head`, `index`, `files`,\n`revision`); `stale_paths` the `files` keys whose `rev` changed. A client\nre-renders from `current`, keeps the user's selection, and asks again.\n\nStage, unstage, discard, commit and branch from one runtime are serialized,\nso a check and its write are atomic with respect to that runtime's other\nwindows; a second concurrent write answers `409` with\n`error.code: \"git_busy\"` rather than queueing behind a long commit hook.\nPush is not serialized: it only moves a remote ref and can wait on the\nnetwork for up to 120 s. The lock does not cover processes outside this\nruntime — a terminal, an editor, or Codewhale's own agent tools — which can\nstill change the repository in the moment between the check and git taking\n`index.lock`; a truly concurrent git write then fails on git's own\n`index.lock` (a `400` carrying git's message). A compare-and-swap commit via\n`commit-tree` and `update-ref` would close that window but skip the\nrepository's hooks, which a Review-sheet commit must run, so it is not used.\n\n**Diagnostics** (read-only logs, crashes, process — APPS-103)\n- `GET /v1/logs` → `{sources: [{dir, files: [{name, size, modified}]}]}` —\n  the runtime's log directory plus `audit.log[.1]` from the codewhale home,\n  newest first, capped\n- `GET /v1/logs/{name}?offset=&limit=&tail=` →\n  `{name, size, modified, offset, bytes, truncated, encoding, content}` —\n  one bounded window; `tail` reads from the end and is mutually exclusive\n  with `offset`; `truncated` means bytes remain after the returned window\n  (a tail read at EOF is `false`), `encoding` is `utf-8` or `base64`\n- `GET /v1/crashes`, `GET /v1/crashes/{name}` — the same list/read contract\n  over the crash-dump directories (`~/.codewhale/crashes`, legacy\n  `~/.deepseek/crashes` merged)\n- `GET /v1/process` → `{pid, version, commit, started_at, uptime_seconds,\n  executable, rss_bytes}` — `rss_bytes` only where the platform reports it\n  (Linux `/proc`); absent rather than fabricated elsewhere\n\nThese routes package what already exists on disk for a client-side export;\nthere is no telemetry upload route and no second log store. Names are\nbasename-validated (no separators, no `..`), listings are capped, reads are\nbounded windows, and symlinks are never followed — a client bundles the\nfiles itself.\n\n**Targets and remote posture** (APPS-50)\n- `GET /v1/targets` → `{targets: [self], remote: {supported: true,\n  attach: \"client\", probe: \"POST /v1/remote/connect\"}, ssh: {…},\n  cloud: {…}}` — this runtime's own record as the attachable target plus\n  per-surface ownership; the runtime keeps no persistent target registry,\n  so `POST /v1/targets` and `POST /v1/targets/switch` answer\n  `501 Not Implemented` — target selection is client-owned and a switch\n  must never move a running task server-side\n- `GET /v1/remote` → `{bind_host, port, loopback_only, reachable_from_lan,\n  auth_required, mobile, tls}` — this listener's reachability posture.\n  `tls` is always `false`: the API has no TLS terminator, so non-loopback\n  reachability assumes a verified overlay (VPN/mesh), never plain LAN trust\n- `POST /v1/remote/connect` `{ \"endpoint\": \"http://host:port\" }` — probes a\n  candidate remote's unauthenticated `GET /v1/runtime/info` (origin only;\n  any pasted path is discarded). Answers `{ok, remote: {endpoint,\n  runtime_api_version, codewhale_version, auth_required, …}, attach:\n  \"client\"}` on success, and `{ok: false, reason: \"unreachable\" |\n  \"not a Codewhale runtime\" | …}` as data on failure. URLs carrying\n  credentials are refused with 400 — the remote's token is configured\n  client-side, and a connect route that forwarded one would be an\n  exfiltration primitive\n- `GET /v1/ssh`, `GET /v1/cloud` → `{supported: false, owner:\n  \"codewhale-control-plane\", reason}`; `POST /v1/ssh/connect` and\n  `POST /v1/cloud/attach` → `501`: SSH workspace provisioning and hosted\n  cloud computers belong to the Apps control plane (ASCII Box for Managed\n  Computer), not to a second authority inside Core\n\nA remote Codewhale is a `serve --http` runtime with a token — that is the\nwhole attach model. These routes describe and probe it; they never execute\na remote request on the local machine.\n\n**LSP** (workspace language intelligence, APPS-93)\n- `GET /v1/lsp` — capability: `enabled`, supported `languages` with their\n  server commands, `custom_languages`, operations, poll and diagnostic caps\n- `GET /v1/diagnostics?path=` — file diagnostics\n- `GET /v1/definition?path=&line=&character=` (1-based)\n- `GET /v1/references?path=&line=&character=` (1-based)\n- `GET /v1/symbols?path=&query=` — empty query returns document symbols\n\nOne lazily-built workspace-level `LspManager` serves these; engine threads\nkeep their own per-thread managers for the post-edit hook, and a server that\nnever serves an LSP route never spawns a language server. `path` is\nworkspace-relative under the same confinement as the file routes. Normal\nabsence is data: no language server, a disabled `[lsp]` config, or a timeout\nanswers `200` with `ok: false` and a machine-readable `reason`\n(`no_server`, `lsp_disabled`, `lsp_error`); malformed input is a 400 and a\nmissing file a 404.\n\n**Voice** (host dictation, APPS-98)\n- `GET /v1/voice` — capability: `available`, detected `recorder` command,\n  resolved `asr` `{kind, model}`, `modes`, `send_phrases`,\n  `max_record_seconds`\n- `POST /v1/voice/dictate` — record then transcribe → `{ ok, text }`\n- `POST /v1/voice/send` — same capture with the \"send it\" / 发送/發送\n  suffix contract: `send: true` tells the client to submit (empty `text`\n  with `send: true` means submit the client's current draft)\n- `POST /v1/voice/control` `{ \"composer\": \"draft text\" }` — assisted\n  dictation that shows the model the composer text; `assisted: false` in the\n  response means a free ASR backend (local whisper/Groq) handled the audio\n  and the composer context was never seen\n\nThe runtime owns the host microphone and the ASR dispatch — the same\nimplementation the TUI's `/voice` commands run, headless. Recording is one\nblocking capture per host (requests serialize; the loser gets\n`ok:false`/`no_speech`, not a fought-over device). Provider ASR resolves its\nkey lazily so local-whisper and Groq paths work without provider auth.\nInterim and final transcription use the selected ASR backend. If local whisper\nor Groq fails, the error stays on that backend: the runtime never retries the\nrecording or composer text with the active model provider. Select provider ASR\nexplicitly to use that route.\nFailure is data: `no_recorder`, `no_speech`, `no_provider_auth`,\n`transcription_failed`. `CODEWHALE_DISABLE_VOICE=1` is an operator\nkill-switch — a headless `serve --http` host reports `available: false` and\nevery dictate call fails closed.\n\n## Provider and model selection\n\nThese three routes are how a GUI renders a model picker whose contents are true\nfor *this* runtime instead of guessed from a version snapshot. They were\nundocumented until 2026-08-04, which cost a desktop integration a day: the\nclient probed `/v1/models`, `/v1/runtime/models`, and `/v1/runtime/providers`\n(all correctly 404) and concluded the capability did not exist.\n\n### `GET /v1/providers`\n\n```json\n{\n  \"current\": \"modelstudio-token-plan\",\n  \"providers\": [\n    {\n      \"id\": \"modelstudio-token-plan\",\n      \"model_provider_id\": \"modelstudio-token-plan\",\n      \"display_name\": \"Alibaba Cloud Model Studio\",\n      \"default_model\": \"qwen3.8-max\",\n      \"has_model_catalog\": true,\n      \"credentialState\": \"configured\"\n    }\n  ]\n}\n```\n\n`current` is the active generic provider id. Only the active entry carries an\nexact identity: an active built-in normally repeats its canonical id in\n`model_provider_id`, while an active named custom route has `current` set to\n`custom` and the exact configured key there (for example `lm-studio`). Other\nentries have a null exact id; a null id on the active `custom` entry identifies\nthe released legacy root-level custom route. Preserve both fields from the\nselected entry and send a non-null exact id back as `POST /v1/threads`'s\n`model_provider_id`; dropping a named custom id would collapse the selection to\nthe legacy root custom route. `credentialState` is a stable, non-secret\nprojection of the Runtime's existing structural credential classification:\n\n- `configured`: credential material is structurally available;\n- `login_required`: the route needs login or a usable login capability;\n- `missing`: an API-style credential is unavailable;\n- `no_auth`: the route explicitly disables credential use;\n- `local`: the exact route is local and keyless;\n- `legacy`: the compatibility route cannot be classified more precisely.\n\nFor an active named custom provider, this state is calculated from the exact\nroute named by `model_provider_id`, not from a generic custom-provider default.\nIt deliberately collapses saved-key and imported-token details into\n`configured`, and login/consent-source details into `login_required`.\n\nThe response never includes endpoint URLs, credential environment-variable\nnames, filesystem paths, credential values, consent-source details, or token\nmetadata. `credentialState` is not a provider canary: `configured` does not\nprove endpoint reachability, credential validity, model entitlement, or a\nsuccessful request. The models route below is also only a selection catalog; a\nnon-empty list does not prove that the route can currently serve a request.\n\n### `GET /v1/providers/{id}/models`\n\n```json\n{\n  \"provider\": \"deepseek\",\n  \"models\": [\n    {\n      \"id\": \"deepseek-v4-flash-vision-exp\",\n      \"image_input\": \"supported\",\n      \"reasoning_effort\": \"unknown\",\n      \"reasoning_effort_levels\": [],\n      \"reasoning_effort_source\": null\n    }\n  ]\n}\n```\n\nFor an exact configured route, supply `?model_provider_id=vision-work` and\nrequire the response to echo that same `model_provider_id`. The Runtime resolves\nthat identity under the requested provider kind before reading model support.\nUnknown or mismatched identities return `400`. Named pagination cursors bind the\nconfiguration identity, endpoint and catalog snapshot; changing any of those\nrequires restarting pagination. Omitting the query preserves the legacy catalog\nprojection and omits the identity echo.\n\nThe catalog for one provider. Returns `400` for an unknown id, and for the\nlegacy `deepseek-cn` alias, which has no provider metadata — use `deepseek`.\nAn empty `models` array means the Runtime has no discoverable or configured\nmodel ids for that provider; it does not report credential presence.\n\nThe ids returned here are exactly the values accepted by `POST /v1/threads`'s\n`model` field and by the switch route below. `image_input` is the exact resolved\nprovider/model route's capability state: `supported`, `unsupported`, or\n`unknown`. Keep `unknown` unknown rather than inferring from the model name or\nwire protocol. `supported` describes the model route; it does not mean a given\nclient implements an image-upload control.\n\n`reasoning_effort` uses the same three capability states and describes whether\nthe exact model's metadata publishes a selectable effort ladder.\n`reasoning_effort_levels` contains only canonical, recognized active effort levels\nfrom that metadata. Off and provider synonyms such as none are excluded: the\nApps/Chat protocol treats off as omission, which does not prove support for an\nexplicit provider disable command. A model capable of reasoning may still have\nan unknown active effort ladder.\nCodex levels are also excluded when native compatibility would change their\nwire value (currently minimal and auto). This projection does not change native\ncompatibility behavior or advertise a tier the Runtime cannot send unchanged.\nNo levels are inferred from a provider-wide default or a familiar model name\non a custom endpoint. `reasoning_effort_source` identifies `catalog`,\n`codex_cli_cache`, or `codex_app_server`; missing, stale, and unrecognized model\nmetadata stays unknown. Codex roster metadata describes the external CLI's\nroster, not proof that a separately configured Runtime credential belongs to\nthe same account or that an authentication boundary is approved.\n\nPass `?model_provider_id=` when selecting a named route.\nThe Runtime validates the provider kind and exact identity together, returns\n`model_provider_id` alongside that route's model list, and leaves the active\nconfiguration unchanged. An empty or unknown requested identity, or a mismatched kind, returns\n`400`; it never falls back to another named route.\n\nThe Runtime Chat relay publishes the same effort fields in camelCase\n(`reasoningEffort`, `reasoningEffortLevels`, `reasoningEffortSource`). These\nmodel facts do not enable tool execution or establish account entitlement.\n\nFor a thread-scoped choice, send the provider fields from the selected entry\nalongside the selected model. Omit `model_provider_id` when it is null:\n\n```json\n{\n  \"model_provider\": \"custom\",\n  \"model_provider_id\": \"lm-studio\",\n  \"model\": \"local-vision-model\"\n}\n```\n\nThis creates one thread on the exact named custom route without changing the\nRuntime's provider or model defaults.\n\n### `PUT /v1/providers/{id}/key` — write-only credential\n\n```json\n// request\n{ \"key\": \"sk-…\" }\n\n// response\n{ \"provider\": \"openai-codex\", \"stored\": true, \"backend\": \"keychain\",\n  \"credentialState\": \"configured\", \"configPath\": \"/…/config.toml\" }\n```\n\nStores a provider API key through the same transactional write as\n`codewhale auth set --provider  --api-key-stdin`: the secret store under\nthe provider write lock, plus the `[providers.] auth_mode` metadata\nmarker persisted to the config document and mirrored into the live runtime\nconfig so `GET /v1/providers` reports the new state immediately. `backend`\nnames which secret backend holds the key and `configPath` which config\ndocument carries the marker (the user-global file when the ambient config\nis workspace-scoped).\n\nThe key is never returned — there is no read route for credential material,\nand neither the key nor its length appears in the response, errors, or\nlogs; the response carries only the readiness projection\n(`credentialState`). An unknown provider id, the `deepseek-cn` legacy\nalias, an empty key, a key over 4 KiB, or one containing control characters\nis `400`. `credentialState: \"local\"` after a successful write is honest\noutput for a keyless local route: the key is stored, but the route\nclassifies as not needing one.\n\n### `DELETE /v1/providers/{id}/key` — clear a Codewhale-owned credential\n\n```json\n// response\n{ \"provider\": \"openai\", \"cleared\": true, \"credentialState\": \"missing\" }\n```\n\nClears the credential through the same shared owner as\n`codewhale auth clear`: the config document is snapshotted and restored if\nits save fails, the secret store is only touched once that save has landed,\nand the cleared markers are mirrored into the live runtime config so\n`GET /v1/providers` reports `missing` on the next read rather than after a\nrestart.\n\nClearing an already-clear route returns `cleared: true` — a client retrying\na revoke must not be told something went wrong. If the config entry is\ncleared but the secret backend refuses the delete, the route answers `500`\nand names the slot: reporting success while the key is still in the keyring\nwould be a lie about a security action.\n\n### Credential ownership: `credentialSource` and `credentialWritable`\n\nBoth credential verbs refuse a route whose credential Codewhale does not\nown, and `GET /v1/providers` carries the same classification so a client can\ndisable its control *before* submitting instead of failing late:\n\n| `credentialSource` | `credentialWritable` | Meaning |\n| --- | --- | --- |\n| `secret_store` | `true` | Codewhale's own durable backend. The only writable source. |\n| `config` | `false` | A literal key in a config file, which still wins at request time. |\n| `external_auth` | `false` | An active external consent (OAuth) owns the credential. |\n| `none` | `false` | The route sends no credential, or has no credential slot. |\n\nWhen `credentialWritable` is `false`, `credentialWritableReason` carries\nuser-facing copy naming the owner, and both `PUT` and `DELETE` answer `409`\nwith that same reason. The classification is structural: it reads declared\nauth mode, consent state and the *kind* of any configured `api_key` value,\nand never resolves a secret, an environment value, or an auth command. It is\na class and never a value, a path, or an environment variable name.\n\n### `POST /v1/providers/{id}/switch`\n\n```json\n// request  (model is optional; omit to take the provider default)\n{ \"model\": \"qwen3.8-max\" }\n\n// response\n{ \"provider\": \"modelstudio-token-plan\", \"model\": \"qwen3.8-max\",\n  \"message\": \"…\", \"persisted\": true }\n```\n\n**Use this rather than simulating a switch with repeated `POST /v1/config`\nwrites plus a reload.** Provider and model move together here, the change is\nvalidated against the provider's catalog before it is applied, and `persisted`\nreports whether it was written to config or applied to the live session only.\nRejects an unknown provider id and the `deepseek-cn` alias with `400`.\n\n## Runtime data model\n\nThe runtime uses a durable Thread/Turn/Item lifecycle.\n\n- **ThreadRecord** — `id`, `created_at`, `updated_at`, `model`,\n  `model_provider` (generic kind), `model_provider_id` (optional exact configured\n  route), `workspace`, `mode`, `task_id`, `system_prompt`, `latest_turn_id`,\n  `latest_response_bookmark`, `archived`\n- **TurnRecord** — `id`, `thread_id`, `status` (`queued|in_progress|completed|\n  failed|interrupted|canceled`), `effective_provider`, `effective_model`,\n  `effective_billing_surface`, timestamps, duration, usage, error summary,\n  `artifacts` and `workspace` (see [Turn artifacts](#turn-artifacts))\n- **TurnItemRecord** — `id`, `turn_id`, `kind` (`user_message|agent_message|\n  tool_call|file_change|command_execution|context_compaction|status|error`),\n  lifecycle `status`, `metadata`, `artifacts` and the legacy\n  `artifact_refs` projection\n\nEvents are append-only with a global monotonic `seq` for replay/resume.\n\n`effective_billing_surface` is a non-secret classification derived from the\nendpoint that served the turn. Recognized StepFun routes use `stepfun-payg` or\n`stepfun-plan`; unknown and custom endpoints leave it unset. The raw base URL is\nnot persisted in `TurnRecord`.\n\n### Restart semantics\n\n- If the process restarts while a turn or item is `queued` or `in_progress`,\n  the recovered record is marked `interrupted` with an `\"Interrupted by\n  process restart\"` error.\n- The trailing newline is an event append's commit marker. On startup, a final\n  JSONL fragment without that delimiter is truncated and fsynced even when its\n  bytes form valid JSON; it is an uncommitted append, and its already-reserved\n  sequence number is not reused. Newline-terminated malformed records are not\n  identifiable crash debris and continue to fail closed during replay.\n- If a terminal turn record reached disk but its terminal event sequence did\n  not, the first async read reconciles any unresolved dynamic calls as\n  `tool_call.canceled` and then emits one `turn.completed`. Existing terminal\n  call and turn receipts are detected and never duplicated.\n- Turn-operation bindings survive restart. Retrying the same `operation_key`\n  and request returns the original recovered turn (including an `interrupted`\n  turn recovered from an in-progress process exit); mismatched reuse remains a\n  conflict. A crash-created binding that never acquired a turn is discarded at\n  startup because engine submission happens only after both records are\n  durable.\n- Task execution performs its own recovery on top of the same persisted\n  thread/turn store.\n\n### Approval model\n\n- The `auto_approve` flag applies to the runtime approval bridge and engine\n  tool context. When enabled for a thread/turn/task, approval-required tools\n  are auto-approved in the non-interactive runtime path, shell safety checks\n  run in auto-approved mode, and spawned sub-agents inherit that setting.\n- When omitted, `auto_approve` defaults to `false`.\n- [Authorization order](AUTHORIZATION_ORDER.md) describes where typed rules,\n  registered tool requirements, safety floors, repository law, approval\n  transport, and sandbox enforcement sit relative to one another.\n\n### SSE event stream\n\nThe SSE event payload shape for `/v1/threads/{id}/events`:\n\n```json\n{\n  \"schema_version\": 1,\n  \"seq\": 42,\n  \"previous_seq\": 38,\n  \"event\": \"item.delta\",\n  \"kind\": \"item.delta\",\n  \"thread_id\": \"thr_1234abcd\",\n  \"turn_id\": \"turn_5678efgh\",\n  \"item_id\": \"item_90ab12cd\",\n  \"timestamp\": \"2026-02-11T20:18:49.123Z\",\n  \"created_at\": \"2026-02-11T20:18:49.123Z\",\n  \"payload\": {\n    \"delta\": \"partial output\",\n    \"kind\": \"agent_message\"\n  }\n}\n```\n\nCompatibility notes:\n\n- `schema_version` is the HTTP/SSE envelope schema version. It is independent of\n  the runtime store schema used for persisted thread/turn/event records.\n- `event` remains the SSE event name in existing clients; it is preserved as-is.\n- `kind` mirrors `event` in the stable envelope for typed clients.\n- `seq` is allocated globally across all Runtime threads. Consequently, gaps\n  between a thread's events are normal when other threads interleave. On this\n  per-thread SSE stream, `previous_seq` is the sequence of the last event\n  delivered for this thread (or the requested replay cursor for the first\n  event); clients detect loss by comparing it with their accepted per-thread\n  cursor, not by requiring `seq == previous_seq + 1`. Sequence allocation is\n  also not rewound after an append is transactionally rolled back, so a retry\n  can intentionally skip an unused value without implying a missing event.\n- `thread.started`, `turn.started`, and `turn.completed` are emitted as SSE event\n  names exactly as before.\n- `timestamp` remains the canonical event time for schema version 1. `created_at`\n  is an equivalent alias for clients that use `created_at` naming elsewhere; do\n  not require both fields to be present.\n\n### Ending and resuming a thread stream\n\nWhenever the server ends a `/v1/threads/{id}/events` stream that already\nreturned `200`, the last frame is `stream.end`, sent exactly once and always\n(it does not need `progress=true`):\n\n```\nevent: stream.end\ndata: {\"schema_version\":1,\"event\":\"stream.end\",\"kind\":\"stream.end\",\"thread_id\":\"thr_1234abcd\",\"reason\":\"replay_failed\",\"last_seq\":42,\"retryable\":true}\n```\n\n- It is a transport frame, not a journal event: it has **no `seq`** and **no\n  SSE `id:`**. Clients that acknowledge on `seq` skip it, and a browser's\n  `Last-Event-ID` stays on the last real event.\n- `last_seq` is the stream's cursor when it ended: the last journal `seq`\n  delivered on this connection, or the effective start cursor if none was\n  (after a `replay_limit` tail, that is already past the omitted history). It\n  is exactly the `since_seq` that resumes with no loss and no repeats.\n- `retryable` says whether resuming from `last_seq` can succeed. Key the client\n  on it, not on the reason list. Treat an unknown `reason` by its `retryable`.\n- There is no free-text message. The underlying error is in the Runtime log,\n  and it can contain store paths.\n\n| `reason` | Meaning | `retryable` |\n| --- | --- | --- |\n| `replay_failed` | The durable history read that feeds the opening replay failed, including a replay worker crash after the first cursor | `true` |\n| `catch_up_failed` | After broadcast lag, the durable re-read from the stream's cursor could not be opened or failed | `true` |\n| `runtime_shutdown` | The Runtime API server is stopping (SIGINT, SIGTERM, or SIGHUP; Ctrl+C or Ctrl+Break on Windows). Open streams get this frame within a bounded drain window before the process exits | `true` |\n\nEvery `200` response carries `x-codewhale-stream-end: 1`. With that header, an\nEOF **without** `stream.end` means the connection or the Runtime process died\nwithout the server choosing to end the stream: a network or proxy drop, a\ncrash, or a kill that allows no drain (for example `SIGKILL`). A Runtime that\npredates this frame sends no header; there EOF stays ambiguous, so treat it as\nconnection loss.\n\nClient resume rule:\n\n1. Keep `cursor` = the `seq` of the last journal frame you accepted. Ignore\n   frames with `seq <= cursor`. If a frame's `previous_seq` is not your\n   `cursor`, you missed events: reload the thread snapshot rather than trust\n   local state.\n2. On `stream.end` with `retryable: true`, reconnect with\n   `since_seq = last_seq` after a bounded backoff, and tell the user what the\n   Runtime said (for example, \"Runtime is shutting down — reconnecting\").\n   With `retryable: false`, stop, show the reason, and fall back to the\n   snapshot.\n3. On EOF or a transport error without `stream.end`, reconnect with\n   `since_seq = cursor` after a bounded backoff and show a connection problem,\n   not a Runtime error.\n4. A `401`/`403` or `404` before the stream opens is terminal (a credential or\n   thread problem). Retry `5xx` with backoff.\n5. Never reconnect from `0` to \"start over\": replay is idempotent only by\n   cursor.\n\nFleet streams (`/v1/fleet/runs/{run_id}/events`) keep their own end frames,\n`fleet.stream.error {retryable}` and `fleet.replay.cursor_unavailable`. They\ndiffer from `stream.end`: they carry no cursor, because a Fleet client resumes\nfrom the opaque `cursor` of the last Fleet event it accepted.\n\n### Steer delivery\n\nPutting a steer into the engine's mailbox is not the same as the model reading\nit. The engine discards a steer whose turn has already moved on, and an\ninterrupted or failed turn drops whatever it had queued. The API reports the\nengine's real verdict rather than the attempt:\n\n- The item is persisted `queued` when the steer is accepted into the mailbox.\n- **Delivered.** The engine committed the text into the turn's record: the item\n  becomes `completed`, `steer_count` rises, and `turn.steered` + `item.completed`\n  are emitted. `POST .../steer` returns `200` with that turn.\n- **Not delivered.** The turn moved on, was interrupted, or failed first: the\n  item becomes `canceled`, `steer_count` does not rise, and `turn.steer_dropped`\n  is emitted carrying `input`, `reason`, and the settled `item`. `POST .../steer`\n  returns `409`, so a client can keep the user's text and resend it rather than\n  clearing a composer over guidance that was never seen.\n- **Still pending.** A steer sent while the engine is inside a long tool call\n  cannot settle until that call returns, and the request does not hang for it.\n  After a short wait `POST .../steer` returns `200` with the item still `queued`;\n  the eventual `turn.steered` or `turn.steer_dropped` event carries the verdict.\n\nA client that treats `200` as \"the model saw it\" is therefore wrong in the third\ncase: read the item's status, or wait for the event.\n\nCommon event names: `thread.started`, `thread.forked`, `turn.started`,\n`turn.lifecycle`, `turn.steered`, `turn.steer_dropped`, `turn.interrupt_requested`,\n`turn.completed`, `turn.artifacts`, `item.started`, `item.delta`, `item.completed`,\n`item.failed`, `item.interrupted`, `approval.required`, `approval.decided`,\n`approval.timeout`, `user_input.required`, `user_input.answered`,\n`user_input.canceled`, `tool_call.requested`, `tool_call.resolved`,\n`tool_call.timeout`, `tool_call.canceled`, `sandbox.denied`,\n`turn.workspace_snapshot`, `runtime.store_failure`.\n\n`runtime.store_failure` is the runtime reporting a fault in the operator's own\non-disk state: a thread, turn, or item record under the session's runtime\nstore could not be read, parsed, or written. The payload carries `operation`\n(`read` | `parse` | `write`), `record_kind` (`thread` | `turn` | `item`),\n`record_id`, `path`, the full `error` chain, the root-cause `reason`, a\n`next_action` (which file to move aside, or where to check free space and\npermissions), and a one-line `message`. When `terminal` is `true`, the turn's\nown record is unreadable or unwritable and no `turn.completed` will follow;\nclients waiting on that turn should treat it as failed.\n\nAgent-message and reasoning deltas are materialized into the item projection\nbefore their corresponding `item.delta` event is sequenced. To avoid an fsync\nfor every provider fragment, adjacent deltas are coalesced to configured bounds\nof at most 32 ms or approximately 16 KiB before publication (an indivisible\nupstream chunk can itself exceed the byte target). A process crash inside that\nunpublished window can lose the recent suffix; no durable event claims that\nsuffix existed. Once an `item.delta` is durable, snapshots at or beyond its\ncursor include the same materialized prefix.\n\n`approval.required` events may include a `matched_rule` string when an\nexecution-policy rule caused the prompt. This field is explanatory metadata for\nclients and does not grant or persist permissions.\n\n`approval.required`, `approval.decided`, and `approval.timeout` carry two\ndistinct identifiers. `approval_id` is the Runtime-minted, single-use capability\ndescribed under **Approvals** — the only value `POST /v1/approvals/{id}` accepts\n— and `approval.required` also repeats it in the legacy `id` field for older\nclients. `tool_call_id` is the provider's raw tool-call ID, present for\ncorrelation only. Automatically resolved prompts (thread `auto_approve`, and the\nAuto-Review posture, which never opens a modal) mint an `approval_id` as well, so\nthe field has one meaning on every path; those IDs register no waiter and are\ninert against the endpoint. Clients must never treat `tool_call_id` as an\napproval capability or assume it is unique across threads.\n\nThe thread event stream forwards these payloads intact. The compatibility turn\nstream carries `approval_id`, its `id` alias and `tool_call_id`; the pending\nsnapshot carries the same capability and correlator so reconnecting clients can\nattach an approval prompt to its tool row. Resolutions carry their resolution\nflags through that projection too: `timeout: true` marks the deny produced when\nthe configured decision budget expires, and `cancelled: true` marks a deny\nforced by a turn interrupt or turn teardown where no user selection\nwas made — clients should clear the pending prompt rather than report a\nrefusal.\n\n## Security boundary\n\n- **Localhost by default**. The server binds to `127.0.0.1` by default.\n  `--mobile` is also loopback-only and rejects a non-loopback host until a TLS\n  or verified-overlay transport boundary exists. The runtime does not provide\n  user isolation or TLS.\n- **Optional token guard**. `--auth-token` or `DEEPSEEK_RUNTIME_TOKEN`\n  requires a matching bearer token for `/v1/*` routes. This is a local\n  convenience guard, not a replacement for TLS, VPN, or a trusted reverse\n  proxy on public networks.\n- **No provider-token custody**. The server never returns the API key. The\n  `api_key.source` capability field reports `env`, `config`, or `missing` —\n  never the key itself.\n- **No hosted relay**. The app-server is a local process under the user's\n  control. There is no cloud component.\n- **Capability responses** never leak secrets, file contents, or session\n  message bodies. They report *metadata*: presence, counts, status flags.\n\n### CORS allow-list\n\nThe runtime API ships with a built-in dev-origin allow-list:\n`http://localhost:3000`, `http://127.0.0.1:3000`, `http://localhost:1420`,\n`http://127.0.0.1:1420`, `tauri://localhost`. To add additional origins (e.g.\nwhen developing a UI on Vite's default `:5173`), use any of:\n\n- CLI flag (repeatable): `codewhale serve --http --cors-origin http://localhost:5173`\n- Env var (comma-separated): `DEEPSEEK_CORS_ORIGINS=\"http://localhost:5173,http://localhost:8080\"`\n- Config (`~/.codewhale/config.toml`):\n  ```toml\n  [runtime_api]\n  cors_origins = [\"http://localhost:5173\"]\n  ```\n\nUser-supplied origins **stack on top of** the built-in defaults; they do not\nreplace them. Wildcard origins are not supported — the explicit allow-list\nmodel is preserved. Cross-origin preflights advertise only `Authorization`,\n`Content-Type`, `Accept`, `X-Codewhale-Runtime-Token`, and the compatibility\n`X-DeepSeek-Runtime-Token` request header; custom request headers are not\nallowed. Added in v0.8.10 (#561), tightened in v0.9.1 (#4454).\n\n## Managed Fleet Runtime and SDK helpers\n\nThe Runtime SDK lives in `npm/runtime-sdk` and is exposed as\nthe `@codewhale/runtime-sdk` workspace package. It is deliberately thin: every\nhelper calls the local Rust Runtime API and therefore cannot bypass Codewhale's\nsandbox, approval prompts, provider configuration, or fleet ledger authority.\n\n```js\nimport { createRuntimeClient } from \"@codewhale/runtime-sdk\";\n\nconst client = createRuntimeClient({\n  baseUrl: \"http://127.0.0.1:7878\",\n  token: process.env.CODEWHALE_RUNTIME_TOKEN,\n});\n\nconst created = await client.createFleetRun({\n  target: \"this_computer\",\n  roles: [{ name: \"reviewer\" }, { name: \"verifier\" }],\n  workflow: {\n    id: \"release-check\",\n    kind: \"parallel\",\n    tasks: [\n      { id: \"review\", name: \"Review\", instructions: \"Review locally.\", worker: { role: \"reviewer\" } },\n      { id: \"verify\", name: \"Verify\", instructions: \"Verify locally.\", worker: { role: \"verifier\" } },\n    ],\n  },\n});\n\n// POST /runs only prepares durable work. This call crosses the launch gate.\nawait client.startFleetRun(created.run.id);\n\nlet cursor;\nfor await (const event of client.fleetEvents(created.run.id, { after: cursor })) {\n  if (event.cursor) cursor = event.cursor;\n  if (event.event === \"fleet.replay.cursor_unavailable\") {\n    // Reload getFleetRun(created.run.id), then reconnect without the old cursor.\n  }\n}\n```\n\nThe managed path is deliberately two-step. `POST /v1/fleet/runs` validates and\npersists the run and queue without starting a worker. A separate authenticated\n`POST /start` activates it and schedules the executor driver; its `202` response\nreports `leased: 0` because the driver performs all leasing after it owns the\nrun. Creation requires named roles, one task owner per role, a `parallel`\nWorkflow, and an explicit Runtime target. v0.9.4 executes\nonly `this_computer`; `another_computer` and `cloud` return `501` rather than\nsilently executing locally. Worker IDs are generated per run; caller-assigned\n`worker_specs` return `501` until custom workers can be given collision-free\nmanaged identities. Parallel tasks with overlapping effective write roots are\nrejected before the run is journaled. Managed `security_policy` overrides also\nfail closed until that document can be enforced end to end; executable\nauthority comes from each named role's tool posture and bounded task workspace\nscope.\n\nFleet helpers cover this HTTP surface:\n\n| Helper | Runtime API route |\n|---|---|\n| `createFleetRun(spec)` | `POST /v1/fleet/runs` |\n| `startFleetRun(runId)` | `POST /v1/fleet/runs/{run_id}/start` |\n| `listFleetRuns()` | `GET /v1/fleet/runs` |\n| `getFleetRun(runId)` | `GET /v1/fleet/runs/{run_id}` |\n| `listFleetWorkers(runId)` | `GET /v1/fleet/runs/{run_id}/workers` |\n| `getFleetWorker(workerId)` | `GET /v1/fleet/workers/{worker_id}` |\n| `interruptWorker(workerId)` | `POST /v1/fleet/workers/{worker_id}/interrupt` |\n| `stopWorker(workerId)` | `POST /v1/fleet/workers/{worker_id}/stop` |\n| `restartWorker(workerId)` | `POST /v1/fleet/workers/{worker_id}/restart` |\n| `stopFleetRun(runId)` | `POST /v1/fleet/runs/{run_id}/stop` |\n| `replayFleetEvents(runId, options)` | `GET /v1/fleet/runs/{run_id}/events/replay` |\n| `fleetEvents(runId, options)` | `GET /v1/fleet/runs/{run_id}/events` (SSE) |\n\n`stopWorker` durably cancels that worker's active task and leaves the rest of\nthe Fleet running. `interruptWorker` is the compatibility name for the same\nattempt-fenced cancellation transition. `stopFleetRun` cancels every queued or\nactive task and marks the whole run cancelled.\n\nReplay covers aggregate run/task transitions and privacy-bounded individual\nworker transitions. Event bodies omit prompts, tool call IDs, completion text,\nartifact paths/checksums, and cancellation identities; bounded failure reasons\npass through secret redaction. `cursor` is opaque and stable across ordinary\nappends and Runtime restarts. Clients reconnect with `after=`. A fresh\nrequest returns a bounded newest tail and marks `history_truncated` when older\nhistory exists. Ledger compaction can remove an old cursor; the JSON endpoint\nthen returns `409`, while the SSE endpoint emits\n`fleet.replay.cursor_unavailable`, so the client reloads the current run\nprojection instead of accepting a silent gap.\n\n`GET /v1/runtime/info` advertises `fleet_run_create`, `fleet_run_start`,\n`fleet_event_replay`, `fleet_event_stream`, and `fleet_local_target`. Older\nruntimes without a requested route still produce a typed SDK\n`RuntimeCapabilityError`.\n\nVerification:\n\n```bash\nnpm test --workspace @codewhale/runtime-sdk\n```\n\n## Agent Run Receipts\n\nSub-agent lanes persist compact run receipts in\n`.codewhale/state/subagents.v1.json`. The Runtime API exposes those receipts as\na read-only inspection surface:\n\n| Operation | Endpoint |\n|---|---|\n| List persisted agent runs | `GET /v1/agent-runs` |\n| Inspect one run | `GET /v1/agent-runs/{run_id}` |\n| Stop one run | `POST /v1/agent-runs/{run_id}/cancel` |\n\nThe response is the same worker-record shape surfaced by `agent` receipts:\n`spec.run_id`, `actor_kind`, lifecycle `status`, bounded `events`,\n`follow_up`, `takeover`, `artifacts`, `usage`, and `verification`. `run_id`\nfalls back to the worker id for older records, and `{run_id}` may be either the\nrun id or the worker id.\n\nThese endpoints do not start or steer sub-agents. The API surface exists so\napp/editor/headless clients can inspect the same handoff receipts that the TUI\nand parent model see, and stop a run they are showing.\n\n`POST /v1/agent-runs/{run_id}/cancel` takes no body. It stops the run through\nthe same session-scoped path as the TUI's stop and the `agent/cancel` tool:\ndescendants stop with it, and a write-scoped child's changed files are named in\nits result rather than dropped. It answers with the worker record:\n\n- `200` when the record is terminal (stopping an already-finished run is a\n  no-op that returns its receipt);\n- `202` when the owning engine accepted the stop but has not recorded the\n  terminal receipt within a few seconds; poll `GET /v1/agent-runs/{run_id}`;\n- `404` for an unknown run;\n- `409` when the run belongs to a session this runtime is not hosting (for\n  example a separate terminal session); stop it from that session.\n\n## Session lifecycle (native UI supervision)\n\n| Operation | Endpoint |\n|---|---|\n| List sessions | `GET /v1/sessions` |\n| List session summaries | `GET /v1/sessions/summary` |\n| Get session | `GET /v1/sessions/{id}` |\n| Rename / archive session | `PATCH /v1/sessions/{id}` |\n| Delete session | `DELETE /v1/sessions/{id}` |\n| Session store repair summary | `GET /v1/sessions/repair` |\n| Resume into thread | `POST /v1/sessions/{id}/resume-thread` |\n| Create thread | `POST /v1/threads` |\n| List threads | `GET /v1/threads` |\n| Attach to events | `GET /v1/threads/{id}/events?since_seq=0` |\n| Send message | `POST /v1/threads/{id}/turns` |\n| Steer | `POST /v1/threads/{id}/turns/{turn_id}/steer` |\n| Interrupt | `POST /v1/threads/{id}/turns/{turn_id}/interrupt` |\n| Compact | `POST /v1/threads/{id}/compact` |\n\n## Compatibility tests\n\nContract snapshots live in `crates/protocol/tests/`. Run:\n\n```bash\ncargo test -p codewhale-protocol --test parity_protocol --locked\n```\n\nThis validates that the app-server's event schema hasn't drifted from the\ndocumented contract. CI runs this on every push to `main` and on release tags.\n\nThe app-server stdio control surface has its own drift guard — the advertised\n`capabilities` method set is pinned in `crates/app-server/src/lib.rs`:\n\n```bash\ncargo test -p codewhale-app-server capabilities\n```\n\nBefore a release, run the headless smoke (stdio probe + optional provider\nmatrix, no secrets leaked):\n\n```bash\nscripts/release/app-server-smoke.sh --matrix        # dry-run plan\nbash scripts/release/app-server-smoke.test.sh       # parser self-test (fake binary)\n```\n\n## Profile constitution\n\n`profile_constitution` in runtime capabilities enables profile snapshots on\n`POST /v1/threads/{id}/turns`. The optional `profile_constitution` field contains\n`{accountId, revision, constitution}`. `constitution` is exactly\n`{schemaVersion: 1, detail, initiative, collaboration, notes}`; choices are\n`brief|balanced|detailed`, `check|judgment|moving`, and `direct|critical|coach`.\nNotes are limited to 4,000 Unicode characters. Invalid data is refused.\n\nThe authenticated account transport supplies the snapshot, which participates in\nthe turn's replay identity. The Engine renders it through its existing personal\nconstitution renderer and records it in native session history. The snapshot is\nunchanged across provider retries and compaction. A new snapshot fully replaces\nearlier personal preferences. Permissions and approval policy are unaffected.\nInternal follow-ups and RLM child calls inherit the admitted preferences;\nthey do not re-read the host operator's account in the middle of that work.\n\nWithout a supplied snapshot, the Engine reads the signed-in profile from the\nconfigured account service at turn admission. When that Engine-loaded profile is\nunavailable or invalid, the turn uses the signed-out local constitution and shows\na notice once per session; an invalid host-supplied `profile_constitution` snapshot still fails\nthe turn, and `GET /v1/constitution` still reports the error. An account without saved\npreferences uses an explicit default snapshot; a signed-out account uses the\nexisting local constitution. Hosted transports always supply the owning\naccount snapshot, including defaults, so local preferences cannot leak between\naccounts.\n\n`GET /v1/constitution` reads the next-turn profile and its model guidance. It is\nnot a receipt that an active turn adopted the edit. `POST /v1/constitution/preview`\naccepts a constitution document and returns `{modelGuidance, saved:false}` without\nsaving anything. `modelGuidance` is the Engine-rendered guidance body: GET and\npreview return identical text for the same document. It excludes the session\nrecord's attribution line or envelope; `source` and `revision` carry provenance.\nBoth routes use normal Runtime authorization. Older runtimes\nmust be upgraded before account transports submit profile-bearing turns.\n"
+        "sha256": "f99a51fde05123158c3ee3b38fb10e3d83dd0c5c196de458e2e8e80ec8b55164",
+        "content": "# Codewhale Engine Runtime API & Integration Contract\n\n> 阅读简体中文版:[zh_hans/RUNTIME_API.md](zh_hans/RUNTIME_API.md)。\n\n`codewhale app-server` exposes the [Codewhale Engine](ARCHITECTURE.md) through\nits canonical local Runtime API and control plane.\nLocal SDKs, mobile/remote-control clients, and editor integrations talk to it\ninstead of screen-scraping terminal output. It serves the full HTTP/SSE runtime\nAPI (`/v1/*`), a JSON-RPC control transport over stdio, and the phone-friendly\nmobile page. `codewhale doctor --json` provides machine-readable health, and\n`codewhale serve --acp` speaks the Agent Client Protocol over stdio for editors\nsuch as Zed.\n\n`codewhale serve --http` / `serve --mobile` remain as **compatibility aliases**\nfor `codewhale app-server --http` / `--mobile`; both launch the identical\nserver. New integrations should target `app-server`.\n\n`codewhale exec` is the separate one-shot headless worker path (stream-json,\nfleet worker subprocess, CI primitive). It is not part of this API, but it\nshares the same runtime, provider/model resolution, permission profiles, and\nevent vocabulary.\n\nThis document is the stable integration contract for native workbench\napplications (and other local supervisors) that embed the Codewhale Engine.\n\n## Architecture\n\n```\nlocal supervisor / SDK / automation harness\n        │\n        ├─ codewhale app-server --http     → HTTP/SSE runtime API (/v1/*)        [canonical]\n        ├─ codewhale app-server --mobile   → runtime API + mobile control page\n        ├─ codewhale app-server --stdio    → JSON-RPC control transport over stdio\n        ├─ codewhale app-server --socket   → same JSON-RPC over a unix domain socket (desktop daemon)\n        ├─ codewhale doctor --json         → machine-readable health & capability\n        ├─ codewhale serve --acp           → ACP stdio agent for editors such as Zed\n        ├─ codewhale serve --mcp           → MCP stdio server\n        ├─ codewhale serve --http/--mobile → legacy aliases for `app-server --http/--mobile`\n        └─ codewhale exec [args]           → one-shot headless worker (stream-json)\n```\n\nThe engine runs as a local-only process. All APIs bind to `localhost` by\ndefault. No hosted relay, no provider-token custody, no secret leakage.\n\nFor the read-only record of what a thread or turn did, see\n[`docs/RECEIPTS.md`](RECEIPTS.md): `codewhale receipts` on the CLI and the\n`/receipt` routes under **Threads** below.\n\n## Runtime API entrypoints\n\n| Entry | Transport | Use |\n|---|---|---|\n| `codewhale web [--port 7878]` | HTTP/SSE on `127.0.0.1:7878` + embedded client | First-class loopback-only browser client; opens the default browser |\n| `codewhale app-server --http` | HTTP/SSE on `127.0.0.1:7878` | Full `/v1/*` runtime API (canonical) |\n| `codewhale app-server --mobile` | HTTP/SSE on loopback + `/mobile` | Runtime API + local mobile control page |\n| `codewhale app-server --stdio` | JSON-RPC 2.0 over stdio | Local SDK / control probe (no listener) |\n| `codewhale app-server --socket [--socket-path P]` | JSON-RPC 2.0 over a `0600` Unix domain socket (macOS/Linux) or an owner-only named pipe (Windows) | Desktop daemon: multi-client, kernel peer identity checked, `daemon/attach` claim handshake. Windows native-app qualification remains open. |\n| `codewhale app-server` | HTTP on `127.0.0.1:8787` | Legacy in-process app-server (`/healthz`, `/thread`, `/app`, `/prompt`); background jobs are `GET /v1/jobs` on the Runtime API; `/prompt` and `/thread` messages execute real turns via the runtime bridge. There is no direct `/tool` route: tools run only inside Engine turns, under the Engine's tool catalog and approval posture. This legacy server does not surface approvals: its bridge forwards only text deltas and the turn's completion, and it has no decision route, so an approval-gated call waits unanswered. Drive approval-gated work through the Runtime API (`/v1/threads/*` events and `POST /v1/approvals/{approval_id}`) |\n| `codewhale serve --http` / `--mobile` | same server as `app-server --http`/`--mobile` | Compatibility aliases |\n\n`app-server --http` and `--mobile` launch the same mature runtime API server\nhistorically reached through `serve --http` — no routes or behavior changed, so\nevery endpoint documented below is identical across both entrypoints. The\nruntime API token is read from `--auth-token`, then `CODEWHALE_RUNTIME_TOKEN`,\nthen `DEEPSEEK_RUNTIME_TOKEN`; use `--insecure-no-auth` only with a loopback\nbind. The `serve` compatibility aliases keep their `--insecure` flag.\nThe legacy in-process `codewhale app-server` also requires an explicit\n`--auth-token` or `CODEWHALE_APP_SERVER_TOKEN` before binding a non-loopback\nhost; its generated one-time `cwapp_*` token is loopback-only.\n\nDevice tokens minted by the master through `POST /v1/auth/client-tokens`\nhave an immutable `intent`: `watch` (the default when omitted) or explicit\n`drive`. Labels do not grant authority. Watch permits ordinary GET/HEAD reads,\nComputer display and one-use display tickets; it cannot mutate Runtime state,\nupgrade a protected HTTP read into a write channel, acquire/release control,\nor forward display input. Drive retains the existing Runtime/control authority\nbut cannot mint, list or revoke device tokens. Display tickets retain the\nissuing principal's intent; input still requires its current, live control lease.\n\n`GET /v1/runtime/info` advertises `capabilities.client_token_intents: true`,\nand the mint receipt returns `device_id`, `intent` and `expires_at`. A relay\ngrant issuer must require this capability before minting and validate that\nreceipt against the requested device/intent before exposing a token. Older\nEngines lack enforcement and must refuse relay grants through this issuer;\nan intent-like label or a successful legacy mint is insufficient. This change\ndoes not add account/Computer ownership scopes to Engine-local device tokens.\n\n### Workspace file suggestions\n\n`GET /v1/workspace/files/search?query=runtime&limit=20` returns\n`{\"paths\":[\"src/runtime.rs\"]}` through the existing authenticated `/v1/*`\nrouter. It searches only the server's configured workspace, not a thread's\nworkspace or the process's current directory. No workspace/path override is\naccepted. The response contains workspace-relative file paths with `/`\nseparators, never file contents, absolute paths, or directories.\n\n- `query` is a literal partial filename/path, without an `@` prefix, at most\n  256 UTF-8 bytes. Missing, empty, or whitespace-only queries return an empty\n  list without walking the filesystem. No match also returns an empty list.\n- `limit` defaults to 20; accepted values are 1–100. Invalid limits, oversized\n  queries, and unknown query parameters return HTTP 400.\n- Matching reuses TUI fuzzy `@file` discovery/ranking: case-insensitive path\n  prefix matches first, then substring matches, alphabetically within each\n  group. This is not glob, subsequence, content, or semantic search, and does\n  not apply the TUI's personal frecency boosts.\n- Discovery shares the composer's ignore policy, including `.ignore` and\n  `.deepseekignore`, always-discoverable AI directories, and the bounded\n  hidden/gitignored local-reference fallback. The special `.agents`, `.claude`,\n  `.cursor`, and `.deepseek` walks intentionally bypass ignore rules, as in\n  the TUI. Ignore files are not confidentiality boundaries.\n- Directory symlinks are not traversed. Files are canonicalized and filtered\n  for containment in the workspace before applying the result limit; external\n  and broken file symlinks are omitted. In-workspace file symlinks may appear\n  by their relative names. Suggestions are a filesystem snapshot, not\n  authorization to read a file later; consumers must revalidate when opening it.\n\nDiscovery runs off the async executor, with the shared default depth of 10,\nat most 20,000 candidates, and a cooperative two-second discovery budget.\nResults are best-effort, not an exhaustive listing; a slow filesystem operation\ncan finish after that budget. Each request scans anew; there is no new index or\ncache. This read-only endpoint does not alter sessions or the pinned model\nprompt/tool prefix.\n\n### Workspace files and session artifacts\n\nNative clients (the GPUI desktop's Files and Preview modules) browse and edit\nthe server's configured workspace through three authenticated routes. They\nread and write the workspace directly; there is no second file store, cache\nor index, and no path override: the workspace root is the only root.\n\n- `GET /v1/workspace/files?path=&limit=<1-2000>` lists one directory.\n  `path` is workspace-relative with `/` separators; empty or `.` is the root.\n  Each entry carries `name`, `path`, `kind` (`file`, `directory`, `symlink`,\n  `other`), and for files `size` and `modified` (RFC 3339). Directories sort\n  first, then names case-insensitively. `limit` defaults to 200; `truncated`\n  reports a cut. `.git` is never listed or served, and symlinks are listed by\n  name only: they are never followed, so `path=` returns 403.\n- `GET /v1/workspace/files/read?path=&offset=&limit=<1-4194304>`\n  returns one byte window of a regular file with `size`, `revision` (the\n  SHA-256 hex of the **whole** file, not of the window), `modified`,\n  `offset`, `bytes`, `truncated`, `encoding` and `content`. Text windows are\n  `utf-8`; a window with a NUL byte, invalid UTF-8, or a split multi-byte\n  character is `base64`. `limit` defaults to 256 KiB. Files above 16 MiB are\n  refused with 413; a directory is 400; a link is 403; a missing file is 404.\n- `PUT /v1/workspace/files` with `{\"path\", \"content\", \"encoding\"?,\n  \"expected_revision\"?}` writes one file atomically through the same confined\n  opener Fleet artifacts use. `encoding` is `utf-8` (default) or `base64`;\n  bodies above 4 MiB are 413. Creating a new file requires **no**\n  `expected_revision` (and creates missing parent directories inside the\n  workspace); overwriting requires the `revision` from the read that the\n  edit was based on, and a stale or missing one is 409 with the current\n  revision in the error message so the client can re-read and merge. This is\n  optimistic concurrency, not a lock: two writers racing between the check and\n  the write can still interleave. The response carries `path`, `size`,\n  `revision`, `created` and `written_at`; 201 for a new file, 200 otherwise.\n  Writes through a link, into `.git`, or to a directory are refused.\n\nEvery path is validated before any filesystem access: absolute paths,\nbackslashes, `.` or `..` components are 400, and each directory on the way is\nopened without following links (`O_NOFOLLOW` per component on Unix, reparse\npoint checks on Windows). These routes use the runtime bearer token like every\nother `/v1/*` route; they do not consult the model's tool permission posture,\nbecause the caller is the authenticated operator, not the model.\n\nSession artifacts are the oversized tool outputs a session recorded as\n`ArtifactRecord`s (`crates/tui/src/artifacts.rs`), stored under\n`sessions//artifacts/`:\n\n- `GET /v1/sessions/{id}/artifacts` lists the records a saved session carries:\n  `id`, `kind`, `tool_call_id`, `tool_name`, `created_at`, `byte_size`,\n  `preview` and the session-relative `path`.\n- `GET /v1/sessions/{id}/artifacts/{artifact_id}?offset=&limit=` reads one\n  artifact with the same window, `revision` and `encoding` contract as the\n  workspace file read. A record whose stored path is absolute or leaves the\n  session directory is 403; a record whose file is gone is 404.\n\nThese routes only serve what a SavedSession indexes (plus immutable image\nevidence). A runtime turn's spills are read through the turn route below. An\nunbound runtime thread's engine has no SavedSession index at all, so for its\nspills the turn record is the only way in.\n\n#### Turn artifacts\n\nA turn records what it produced as typed references on its items and on the\nturn itself. Nothing is scanned to build them. Each fact is recorded where the\nbytes were written:\n- file tools and `apply_patch` report `size`/`sha256` in `mutation.files[]`;\n- spills report `artifact_digest`;\n- tool media reports `sha256`.\n\nThe workspace-level half comes from the turn's own restore points: the\n`pre_turn` and `post_turn` receipts in `TurnRecord.workspace_snapshots` (see\n\"Workspace restore points\" below), diffed by tree id in the existing side\nrepo. No second store, snapshot or event is involved.\n\nA reference (`TurnArtifactRef`) carries:\n\n| Field | Meaning |\n| --- | --- |\n| `id` | Stable within the turn. A file's id is `file_` plus the first 32 hex digits of SHA-256(path). A spill's is `art_`. Media's is `art_image_`. |\n| `kind` | `file`, `tool_output` or `media`. |\n| `path` | For `file`: workspace-relative with `/` separators. For `tool_output` and `media`: session-relative (`artifacts/...`). |\n| `change` | For `file` only: `created`, `updated`, `deleted` or `renamed`. A rename also has `previous_path`. |\n| `size` | Byte size. Absent when the file was deleted. |\n| `revision` | SHA-256 hex of the whole content. This is the value `GET /v1/workspace/files/read` reports as `revision`, and file-revert's `expected_hash` is `sha256:` + `revision`. Absent when the file was deleted, or for a delta blob over 16 MiB. |\n| `content_type` | For `media`: the exact media type. |\n| `session_id` | For `tool_output` and `media`: the artifact session that owns the bytes. |\n| `item_id`, `tool_call_id`, `tool_name` | The tool call that wrote it. Absent for a change seen only in the workspace delta. |\n| `source` | `tool_mutation`, `tool_output_spill`, `tool_media`, or `workspace_changed_during_turn`. |\n| `restore_snapshot_id` | A restore point `POST /v1/threads/{id}/file-revert` accepts for this path on this thread: the `tree_id` of a receipt recorded on this turn's `workspace_snapshots`, so the thread owns it whether or not it is bound to a saved session. For a tool write it is the call's `tool` receipt; for a delta change it is the turn's `pre_turn` receipt. Absent when the turn recorded no such receipt. |\n| `recorded_at` | When the reference was recorded. |\n\nWhere references appear:\n- **Items.** `TurnItemRecord.artifacts` lists what one tool call produced. It\n  is set when the call completes, succeeded or failed, so `item.completed` and\n  `item.failed` carry it live.\n- **Legacy projection.** `artifact_refs` is derived from `artifacts`. It holds\n  only the workspace-relative paths of files that still exist: never spills,\n  media or deleted files.\n- **Turns.** `TurnRecord.artifacts` is the turn aggregate. It is computed by\n  one merge and ordered most recent first.\n  - Spill and media refs are always kept.\n  - File refs compose in item order: created then deleted drops the file,\n    created then updated stays `created`, and a rename folds its origin.\n  - Once the workspace delta settles, it is authoritative for the net change,\n    `size` and `revision` of every path the snapshots can see. It adds files\n    no tool receipt named, such as shell and sub-agent writes. It drops an item\n    path the snapshots track but that ended the turn unchanged.\n  - The aggregate is capped at 1000 refs, and `workspace.truncated` /\n    `workspace.omitted` report the cut.\n\n`TurnRecord.workspace` follows the delta's lifecycle. It is `null` while the\nturn runs. `turn.completed` carries it with one of these states:\n- `pending`: the turn recorded both a `pre_turn` and a `post_turn` receipt,\n  and their diff is still running. When it finishes, the runtime publishes\n  `turn.artifacts`\n  (`{turn_id, workspace, artifacts}`). That event may arrive after the next\n  turn's `turn.started`, so key it by `turn_id`.\n- `settled`: the delta is merged. `pre_turn_snapshot_id` and\n  `post_turn_snapshot_id` are the tree ids of the pair.\n- `unavailable`: no delta will come. `reason` says why:\n  - `snapshots_disabled`, `workspace_too_large`, `too_many_files`,\n    `unsafe_location`, `snapshot_failed`: the snapshot gates. A turn with a\n    `pre_turn` receipt but no `post_turn` one keeps `pre_turn_snapshot_id`.\n  - `not_captured`: the turn recorded no restore point: a compaction or purge\n    operation, or a turn whose engine ended before taking one.\n  - `runtime_restarted`: the process stopped before the delta settled. This is\n    reconciled at startup and never recomputed.\n  - `delta_failed`: the diff itself failed.\n\n  `artifacts` still holds what the tool receipts recorded. `turn.artifacts` is\n  published for every settlement outcome, so a client waiting on `pending`\n  always hears back.\n\nWhat the delta means, and what it cannot see:\n- **It is a workspace diff, not attribution.** A delta change is everything\n  that changed in the workspace while the turn ran. That includes an editor,\n  another thread, or a background job writing the same workspace at the same\n  time. `source: workspace_changed_during_turn` says exactly that.\n- **Excluded paths are invisible to snapshots.** This covers the built-in\n  excludes (for example `node_modules/`, `target/`, `dist/`, `build/`,\n  `.next/`, and binary and media extensions) and the workspace's `.gitignore`. A file tool's write to\n  such a path is still reported from its receipt. A shell command's write to\n  one is not reported at all.\n- **Shell writes have no per-call record.** A shell command's writes are\n  never attributed to its item, only to the turn, and only when snapshots are\n  enabled.\n\nRoutes:\n\n- `GET /v1/threads/{id}/turns/{turn_id}/artifacts` returns\n  `{thread_id, turn_id, workspace, artifacts}` from the runtime store's turn\n  record. While the turn runs, `artifacts` is merged from its items on the fly\n  and `workspace` is `null`. An unknown turn, or a turn of another thread, is\n  404.\n- `GET /v1/threads/{id}/turns/{turn_id}/artifacts/{artifact_id}?offset=&limit=&revision=`\n  reads one reference.\n  - It uses the workspace file read's window contract (`size`, `revision`,\n    `offset`, `bytes`, `truncated`, `encoding`, `content`) and adds:\n    - `artifact`: the reference;\n    - `source`: `workspace`, `snapshot` or `session_artifact`;\n    - `current`: whether the workspace still holds these bytes, or `null`\n      when that is not a question for this reference.\n  - `revision` selects an intermediate revision one of the turn's items\n    recorded. The default is the reference's own revision.\n  - A `file` is served from the workspace when it still holds the recorded\n    revision (`current: true`). Otherwise it comes from the turn's post-turn\n    snapshot (`current: false`).\n  - A `tool_output` or `media` reference is read under the session artifact\n    root the writer used. The same confinement, image-manifest and integrity\n    checks apply as for the session route.\n- `GET /v1/threads/{id}/turns/{turn_id}/calls/{tool_call_id}/changes?limit=`\n  returns what **one** tool call changed, read from the same two restore\n  points the engine recorded around it (the call's `tool:` receipt and its\n  `post-tool:` partner on this turn):\n\n  ```json\n  {\n    \"thread_id\": \"thr_1a2b3c4d\", \"turn_id\": \"turn_…\", \"tool_call_id\": \"call_…\",\n    \"tool_name\": \"exec_shell\",\n    \"state\": \"captured\", \"reason\": null, \"truncated\": false,\n    \"files\": [\n      {\n        \"path\": \"out/result.json\", \"change\": \"created\",\n        \"added\": 12, \"removed\": 0, \"size\": 210, \"revision\": \"\",\n        \"restore_snapshot_id\": \"
\",\n        \"diff\": \"@@ -0,0 +1,12 @@\\n+{}\\n\", \"diff_truncated\": false\n      }\n    ]\n  }\n  ```\n\n  - This is the per-call counterpart of the turn aggregate, and the only\n    surface on which a **shell command's own writes** are attributable to that\n    command: a command produces no `metadata.mutation`, so nothing else names\n    what it wrote. The files a file tool changed are here too, read from the\n    same span.\n  - `change` is `created`, `updated` or `deleted` (git's `A`/`M`/`D`, with a\n    type change read as `updated`; the diff runs with `--no-renames`, so a move\n    is a delete plus a create). `added`/`removed` are `null` for a binary path.\n  - `size` and `revision` are the span's **end**, never the work tree as it is\n    now. `revision` is bare SHA-256 hex; pass `sha256:` followed by that hex as\n    `file-revert`'s `expected_hash`, or `absent` for a path deleted by the call.\n    Both are `null` when the path was deleted here or is too large to read.\n  - `diff` is the patch between the two restore points, cut at 64 KiB on a char\n    boundary (`diff_truncated` says so). It is `null` when there is nothing to\n    render: a binary path, a change with no content delta, or no patch.\n  - `state: \"pending\"` means the recorded call is queued or in progress and\n    its snapshot pair is incomplete. `reason` is `null` and `files` is empty;\n    read again after settlement. Once both receipts exist, the state is\n    `captured`, including when the call changed nothing.\n  - `state: \"unavailable\"` means the settled span cannot be resolved, and `reason`\n    says why: `call_not_bounded` (the call took no receipt — the engine judged\n    it read-only, or the turn predates receipts), `post_snapshot_missing` (the\n    opening receipt exists and the closing one was lost), `pre_snapshot_missing`,\n    or `snapshots_pruned` (the receipts are still on the turn, but the side repo\n    no longer holds the trees they name — snapshots are pruned to the newest few\n    while turn records are durable, so an older turn's span is regularly\n    unrecoverable, and a workspace whose store was deleted reads the same way).\n    This is **not** the same answer as an empty `files` list, which means the\n    span changed nothing, and the receipt's own `changed_paths` remain readable\n    on the turn record either way.\n  - `limit` (default 200, max 1000) caps the list; `truncated` says it was cut,\n    and how many paths were left off is not counted.\n  - Reading the span runs `git diff` inside the side repo. Neither the work\n    tree nor the index is touched.\n\n| Status | When |\n| --- | --- |\n| 404 | Unknown thread or turn, a turn of another thread, or a `tool_call_id` this turn has no item or receipt for. |\n| 400 | `limit` outside `1..=1000`. |\n| 500 | A runtime item record could not be read or parsed, or an operational failure occurred reading the snapshot repository; this is not evidence that the call was unbounded or snapshots were pruned. |\n\nThe artifact routes answer:\n\n| Status | When |\n| --- | --- |\n| 404 | Unknown thread, turn or artifact id, or a `revision` this turn never recorded. |\n| 409 | A file's recorded revision is in neither the workspace nor the snapshot store (snapshots are pruned after 50 per workspace or 7 days), or a session artifact's bytes no longer hash to the recorded revision. The message names the current revision. |\n| 410 | The turn deleted the file (restore it with `file-revert` and `restore_snapshot_id`), or a session artifact's bytes were pruned. |\n| 413 | Content over 16 MiB. |\n| 403 | A symlink, or a reference that leaves its root. |\n\nFleet receipt artifacts keep their own route\n(`GET /v1/fleet/runs/{run_id}/receipts/{task_id}/evidence`).\n\n### Runtime and account identity\n\n`GET /v1/runtime/info` reports `codewhale_version` plus the full 40-character\n`codewhale_commit` embedded by the shared CLI/TUI build. A source archive that\ncannot provide an exact commit reports `unknown`, allowing compatibility\nclients to fail closed rather than accepting an ambiguous binary pair.\n\nThe same response advertises `capabilities.account_session: true` and\n`capabilities.turn_operation_idempotency: true`. A client must require the\nlatter before relying on `operation_key`; do not infer support from a 2xx turn\nresponse because an older tolerant reader may ignore an unknown request field.\n`capabilities.turn_operation_lookup: true` separately advertises the read-only\noperation lookup below; clients must require it before relying on GET-based\nrecovery of a lost turn response.\nThe response also includes a token-free account receipt:\n\n```json\n{\n  \"account\": {\n    \"schema_version\": 1,\n    \"state\": \"authenticated\",\n    \"api_base\": \"https://api.codewhale.net\",\n    \"account_id\": \"acct_...\",\n    \"session_id\": \"session_...\",\n    \"scopes\": [],\n    \"expires_at\": \"2026-08-01T20:00:00Z\"\n  }\n}\n```\n\nThe Runtime reads this receipt from the exact profile- and API-origin-scoped\nsecure record written by `codewhale account login`; it does not run a second\nlogin flow. States are `signed_out`, `authenticated`, `offline_cached`,\n`expired`, or `revoked`. Scopes are copied only from explicit stored session\ngrants and are never inferred from account identity. Access/refresh tokens,\nemail, provider profile, and provider credentials are never returned.\n`account_id` and `session_id` are included only for a request authorized with\nthe Runtime token (or an explicitly insecure loopback server); the public\nbootstrap response remains usable but reports `signed_out`. Signed-out local\nWork remains supported and never allocates cloud compute implicitly.\n\nThe `--stdio` control transport is newline-delimited JSON-RPC 2.0. Probe it\nwithout spending model tokens:\n\n```bash\nprintf '%s\\n' \\\n  '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"healthz\"}' \\\n  '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"capabilities\"}' \\\n  '{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"shutdown\"}' \\\n  | codewhale app-server --stdio\n```\n\n`capabilities` returns the advertised method families (`thread/*`, `app/*`,\n`prompt/*`) and the full method list; `thread/capabilities`,\n`app/capabilities`, and `prompt/capabilities` scope it per family. The method\nset is pinned by a drift test in `crates/app-server/src/lib.rs`, so SDK and\nlocal integration clients can rely on it not changing silently.\n\n### Daemon socket: `codewhale app-server --socket`\n\nThe desktop shell (DESKTOP-APP-BRIEF §2) attaches to a long-lived daemon over\na unix domain socket. The wire is the `--stdio` transport verbatim — the same\nnewline-delimited JSON-RPC 2.0 methods, dispatched by the same code — with one\nhandshake in front of it.\n\n> Note (2026-09-14): the Tauri desktop shell named above is retiring under the\n> 2026-09-14 product-client transition, and the DESKTOP-APP-BRIEF reference is\n> a dangling pointer (that brief does not exist in this repo). The GPUI client\n> in the private `codehwhale-gpui` repo is the successor daemon consumer over\n> this HTTP runtime API; the socket protocol described here is unchanged.\n\n**Endpoint.** `--socket-path` if given; else `$CODEWHALE_HOME/run/daemon.sock`\nwhen `CODEWHALE_HOME` is set (an explicit home is an isolation boundary); else\n`$XDG_RUNTIME_DIR/codewhale/daemon.sock`; else\n`~/Library/Application Support/codewhale/daemon.sock` on macOS or\n`~/.codewhale/run/daemon.sock` elsewhere. The directory is created `0700`, the\nsocket is `0600`, and every accepted peer must present the daemon's own uid.\nOn start, a socket file nobody answers on is removed; a live one makes the new\ndaemon exit with `a live listener already answers on ; refusing to\nreplace it`; a non-socket file at the path is never touched. On Windows `--socket` fails with a typed\n`UnsupportedPlatform` error naming the reserved pipe `\\\\.\\pipe\\codewhale-daemon`\n— there is no silent TCP fallback. The daemon prints\n`codewhale daemon: listening on ` to stderr once it is accepting.\n\n**Handshake.** The first request on a connection must be `daemon/attach`\n(`healthz` is also allowed beforehand, so a shell can probe liveness). Every\nother method is refused with `-32010 attach_required` until then.\n\n```json\n{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"daemon/attach\",\"params\":{\n  \"client\":{\"name\":\"codewhale-desktop\",\"version\":\"1.2.3\",\"pid\":4242},\n  \"mode\":\"claim\",\n  \"expect_daemon_version\":\"0.9.11\"}}\n```\n\n`mode` is `\"claim\"` (this client spawned the daemon and manages its lifetime)\nor `\"attach\"` (default: a guest that found a healthy daemon). A claim while\nanother connection owns the daemon fails with `-32011 daemon_already_claimed`\n(`data.owner` names the holder) and the client should retry with `attach`.\n`expect_daemon_version`, when present, must equal the daemon's crate version\nor the attach fails with `-32013 daemon_version_skew` (the bundle-skew guard).\nThe reply reports the granted `role` (`owner` / `attached`), the daemon's\n`pid`, `version`, `socket_path`, and `uptime_ms`, the current `owner`, and the\nlive `connections` count. A second `daemon/attach` on an attached connection\nis `-32014 already_attached`.\n\n**Capabilities.** On this transport `capabilities.methods` is the pinned stdio\nset plus `daemon/attach` (second entry, after `healthz`); `transport` reads\n`unix-socket`. `shutdown` is advertised to every connection because the method\nexists, but only the owner may call it (below).\n\n**Ownership.** Only the owner may `shutdown`; a guest's `shutdown` is refused\nwith `-32012 not_daemon_owner` and does not interrupt anyone's turn. When the\nowner disconnects the slot frees, so a relaunched shell re-claims the daemon it\nleft running. The owner's `shutdown` stops the listener, closes every\nconnection, and removes the socket file. Journal replay from a client's\nlast-seen `seq` is not part of this transport yet.\n\n### Interrupting a turn\n\n`thread/message` streams until the turn reaches a terminal state, which can\ntake minutes. The read loop keeps polling stdin while a turn streams, so a\nclient can send:\n\n```json\n{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"thread/interrupt\",\"params\":{\"thread_id\":\"thr_...\"}}\n```\n\nand the runtime is asked to interrupt that turn\n(`POST /v1/threads/{id}/turns/{turn_id}/interrupt`). The reply carries\n`interrupted: false` when no turn is streaming for that thread — this is not\nan error, just nothing to stop. The interrupted `thread/message` then fails\nwith a `turn interrupted` error, and its reply is written before the\ninterrupt's own reply, since the turn owns the writer until it unwinds.\n\n`shutdown` sent during a live turn also interrupts first: it needs the same\nbridge that the turn holds, so without that it would wait for the very turn\nit was meant to stop. Other requests that arrive mid-turn are queued and run\nin order once the turn finishes.\n\n### Running a prompt\n\n`prompt/request` and `prompt/run` (byte-identical aliases) and the legacy\nHTTP `POST /prompt` all execute a **real turn** on the runtime, through the\nsame bridge `thread/message` uses. There is no local fallback: nothing else\nin the app-server can produce model output, so a prompt either runs or fails.\n\n- `params.prompt` is required and must be non-empty (`-32602` otherwise).\n- `params.thread_id` is optional. With one, the prompt runs on that thread and\n  its history. Without one, the runtime gets a fresh thread for that single\n  turn; the mapping is dropped when the turn ends, so a one-shot prompt is not\n  addressable by `thread/interrupt`. Use `thread/message` when you need to be\n  able to interrupt.\n- `params.model` selects the model only when the call is the one that creates\n  the runtime thread; an existing thread keeps the model it was created with.\n- The response carries what the model actually said: `output` is the\n  concatenated `agent_message` text, `model` is the model the runtime reports\n  for the thread that ran it, and `events` are the real\n  `response_start`/`response_delta`/`response_end` frames. Over stdio the same\n  frames are also streamed to stdout while the turn runs, exactly as for\n  `thread/message`.\n- If the runtime cannot be reached, the call fails with `-32005`\n  (`runtime_unavailable`) on stdio, or HTTP `503` with\n  `{\"error\":{\"code\":\"runtime_unavailable\", ...}}` on `POST /prompt`. Failures\n  are never shaped like a successful `PromptResponse`.\n\n`POST /thread` with a `Message` body behaves the same way — it runs the turn\nand replies `status: \"completed\"` with the streamed frames in `events` — where\nit previously replied `accepted` without doing anything.\n\n### Thread ids and restarts\n\n`thread/message`, `thread/request` messages, and HTTP `POST /thread` messages\ntake a thread id from `thread/create` (or `thread/fork`). An id that was never\ncreated fails with `-32004` (`thread_not_found`) on stdio, or HTTP `404` on\n`/thread`, before any runtime thread is started. `/prompt`, `prompt/request`,\nand `prompt/run` are different: their optional `thread_id` is any key the\ncaller chooses, and a new key starts a new conversation.\n\nThe authenticated canonical owner keeps the full saved conversation graph,\nits selected branch and the thread/session binding. Compatibility controls use\nthat owner; the old SQLite history remains a protected, read-only import source.\nImport compares the complete source graph and selected leaf before publishing\nthe canonical alias. A failed alias publication retains the source and the\nactual canonical result so recovery can report what completed.\n\nExisting validated legacy goals are imported into the owner goal store. Active\ngoals are paused during import; historical data never starts a provider call.\nSource goal fields participate in the same protected source comparison.\n\nSaved-session forks preserve the complete journal, including inactive branches,\nand copy a validated local session-goal sidecar into the new saved session.\nAn active local goal is copied as paused; the source remains unchanged.\nThat sidecar is separate from the public Runtime thread goal. A native Runtime\nthread fork does not automatically inherit the public thread goal.\n\n`thread/create`, `thread/start`, `thread/resume` and `thread/fork` carry a\nclient-generated `operation_key`. Capture one key for each user intent before\nsending it, retain it after an uncertain response, and reuse it when recovering\nthat same intent. Create carries the key in `metadata.operation_key`; Start,\nResume and Fork carry it in `operation_key`. Two intentional forks use different\nkeys. Recovery checks\nthe original operation in the existing owner store; it never creates another\nthread merely because the response was lost or the source later grew.\n\n`POST /v1/thread-history/operations/lookup` is read-only. Its closed request\ncontains `version: 1`, `operation_key`, `expected_data_dir`,\n`expected_execution_scope` and `workspace`; the response is `absent`, `pending`\nor `committed`. A pending or committed response includes the retained receipt\nand its exact action/source `association`.\n\n`POST /v1/thread-history/operations/recover` accepts that lookup request in\n`operation` and the expected `association`. It explicitly finishes an already\nprepared target under the same owner, after checking the saved document, full\ngraph, workspace, checkpoint and action/source identity. It does not rebuild\nthe original intent from a source that may already have changed. An unprepared\ntarget stays pending; a changed or unverifiable target refuses completion.\nRepeat recovery with the same key to observe the same committed result.\n\nThe selected workspace comes from the acknowledged owner or the explicitly\nadmitted request. History and historical receipts cannot supply permissions,\ncredentials, endpoints or a different owner. A missing, changed, busy or\nincompatible bound store is an explicit failure. A missing canonical target\ndoes not start an empty replacement conversation.\n\n`codewhale thread resume` and `codewhale thread fork` perform the durable\nowner control and print its committed thread, session and operation receipt.\nThey do not launch an interactive frontend.\n\nGlobal `--workspace` (also `--cd`), `--profile` and `--config` select an explicit\ncontrol scope. Relative paths are captured before attachment; the client\nauthenticates the owner, then admits that scope against the same owner receipt\nand its captured worker setting. An incompatible profile or config, missing\nscope facts, or a changed owner fails explicitly. Without these options, the\nacknowledged owner supplies the workspace. Thread listing remains store-wide.\n\nFor a fresh `thread resume` or `thread fork`, global `--provider`, `--model`,\n`--approval-policy` and `--sandbox-mode` supply proposals to the existing owner\ndecoder and permission checks. The corresponding `--set` keys are `provider`,\n`model`, `default_text_model`, `approval_policy` and `sandbox_mode`. Credentials\nand endpoints stay with the owner: `--api-key`, `--base-url` and other per-run\nsettings are refused for these controls. Configure and authenticate the owning\nRuntime before using them. With a retained `--operation-key`, newly supplied\nmodel, provider, policy or sandbox proposals are refused; recovery observes\nthe original admitted intent.\n\nInteractive `codewhale resume` and `codewhale fork` use the same canonical\nhistory operation. An inactive local owner is shut down and joined before the\nexisting TUI acquires the saved-session lease and store. A live owner or an\nuncertain handoff refuses attachment. `--operation-key ` recovers the\noriginal outcome, including completing a verified prepared target; an\nunprepared or unverifiable result preserves uncertainty.\n\n### Changing config\n\n`app/config/set` and `app/config/unset` write the change to the config file\nbefore replying: the `--config` path, or the default `config.toml` used by the\ncanonical owner when no `--config` is given. They change user settings that\noutlive the app-server, not just this session. The change is applied to the\nfile as it is on disk, so edits saved by other processes are kept. If the file\ncannot be read, parsed, or written, the reply is `ok: false` and nothing\nchanges; a file that does not parse is not rewritten, so fix it by hand (or\n`app/config/reload` after fixing it). Over HTTP `/app`, a rejected key or value\nis `400` and a read or write failure is `500`.\n\n### Answering a clarification question\n\nWhen a headless turn calls `request_user_input`, the runtime emits a\n`user_input.required` event carrying a `request_id`. Reply on the runtime API:\n\n```\nPOST /v1/user-input/{thread_id}/{request_id}\n```\n\nThe app-server control transport cannot accept that reply.\n`app/request` with `SubmitUserInput` returns `ok: false` and\n`error: \"user_input_reply_unsupported\"`. This is a property of the transport,\nnot an omission: while a turn is streaming, the stdio loop executes only\n`thread/interrupt` and queues everything else, so an answer sent there would\nwait on the very turn that is waiting for it.\n\n## SDK contract\n\nThe app-server exists so an external SDK can answer — without scraping TUI\noutput — *what route ran, which provider/model/reasoning/permission profile was\neffective, what events happened, how many tokens were used, and how the run\nfinished.* The durable Thread/Turn/Item data model already carries most of\nthis; the table maps each integration need to where a local client reads it.\n\n| Integration need | Where it comes from | Status |\n|---|---|---|\n| Route / effective model / billing surface | `TurnRecord` + thread `model`; per-run `--provider`/`--model` overrides | available |\n| Permission / sandbox / approval profile | thread `auto_approve`, sandbox + approval policy; `TurnRecord.permission_posture` + `TurnRecord.mode` for how *that* run was governed (the thread's own `mode` may have been switched since) | available |\n| Run / thread / turn IDs | `thread_id`, `turn_id`, SSE event envelope | available |\n| Event stream | `GET /v1/threads/{id}/events` (replay + live SSE) | available |\n| Turn status / terminal classification | `TurnRecord.status` + error summary | available |\n| Token usage | `TurnRecord.usage`; aggregate via `GET /v1/usage` | available |\n| Action receipt (files, commands, web/MCP calls, agents, approvals and who decided, failures) | `GET /v1/threads/{id}/receipt`, `GET /v1/threads/{id}/turns/{turn_id}/receipt` | available ([RECEIPTS.md](RECEIPTS.md)) |\n\nFor one-shot/headless automation, prefer `codewhale exec` with explicit\n`--provider  --model ` so a failure identifies the exact provider/model\npair. Use `app-server` when a local integration needs to start, resume, steer,\nor interrupt turns, list models/capabilities, follow the event stream, or read\nusage. Both paths share the same runtime, so route-effective model resolution\nand the event vocabulary match.\n\n### Release smoke\n\n`scripts/release/app-server-smoke.sh` is the committed pre-release check:\n\n```bash\nscripts/release/app-server-smoke.sh                 # stdio health/capabilities probe (no tokens)\nscripts/release/app-server-smoke.sh --matrix        # + print the configured provider/model matrix\nscripts/release/app-server-smoke.sh --matrix --real # + exec a cheap sentinel per provider\n```\n\nThe stdio probe runs against a throwaway config, so it never reads real keys.\nThe matrix discovers configured providers from `codewhale auth list`, skips\nunconfigured providers, and maps a provider to a cheap sentinel model only when\nit has a built-in cheap default. That built-in set is deliberately conservative\n(currently `deepseek`, `zai`, `moonshot`, and `openai`); every other provider —\nincluding `arcee`, `openrouter`, `xiaomi-mimo`, and `openai-codex` — is left\nunmapped on purpose and must be given a model per run via `SMOKE_MODEL_`\nrather than a guessed default (#3205). Any configured-but-unmapped provider\nfails loudly in `--real` mode. `auth list` reports presence flags only and exec\noutput is passed through a redactor, so secrets are never printed. The parser is\ncovered by `scripts/release/app-server-smoke.test.sh` against a fake `codewhale`\nbinary.\n\n## ACP stdio adapter: `codewhale serve --acp`\n\nACP JSON-RPC over newline-delimited stdio is a transport over the existing\nRuntimeThreadManager and Engine. It has no provider/tool round loop, executable\nregistry, prompt composer, or conversation writer of its own. The server loads\nthe selected config/profile and plugin discovery once; each prompt uses a\ncanonical thread, Core turn, event timeline, approval waiter, and full Engine\nsession snapshot.\n\nThe editor surface supports `initialize`, `session/new`, `session/list`,\n`session/load` (including durable ID prefixes), `session/prompt`, `session/cancel`,\nmodel discovery/selection, and the advertised mode/model config options.\n`session/new` creates a durable bare UUID and empty checkpoint without calling a\nprovider. At most 64 idle transport bindings are retained; eviction keeps the\ndurable conversation. Resume deduplicates against the existing thread binding.\nFull histories, tool pairs, signatures, media and partial-effect receipts are\nsaved under the same checkpoint guard and session write lease used by HTTP.\nACP display text may be shortened; the saved history is the full Core snapshot.\n\nThe trusted local ACP profile narrows Core to file/search/git/patch and admitted\nforeground shell tools. Shell requires both editor terminal support and operator\n`allow_shell`; a requested external sandbox that is unavailable removes shell.\nMCP, dynamic tools, task/PTY/background shell, interpreter, subagent and RLM\nlifecycles are unavailable on this transport. Built-in overrides remove the\nwhole compatibility alias family. Final dispatch rechecks the profile, so a\nfabricated alias, hook rewrite or omitted catalog entry cannot bypass it. Plan\nremains read-only under Full Access. Full Access and the ordinary approval\nposture are server-owned read-only options; the editor cannot widen them.\n\nTool updates begin `pending`. `in_progress` means Core reached final dispatch;\n`completed`/`failed` and typed image blocks come from the actual Core result.\n`session/request_permission` correlates a private JSON-RPC ID to the same\nRuntime-minted pending approval and Core execution ID. Only an exact live\n`allow-once` response can release that waiter; wrong IDs are ignored, invalid\noptions deny, and cancellation withdraws it. ACP grants neither remembered\npermissions nor Native capabilities. Core typed rules, strict hooks, repo law,\nHeadless Auto-Review and hard floors remain in force, including the absence of a\nworkspace-write carve-out; a guardian-only decision refuses on this host.\n\nReplay uses the existing bounded event reader and sequence deduplication. A\nreplay gap, closed owner or terminal store fault is an explicit error, never a\nre-execution. Input is serviced between replayed events; each transport write has a 30-second\ndeadline. Cancel, EOF and writer\nfailure interrupt only this transport's claimed Core turn; settlement uses its\nactual terminal receipt and preserves any completed effects. An unconfirmed\ncancellation does not fabricate success. `stopReason` is `end_turn`, `cancelled`,\nor typed `max_turn_requests`; Core failure remains an error. Prompts use at most\n50 model steps (a lower configured limit still wins) and Core's bounded final\nreport response; this profile does not dispatch autonomous goal continuations.\n\nACP presently claims its own exclusive canonical Runtime owner. If another\nprocess holds that store, startup refuses. A saved conversation bound to another\nRuntime store also refuses; authenticated cross-process owner attachment remains\nunqualified and ACP does not copy a live conversation into a random store. This\ndoes not expose every `/v1/*` steering, job or control method to an ACP editor.\nUse `codewhale app-server --http` for that full runtime API.\n\n\n## Capability endpoint: `codewhale doctor --json`\n\nReturns a JSON object describing the current installation's readiness state.\nSuitable for health-check polling from a macOS workbench. This command is\nstrictly structural and offline: it does not load workspace credential\n`.env` files, inspect credential environment values, open secret/OAuth files,\nprobe an OS keyring, contact providers, or start MCP processes.\n\n```bash\ncodewhale doctor --json\n```\n\n### Response schema (key fields)\n\n| Field | Type | Description |\n|---|---|---|\n| `version` | string | Installed version (e.g. `\"0.8.9\"`) |\n| `config_path` | string | Resolved config file path |\n| `config_present` | bool | Whether the config file exists |\n| `paths` | object | Canonical config, settings, state, sessions, logs, automations, and secrets paths |\n| `secret_backend` | object | Metadata-only file-store shape, or literal `unknown` / `not_probed` for system and unsupported backends |\n| `workspace` | string | Default workspace directory |\n| `legacy_state.primary_root` | string | Primary Codewhale state root inspected for known state paths |\n| `legacy_state.legacy_root` | string | Legacy `.deepseek` state root inspected for known state paths |\n| `legacy_state.needs_attention` | bool | Whether known `~/.deepseek` state paths need review or the read-only session recovery diagnostic found missing destination filenames / could not complete |\n| `legacy_state.legacy_only_count` | number | Count of known state paths present only under the legacy root |\n| `legacy_state.dual_present_count` | number | Count of known state paths present under both primary and legacy roots |\n| `legacy_state.entries` | array | Per-path migration status: `{name, primary_present, legacy_present, status}` |\n| `legacy_state.session_recovery.status` | string | `isolated`, `no_legacy_sessions`, `migration_pending`, `migration_incomplete`, `migration_complete`, or `scan_failed` |\n| `legacy_state.session_recovery.read_only` | bool | Always true; doctor never invokes session migration or modifies either session directory |\n| `legacy_state.session_recovery.chat_contents_read` | bool | Always false; comparison is based only on top-level `.json` filenames and filesystem metadata |\n| `legacy_state.session_recovery.checkpoint_internals_scanned` | bool | Always false; `sessions/checkpoints/` and all other directories are skipped |\n| `legacy_state.session_recovery.recoverable_files` | array | Bounded sample of up to 100 missing destination filenames with source and destination paths; no chat payloads |\n| `legacy_state.session_recovery.recoverable_file_count` | number | Total missing destination filename count, including entries beyond the bounded sample |\n| `legacy_state.session_recovery.recoverable_files_truncated` | bool | Whether more than 100 recoverable filenames were found |\n| `legacy_state.session_recovery.recovery_command` | string or null | `codewhale sessions` when additive automatic recovery is available; null for isolated, complete, empty, or failed scans |\n| `api_key.source` | string | Structural source state: `config_declared`, `env_declared`, `external_auth_declared`, `secret_store_unprobed`, `secret_store_unavailable`, `oauth_unprobed`, `external_consent`, `none`, `local_runtime`, or `unknown`; declarations are not availability proof |\n| `api_key.availability` | string | Literal `present`, `not_required`, `not_probed`, `unavailable`, or `unknown`; only `present` and `not_required` certify structural Setup/Fleet credential readiness |\n| `base_url` | string | Provider URL authority only (`scheme://host[:explicit-port]`); userinfo, path, query, and fragment are omitted |\n| `default_text_model` | string | Default model |\n| `memory.enabled` | bool | Whether the memory feature is on |\n| `memory.path` | string | Path to memory file |\n| `memory.file_present` | bool | Whether memory file exists |\n| `mcp.config_path` | string | MCP config file path |\n| `mcp.present` | bool | Whether MCP config exists |\n| `mcp.probe_scope` | string | `configuration`; doctor does not start MCP servers |\n| `mcp.live_health_checked` | bool | Always false for doctor JSON |\n| `mcp.servers` | array | Per-server structural result and counts plus separate `checks`; URL userinfo/path/query/fragment and command argv, environment, header, and token values are never emitted, and all live stages are `not_checked` |\n| `skills.selected` | string | Resolved skills directory |\n| `skills.global.path` / `.present` / `.count` | — | Codewhale global skills dir (`~/.codewhale/skills`, with legacy `~/.deepseek/skills` support) |\n| `skills.agents.path` / `.present` / `.count` | — | Workspace `.agents/skills/` dir |\n| `skills.agents_global.path` / `.present` / `.count` | — | agentskills.io global skills dir (`~/.agents/skills`) |\n| `skills.local.path` / `.present` / `.count` | — | `skills/` dir |\n| `skills.opencode.path` / `.present` / `.count` | — | `.opencode/skills/` dir |\n| `skills.claude.path` / `.present` / `.count` | — | `.claude/skills/` dir |\n| `tools.path` / `.present` / `.count` | — | Global tools directory |\n| `plugins.path` / `.present` / `.count` | — | Global plugins directory |\n| `sandbox.available` | bool | Whether sandbox is supported on this OS |\n| `sandbox.kind` | string or null | Sandbox kind (e.g. `\"macos_seatbelt\"`) |\n| `storage.spillover.path` / `.present` / `.count` | — | Tool output spillover dir |\n| `storage.stash.path` / `.present` / `.count` | — | Composer stash |\n\n### Example\n\n```json\n{\n  \"version\": \"0.8.9\",\n  \"config_path\": \"/Users/you/.codewhale/config.toml\",\n  \"config_present\": true,\n  \"workspace\": \"/Users/you/projects/codewhale-tui\",\n  \"api_key\": {\n    \"source\": \"secret_store_unprobed\",\n    \"availability\": \"not_probed\"\n  },\n  \"base_url\": \"https://api.deepseek.com\",\n  \"default_text_model\": \"deepseek-v4-pro\",\n  \"memory\": {\n    \"enabled\": false,\n    \"path\": \"/Users/you/.codewhale/memory.md\",\n    \"file_present\": true\n  },\n  \"mcp\": {\n    \"config_path\": \"/Users/you/.codewhale/mcp.json\",\n    \"present\": true,\n    \"servers\": [\n      {\"name\": \"filesystem\", \"enabled\": true, \"transport\": \"stdio\", \"args_count\": 2, \"env_count\": 0, \"status\": \"ok\"}\n    ]\n  },\n  \"sandbox\": {\n    \"available\": true,\n    \"kind\": \"macos_seatbelt\"\n  }\n}\n```\n\n## HTTP/SSE runtime API: `codewhale app-server --http`\n\n```bash\ncodewhale app-server --http [--host 127.0.0.1] [--port 7878] [--workers 2] [--auth-token TOKEN] [--insecure-no-auth]\ncodewhale app-server --mobile [--host 127.0.0.1] [--port 7878] [--auth-token TOKEN]\ncodewhale app-server --mobile --host ::1 [--port 7878] [--insecure-no-auth]\ncodewhale web [--port 7878]\n\n# Compatibility aliases — identical server, serve flag names:\ncodewhale serve --http   [...] [--insecure]\ncodewhale serve --mobile [...] [--insecure]\n```\n\nDefaults: host `127.0.0.1`, port `7878`, 2 workers (clamped 1–8).\n\nThe server binds to `localhost` by default. Configuration is via CLI flags —\nthere is no `[app_server]` config section.\n\n`/v1/*` routes require a bearer token unless `codewhale app-server` is started\nwith `--insecure-no-auth` on a loopback bind such as `127.0.0.1`. Mobile mode\nis loopback-only: non-loopback hosts are rejected until Runtime has a TLS or\nverified-overlay transport boundary. The `codewhale serve` compatibility aliases\nuse `--insecure` for the same loopback escape hatch.\nPass `--auth-token TOKEN` or set `CODEWHALE_RUNTIME_TOKEN=TOKEN` before starting\nthe server; `DEEPSEEK_RUNTIME_TOKEN` remains a compatibility alias. If neither\nis set, the process generates a Runtime token for that process and does **not**\nprint it. `/health`, `/v1/runtime/info`, and an enabled static client shell\nremain public; Runtime mutations and thread data stay behind `/v1/*`\nauthentication. `/mobile` returns 404 when mobile mode is disabled and serves\nthe unchanged static shell when it is enabled.\n\nAuthenticated clients can provide the token as `Authorization: Bearer TOKEN`,\n`X-Codewhale-Runtime-Token: TOKEN`, the legacy\n`X-DeepSeek-Runtime-Token: TOKEN`. Query-string and raw Runtime-token cookie\nauthentication are not supported.\n\n### Local browser client\n\n`codewhale web` starts the canonical Runtime API on `127.0.0.1`, serves\ndependency-free assets embedded in the binary, prints a single-use launch URL,\nand asks the operating system to open that URL in the default browser. If the\nbrowser does not open, the printed URL remains usable for ten minutes. The\ncommand cannot bind to a non-loopback host and cannot run with Runtime auth\ndisabled.\n\nThe browser-launch URL contains a random, short-lived, one-time bootstrap\ncapability, never the Runtime token. A loopback request exchanges that\ncapability for a\n`codewhale_web_session=…; HttpOnly; SameSite=Strict; Path=/` cookie backed by a\nsingle process-local server session that expires 12 hours after the server\nprocess starts, consumes the capability immediately, and redirects to `/`.\nReused, expired, malformed, or\nnon-loopback bootstrap attempts fail closed. The Runtime bearer token is not\nplaced in rendered HTML, browser storage, logs, URL queries/fragments, or\nbrowser-launch arguments. The one-time bootstrap capability is printed in the\nlocal terminal and transits the OS browser launcher's argument list. A same-user\nprocess could race the browser to the exchange, which is why the capability is\nsingle-use, loopback-only, and expires after ten minutes — and why a same-user\nattacker has strictly easier local avenues than this race.\nWeb fetches require the session cookie plus an origin-scoped request proof;\nstreams use a fresh single-use ticket. The initial redirect carries the proof\nin a fragment, which the client removes and saves in origin-scoped\n`sessionStorage`. On reload or in a second tab, an authenticated `GET /` also\nembeds the proof in a meta tag when `Sec-Fetch-Site` is `same-origin` or `none`\n(direct navigation). The page uses `no-store`, disallows framing, and grants no\ncross-origin read access. This lets a new tab recover without reusing the\nbootstrap URL, including when storage is unavailable. Clients without Fetch\nMetadata can only reuse the fragment or their existing stored proof; recovery\ndoes not extend the server session or replace an expired cookie.\nCross-origin Fetch Metadata or a mismatched Origin is rejected on web API\nrequests. Explicit bearer and Runtime-token header clients keep their existing\nbehavior. Transient stream-ticket failures retry with capped backoff; HTTP\n401/403 stops ticket retries until a fresh session is opened.\n\nThe embedded client provides a responsive thread/search rail, Runtime-owned\nsession facts, transcript and tool receipts, and a bottom composer. It can\ncreate, select, rename, and archive threads; choose a provider and model for a\nnew thread without changing Runtime defaults; start or steer turns; interrupt\nwork; resolve approvals; and answer Runtime user-input requests. Selection\nloads `GET /v1/threads/{id}` first, then opens the replayable event stream with\n`since_seq=latest_seq`; reconnection advances from the newest accepted sequence\nand drops duplicates or events from a stale selection. The thread detail\nsnapshot includes `pending_approvals`, `pending_user_inputs`, and\n`pending_dynamic_tool_calls`; clients must hydrate those fields before\nsubscribing so a reload cannot strand work whose request event is at or before\n`latest_seq`. Resolution is also published as `approval.decided`,\n`user_input.answered`, `user_input.canceled`, `tool_call.resolved`,\n`tool_call.canceled`, or `tool_call.timeout` for already-connected clients.\n\nAn existing thread's model, mode, permission posture, workspace, and branch are\ndisplay-only in this client. Files/Changes, PTY/terminal, preview, artifacts,\nprovider login or global-default switching, Fleet creation, and\nundo/retry/restore controls are not built into this page. The native desktop\nclient covers them through the workspace-file, turn-artifact, terminal and\nworkspace-restore routes documented here.\n\n### Mobile control page\n\n`codewhale serve --mobile` starts the same HTTP/SSE runtime API and serves a\nphone-friendly control page at `/mobile`. It binds only to loopback\n(`127.0.0.1` or `::1`); a non-loopback host is rejected because this Runtime\nsurface does not yet provide TLS or a verified overlay transport. The static\nHTML page contains no Runtime bearer and is not itself token-gated. When\nRuntime auth is enabled, the CLI prints a short-lived, single-use loopback\nbootstrap URL. That capability creates a 30-minute, process-local\n`Max-Age=1800; HttpOnly; SameSite=Strict` mobile session cookie plus origin-scoped browser\nproofs. A sibling port that receives the host-scoped cookie cannot use it by\nitself. The page can also exchange an explicitly entered bearer once, then\nclears it rather than storing it in browser storage or a cookie. EventSource\nconnections use separate short-lived, single-use stream tickets.\n\nThe mobile page can list/create threads, send prompts, follow live SSE events,\nsteer or interrupt an active turn, and resolve normal tool approvals through\n`POST /v1/approvals/{approval_id}`. It is a local-only convenience surface;\ndo not expose it directly to another device or public network until Runtime has\na TLS or verified transport boundary.\n\n### Endpoints\n\n**Health**\n- `GET /health`\n\n**Sessions** (durable session manager)\n- `GET /v1/sessions?limit=50&search=&include_archived=false&archived_only=false&workspace=&sort=recent|name|size`\n- `GET /v1/sessions/summary?…` (same query params; projected row shape)\n- `GET /v1/sessions/{id}` (add `?peek=true&entries=12` for a bounded, redacted\n  read-only peek instead of the full transcript). The full response carries\n  `turn_outcomes` when a turn ended `Failed`: `{ status, error, ended_at,\n  after_message_count }` per failure, oldest first, bounded to 64, with the\n  error text the transcript showed and secrets redacted\n- `PATCH /v1/sessions/{id}` (`{ \"title\"?: string, \"archived\"?: bool }`)\n- `DELETE /v1/sessions/{id}`\n- `POST /v1/sessions/{id}/resume-thread` returns the open thread that already\n  holds the whole saved session (`200`), or seeds a new thread from it (`201`)\n  when none does, including when the session grew after that thread opened it.\n- `GET /v1/sessions/{id}/artifacts` and `GET /v1/sessions/{id}/artifacts/{artifact_id}?offset=&limit=`\n  (see workspace files and session artifacts above; runtime-turn spills are\n  read through `GET /v1/threads/{id}/turns/{turn_id}/artifacts/{artifact_id}`)\n- `POST /v1/sessions` (`{ \"thread_id\": string, \"title\"?: string }`) exports a\n  thread as a saved session. It is idempotent: it writes only the document\n  whose id is derived from the thread, creating it the first time (`201`) and\n  updating it after (`200`), so a retry never makes a duplicate. A session the\n  thread was resumed from is left unchanged.\n- `PUT /v1/sessions` (`{ \"thread_id\"?: string, \"session_id\"?: string }`) saves\n  a thread's live conversation. Naming a `session_id` that another thread is\n  bound to returns `409 Conflict`.\n- `GET /v1/sessions/repair` returns the last session-store repair summary, or\n  `null` if none has run\n\nSessions and threads answer the same `include_archived` / `archived_only` pair\nwith the same meaning, and `search` is the same fuzzy match (title, id,\nworkspace — substring, then subsequence) the TUI session picker and the workbar\nSessions list use. All three surfaces run one projection\n(`crates/tui/src/session_projection.rs`), so a listing cannot differ between\nthe terminal and the dashboard.\n\n`GET /v1/sessions/summary` returns rows that are field-compatible with\n`GET /v1/threads/summary` — `id`, `title`, `preview`, `model`, `mode`,\n`workspace`, `archived`, `updated_at` — plus `message_count`, `total_tokens`,\n`created_at`, `parent_session_id`, and `is_current`. One caveat stated plainly:\n`preview` is the session's recorded **title**, not its last message. Session\nmetadata does not store a last message, and reading every transcript to\nsynthesise one would make a list view an unbounded read. Full transcript\npreview lives in the TUI session picker, which reads one selected session.\n\n`PATCH /v1/sessions/{id}` renames and/or archives a saved session and returns a\nlifecycle receipt shaped like the thread patch receipt:\n\n```json\n{\n  \"session\": { \"id\": \"…\", \"title\": \"Renamed\", \"archived\": true, \"…\": \"…\" },\n  \"changes\": { \"title\": \"Renamed\", \"archived\": true }\n}\n```\n\n`changes` lists only what actually moved, so a no-op patch is distinguishable\nfrom an applied one. Archiving is durable and reversible: an archived session\nstays on disk and stays loadable, disappears from default listings, and is\nnever chosen by `--continue` or by auto-resume. The route is the same writer\nthe TUI picker (`e`) and `/sessions archive ` use — there is no second\narchive notion.\n\nWhile a session is open in an interactive Codewhale process, that process holds\nthe authoritative copy in memory and rewrites the whole document on its next\nautosave. `PATCH`, `PUT` and `DELETE` therefore fail closed on it with\n`409 Conflict` rather than writing something that would be silently reverted.\nChange it in the terminal instead. The open process holds a lock on the\nsession (`sessions/.late-usage/.live`), so this holds whether the request\nreaches the API inside that process or a separate `codewhale serve`.\n\nThe session store is repaired in the background at each launch and each\n`codewhale serve` start. The repair gives a \"Recovered:\" session to each thread\nin a Runtime store that no session is bound to. It unbinds threads whose session\ndocument is gone; they then load from their own turns. It moves unreadable\ndocuments, empty unbound stores, and old artifact directories that no session\nnames to `sessions/.set-aside//`, and writes a `MANIFEST.jsonl` there.\nNothing is deleted. `GET /v1/sessions/repair` and `codewhale doctor` report the\nlast run; `codewhale doctor --repair-sessions [--dry-run]` runs one on demand.\n\n`GET /v1/sessions/{id}?peek=true` returns a bounded, redacted, read-only view\ninstead of the transcript: at most 12 entries of at most 400 characters each\n(`&entries=N` lowers the budget, never raises it past the cap), tool calls and\nresults summarised to a name and a size rather than inlined, and\ncredential-shaped substrings masked. `omitted_before` reports how many earlier\nmessages were dropped. The payload carries `\"live\": false` and deliberately has\nno turn status, `running`, or `active` field — a saved session is a recording,\nand live state comes only from a resumed thread's SSE stream.\n\n**Threads** (durable runtime data model)\n- `GET /v1/threads?limit=50&include_archived=false&archived_only=false`\n- `GET /v1/threads/summary?limit=50&search=&include_archived=false&archived_only=false&thread_ids=,`\n- `GET /v1/threads/running`\n- `GET /v1/goals?limit=50&cursor=&status=` — read-only index of\n  the per-thread goal records across threads, newest update first. Returns\n  `{\"goals\": [ThreadGoal...], \"next_cursor\": \"\"}`; `next_cursor` is\n  absent on the last page. `limit` is 1-200 (default 50); `status` is one of\n  `active`, `paused`, `blocked`, `usage_limited`, `budget_limited`, `complete`;\n  goals whose thread no longer exists are omitted; a bad `status` or `cursor`, or a cursor issued under a different `status`, is `400`. Same auth as every other `/v1` read.\n  Goal creation, completion and removal stay on `/v1/threads/{id}/goal`.\n- `GET /v1/threads/{id}/notices`\n- `DELETE /v1/threads/{id}/notices/{notice_id}`\n- `POST /v1/threads`\n- `GET /v1/threads/{id}`\n- `PATCH /v1/threads/{id}` (see body shape below)\n- `POST /v1/threads/{id}/resume`\n- `POST /v1/threads/{id}/fork`\n- `GET /v1/threads/{id}/receipt` — what the thread did, one entry per action\n  (read-only; shape in [RECEIPTS.md](RECEIPTS.md))\n- `GET /v1/threads/{id}/turns/{turn_id}/receipt` — the same, for one turn;\n  `404` for an unknown thread or a turn that is not this thread's\n\n`thread_ids` is a comma-separated list of up to 200 IDs (each at most 128 bytes).\nThe route filters exact IDs before applying `limit`, preserving newest-first\norder. This is a selection filter, not an ownership or authorization check;\nthe Runtime bearer token remains the access boundary.\n\n`POST /v1/threads` accepts optional execution defaults in addition to the\nprovider, model, workspace, and permission fields:\n\n```json\n{\n  \"model_provider\": \"openai-codex\",\n  \"model\": \"gpt-5.6\",\n  \"reasoning_effort\": \"high\",\n  \"allowed_tools\": [\"read_file\", \"search\"]\n}\n```\n\n`reasoning_effort` uses the canonical Runtime vocabulary (`auto`, `off`,\n`low`, `medium`, `high`, `xhigh`, `ultra`, or `max`; documented compatibility\naliases are accepted and persisted canonically). `allowed_tools` is a\nmodel-visible allowlist. Omitting it keeps the normal configured catalog; an\nexplicit empty array (`\"allowed_tools\": []`) exposes no tools to the model.\nBoth fields are additive: older thread records and clients that omit them\nretain their previous behavior.\n\n`GET /v1/threads/summary` is the read-only summary surface used by the VS Code\nAgent View. `search` matches thread `id`, `title`, and `model` (and, when the\ntitle is unset, the latest turn's input summary — the displayed title). It\ndoes not scan turn or item bodies: `preview` is filled only after a match, so\na dashboard keystroke is not a whole-store read per thread. Each item includes\n`id`, `title`, `preview`, `model`, `mode`, `archived`, `updated_at`,\n`latest_turn_id`, `latest_turn_status`, plus workspace metadata:\n\n```json\n{\n  \"id\": \"thread_...\",\n  \"title\": \"Implement MCP status count\",\n  \"preview\": \"The TUI footer should count project MCP servers...\",\n  \"model\": \"deepseek-v4-pro\",\n  \"mode\": \"agent\",\n  \"branch\": \"feature/runtime-api\",\n  \"head\": \"abc1234\",\n  \"dirty\": false,\n  \"workspace\": \"/Users/you/projects/codewhale\",\n  \"archived\": false,\n  \"updated_at\": \"2026-06-06T05:43:00Z\",\n  \"latest_turn_id\": \"turn_...\",\n  \"latest_turn_status\": \"completed\"\n}\n```\n\n`branch` is resolved from the thread workspace at request time and may be\n`null` when the workspace is not a Git repository or the branch cannot be read.\n`head` is the current short Git commit for that workspace when available.\n`dirty` is true when the workspace has staged, unstaged, or untracked changes.\n`workspace` is included so editor clients can show when an agent lane is working\noutside the current VS Code folder.\n\nThread forks are sibling runtime threads, not an in-place tree projection.\n`thread.forked` events include `source_thread_id`; internal backtrack-aware\nforks may also include `backtrack_depth_from_tail` and `dropped_turn_id`, and\na fork anchored to a named turn (`/fork-at-turn`) reports them with the depth\nresolved from that turn, and names the first user turn it dropped (not the\nanchor, which a named-turn fork keeps, and not a prompt-less turn such as a\nmanual compaction that sits between them).\nThread list and summary responses remain flat in v0.8.40, so clients that need\na graph should reconstruct it from events instead of assuming list order is a\ncomplete tree.\n\n`GET /v1/threads/running` is the running-work accounting surface\n(#6180): threads with at least one queued or in-progress turn, each with\n`thread_id`, `model`, `title`, and `active_turns` (`turn_id` + `status`).\nBackground-capable clients use it for quit/background decisions — one call,\nno inference from latest-turn status. Archive state is ignored (archiving\nhas no quiescence gate); an empty array means no owned work is live.\n\n`GET /v1/threads/{id}/notices` is the per-thread active-notice surface\n(#6180): the TUI-visible conditions a watch-only client must surface —\n`subagent-terminal` (a child settled), `elevation-needed` (a tool call is\nblocked on elevation), `model-notify` (the model asked the user to come\nback) — each with `turn_id` and a `subject` id for targeting. Notices are\nin-memory session state, bounded to 32 per thread (oldest evicted), and\nnever persisted. Clearing: elevation auto-clears when its tool call\ncompletes; terminal/notify clear on `DELETE .../notices/{notice_id}`\n(204, unknown ids 404). Unknown threads 404 on both endpoints.\n\n`archived_only=true` returns archived threads only (mutually overrides\n`include_archived`). Default behavior is unchanged: `include_archived=false`\nand `archived_only=false` returns active threads. Added in v0.8.10 (#563).\n\n`PATCH /v1/threads/{id}` body — every field is optional, missing means\n\"no change\". At least one field must be present. `title` and `system_prompt`\naccept an empty string to clear a previously-set value. Added in v0.8.10 (#562):\n\n```json\n{\n  \"archived\": true,\n  \"allow_shell\": false,\n  \"trust_mode\": false,\n  \"auto_approve\": false,\n  \"model\": \"deepseek-v4-pro\",\n  \"mode\": \"agent\",\n  \"title\": \"User-set thread title\",\n  \"system_prompt\": \"You are a useful assistant.\",\n  \"model_provider\": \"custom\",\n  \"model_provider_id\": \"lm-studio\"\n}\n```\n\n`model_provider` switches the provider the thread's future turns use. It\ntakes a built-in kind (`deepseek`, `xai`, ...) or a configured route name, as\n`/provider` does. `model_provider_id` names one exact `[providers.]` table\nand wins over a route name. The target route is resolved and its client\npreflighted before anything is saved, so an unknown or credential-less\nprovider is refused and nothing changes. Without `model`, the thread takes the\nnew provider's default model; an `auto` thread stays `auto`. The loaded\nengine and conversation history are kept, and the next turn installs the new\nroute.\n\nA change of effective permission adds a completed `status` item to the thread's\nlatest turn and publishes a `thread.permissions_changed` event (`from`, `to`,\n`source`).\n\n**Turns** (within a thread)\n- `POST /v1/threads/{id}/turns`\n- `POST /v1/threads/{id}/turns/{turn_id}/steer` - inject guidance into the running turn. The response is a receipt for what actually happened, not for what was attempted; see [Steer delivery](#steer-delivery).\n- `POST /v1/threads/{id}/turns/{turn_id}/interrupt`\n- `GET /v1/threads/{id}/turns/{turn_id}/artifacts` - what the turn produced: typed references plus the workspace-delta state. See [Turn artifacts](#turn-artifacts).\n- `GET /v1/threads/{id}/turns/{turn_id}/artifacts/{artifact_id}?offset=&limit=&revision=` - read one reference from the workspace, the post-turn snapshot, or the session artifact directory.\n- `POST /v1/threads/{id}/compact` (manual compaction)\n- `POST /v1/threads/{id}/undo` - fork the thread with the last N turns removed (`{\"depth\": N}`, default 0 = last turn only); returns the forked thread plus `original_user_text` so a GUI can pre-populate the input box\n- `POST /v1/threads/{id}/fork-at-turn` - fork at one named user turn (`{\"turn_id\": \"turn_…\"}`, as `GET /v1/threads/{id}` reports it). The fork *keeps* that turn and every turn before it, and drops the turns after it; naming the last turn therefore keeps the whole conversation. The receipt is `/undo`'s (`thread`, `original_user_text`, `original_user_images`), carrying the *first dropped* user turn's prompt — what was asked next, even when a prompt-less turn such as a manual `/compact` sits between — so a client can put it back in the composer for editing. The source thread, its session document and the workspace are untouched, and there is no file rollback: a fork is a sibling conversation, and rewinding the workspace would rewind the branch left behind with it. Clients should name the turn instead of computing a `depth` — the transcript they render and the turn list this cuts are not the same list (steers, image-only prompts and injected handoffs each sit on one side only), and a client-side count that is off by one forks the wrong prefix while answering `201`. `400` when the turn is not a user turn of that thread.\n- `POST /v1/threads/{id}/patch-undo` - rolls back the files the dropped turns changed, then the same fork (`{\"depth\": N}`); returns `patch_result` (`files_restored`, `summary`, `snapshot_label`) alongside the forked thread. See [Workspace restore endpoints](#workspace-restore-endpoints) for ownership, the trust, admission and abort rules, and the `error.code` values of a refusal.\n- `POST /v1/threads/{id}/file-revert` - restore exactly one file from one restore point the thread owns (`{\"path\", \"snapshot_id\", \"expected_hash\"}`); never forks the conversation. See [Workspace restore endpoints](#workspace-restore-endpoints).\n- `POST /v1/threads/{id}/retry` - fork with the last N turns removed and immediately start a new turn (`{\"depth\": N, \"prompt\": \"...\"}`; `prompt` overrides the original user text, which is re-used when omitted)\n\n`POST /v1/threads/{id}/turns` accepts the same optional\n`reasoning_effort` and `allowed_tools` fields as per-turn overrides:\n\n```json\n{\n  \"prompt\": \"Review this change without running tools.\",\n  \"operation_key\": \"cwc-request-01J7Y6Q9W4\",\n  \"reasoning_effort\": \"max\",\n  \"allowed_tools\": []\n}\n```\n\nThe same `model_provider` / `model_provider_id` fields on a turn route that\none turn through another provider. The saved thread keeps its provider.\nWithout `model`, the turn uses that provider's default model (an `auto` thread\nstays `auto`). The override is always preflighted and is part of the\n`operation_key` fingerprint.\n\nAccount-owned model turns require `capabilities.account_model_owner: true` before\nsubmission. Set `account_model_owner` to the signed-in account ID, both\n`model_provider` and `model_provider_id` to `codewhale`, and `model` to the exact\n`provider/model` account route. The Runtime requires current model access for\nthat same secure account session and uses only that account credential; missing,\nexpired or revoked access is refused. Local keys and other configured routes are\nnot substitutes. A supplied profile constitution must belong to the same account.\nThe owner participates in the durable operation fingerprint; replay of an\nalready completed exact operation returns its receipt before fresh credential\nadmission. The account gateway resolves the selected provider's current saved\nAPI key at use. Ordinary turns and saved Chat defaults retain their existing\nbehavior. The Runtime Chat relay carries the same assertion as `accountModelOwner`.\n\nResolution is deterministic: a turn override wins over the thread default,\nwhich wins over the Runtime's normal configuration. For tools, reaching normal\nconfiguration means the ordinary configured catalog; `[]` is never treated as\nmissing. Reasoning is normalized only after the exact provider/model route is\nresolved, and `auto` remains a per-prompt reasoning decision even when the\nthread uses a fixed model. The request still enters the existing\n`Op::SendMessage` path and the single `Engine::run_turn` loop.\n\nImage input uses the same turn path: `\"images\": [{\"mime\": \"image/png\",\n\"dataBase64\": \"...\"}]`. Clients must first observe\n`capabilities.turn_image_inputs: true` in `/v1/runtime/info` (or the isolated\nRuntime Chat relay catalog). Older HTTP runtimes ignore unknown fields, so a\nsuccessful text response is not evidence that an attachment was accepted.\nThe field is omitted when empty. It is also accepted by app-server\n`thread/message`, `thread/request` messages, and prompt requests; that bridge\nchecks the underlying Runtime capability before forwarding image bytes.\nLegacy remote Work commands do not support images and explicitly refuse them.\n\nNew inline images require a named model whose exact resolved route reports\n`image_input: \"supported\"`; Auto and unknown/unsupported image routes are\nrefused before classifier or provider dispatch. This does not change the\nexisting trusted-local attachment behavior for routes with unknown capability.\nA nonempty prompt is required. Inputs are limited to 10 images, 4 MiB decoded\nbytes per image, 5 MiB total, and an 8 MiB JSON body. PNG, JPEG, GIF and WebP\nmust have matching MIME, canonical padded base64 and valid bounded image\ncontent: at most 8192 pixels per dimension, 33,554,432 pixels total and 64 MiB\ndecoder allocation. The Runtime does not fetch paths or URLs from this field.\nMalformed images refuse the whole turn; callers can retain the draft for\ncorrection. Relay command polling uses an 8 MiB response budget; the sender\nmust paginate by serialized bytes without advancing past unserved commands.\n\nAccepted image bytes and order are retained in the existing turn records and\nreconstructed after restart, import and fork. Retry retains those images even\nwhen its optional `prompt` changes the text; undo responses include\n`original_user_images` when present. Image-bearing records require schema v3,\nwhich older readers refuse. Text-only records and operation fingerprints retain\ntheir prior representation. Validated stored local images retain the existing\n5 MiB per-image ceiling and prior aggregate/count semantics on import/retry;\nthis internal storage authority does not\nrelax exact model or permission checks. Image bytes, MIME and order participate in request\nidentity, so changing an image under the same operation key conflicts.\nCompaction can summarize older context; retaining the original attachment does\nnot promise that every later model request includes it. Image pixels are not\nsubject to text-secret redaction.\n\n`operation_key` is an optional idempotency key for clients that may lose an\nHTTP response after the Runtime accepted a turn. It is scoped to the current\nRuntime store and thread, may contain at most 128 UTF-8 bytes, and may not be\nempty or contain surrounding whitespace or control characters. Omitting it\npreserves the legacy create-a-new-turn behavior.\n\nThe first accepted request durably binds a SHA-256 fingerprint of the key to\nthe Runtime turn id and a canonical request fingerprint before sending the\nexisting `Op::SendMessage`. An exact retry returns that original turn in the\nnormal `{ \"thread\": ..., \"turn\": ... }` response and emits no second engine\noperation, item, or lifecycle sequence. Reusing the key on the same thread\nwith a different provider/model, prompt, reasoning policy, tool allowlist or\ndynamic-tool schema, environment, or permission policy fails closed with\n`409 Conflict`. The same caller key may be used independently on another\nthread.\n\nOnly the scoped key fingerprint, request fingerprint, thread id, and turn id\nare stored in the Runtime's private turn-operation index. The raw key is never\npersisted or logged, and request bodies, credentials, and attachments are not\ncopied into that index. Existing thread/turn persistence remains the source of\nthe returned turn after a process restart.\n\n**Exact accepted-turn lookup**\n\n`GET /v1/threads/{id}/turn-operations/{operation_key}` uses the same Runtime\nauthentication as turn submission. URL-encode each path segment. It returns\n`200 OK` with the existing bare `TurnRecord` (the `turn` object in the POST\nresponse), identified by that exact thread and operation key. It does not use\nthe thread's latest turn or require the original request body or current route\nsettings to match.\n\n- `404 Not Found`: no binding exists for that thread/key, or persisted identities\n  do not match. These cases share a generic response.\n- `409 Conflict`: admission holds the operation claim, or its durable binding\n  is incomplete. Retry the lookup; this response does not authorize another turn.\n- `400 Bad Request`: the thread ID or operation key is malformed. The key uses\n  the same 128-byte and whitespace/control-character rules as POST.\n- `500 Internal Server Error`: storage or the existing claim lock cannot be\n  checked safely. This is not evidence that the operation is absent.\n\nThe lookup holds a shared read lock on the existing operation claim while\nreading the binding and turn. It creates no files, starts no engine, emits no\nevents, and performs no replay or recovery. Normal Runtime startup may recover\nan incomplete admission before a later lookup, but GET itself never does so.\n\n**Approvals**\n- `POST /v1/approvals/{approval_id}` with body\n  `{ \"decision\": \"allow\" | \"deny\", \"remember\": false }`\n- `GET /v1/approvals?limit=` (1-500, default 100) is the read-only history:\n  decided and still-pending approvals across all sessions, newest ask first.\n\nEach history row carries `approval_id`, `tool_name`, `outcome`, `asked_at` and\n`decided_at` (null while pending), plus optional `decided_by` (`user`,\n`session_rule`, `posture`, `host`), `target` and `summary`. `outcome` is one of\n`allowed_once`, `denied`, `timeout`, `cancelled`, `unavailable`,\n`retry_with_policy`, or `pending`. `standing_rule` (`allow` or `deny`) is present\nonly when a remembered session rule or grant answered rather than a person\n(`decided_by: session_rule`); `outcome` is unchanged. `target` is the host of a\nURL or a workspace-relative path (absolute paths outside the workspace are\nreduced to `.../`); `summary` is a one-line description of what was\nasked. Both are redacted conservatively when the ask is recorded: a shell\ncommand keeps only the program name and plain arguments, and any quoted,\n`KEY=VALUE`, credential-like or unrecognised argument replaces all arguments\nwith `[args redacted]`. Whitespace is collapsed and text is capped at 120\ncharacters. Rows recorded before these fields existed\nomit `target` and `summary`.\n\n`approval_id` is minted by the Runtime, not by the model or the provider. It is\nan opaque `approval_<32 hex>` capability, unique per prompt, bound to the thread\nthat raised it, and single-use: the Runtime removes it when the decision is\ndelivered, when the prompt times out, or when the turn abandons it. Clients echo\nthe value they were given and must not construct, derive, or guess one.\n\nIt is deliberately **not** the provider's tool-call ID. Providers restart their\ncall-ID counters per response, so two threads can gate calls whose raw IDs are\nbyte-equal; keying approvals by that value let one thread's decision settle\nanother thread's call. The endpoint therefore performs one exact match on the\nminted ID and has no fallback: a raw tool-call ID, an expired ID, or a replayed\nID that has already been settled all return `404` and reach no engine. A `404`\nmeans the capability is not pending — it is not evidence about how the approval\nwas resolved; read `approval.decided` for that.\n\nThe raw provider call ID travels separately as `tool_call_id` on\n`pending_approvals[]` and on the approval events. It is a correlator for\nattaching a prompt to the tool row it gates, and never accepted as a decision.\nEach thread-detail `pending_approvals[]` entry is\n`{ \"id\", \"turn_id\", \"tool_name\", \"description\", \"intent_summary\"?, \"tool_call_id\"?, \"summary\"? }`,\nwhere `id` is the capability above. `summary` (also on `approval.required`) is\na one-line description of the gated call built from the tool name and its\narguments only, never from model text (\"Search the web for 'espresso'\",\n\"Write notes/espresso.md\"); paths inside the workspace are workspace-relative.\nClients show it first and keep the raw arguments behind it. For task and\nautomation create/update, `summary` also names the requested trust mode,\nshell, auto-approve, mode and workspace.\n\n`\"remember\": true` on an `allow` records a **session grant** for that tool and\nargument class (the approval grouping key: a shell command family for a\nsimple, known command such as `git status` whose only options are value-free\nones like `-s` or `--porcelain` — a compound, wrapper, interpreter or\nunrecognised command, one with any other option, or one whose arguments are\nwhat runs or is installed (`go run`, `make`, `git bisect`, package installs)\nis granted as its full normalized command, and a\nshell interact or wait call as the exact call — a patch's file set, a `fetch_url` host, an MCP tool, a `web.run` action kind — for\n`open`, the hosts it opened). Computer Use consent and `app_script` calls, and\nany tool without a class, are granted for the exact call only. A grant never\nchanges the thread's permission posture. Later matching calls on the thread are\napproved without a prompt: they still emit `approval.required`, then\n`approval.decided` with `\"auto\": true` and the `grant_id`. Creating a grant\nemits `approval.grant_added` with `{ \"grant\": { \"grant_id\", \"tool_name\",\n\"scope\", \"summary\", \"granted_at\" } }`; thread detail lists live grants in\n`approval_grants[]`. `DELETE /v1/threads/{id}/approval-grants/{grant_id}`\nrevokes one (emitting `approval.grant_revoked`); the next matching call\nprompts again. Archiving or deleting the thread ends all of its grants\n(archiving emits `approval.grant_revoked` for each; unarchiving does not\nrestore them). Grants are stored in the Runtime data directory\n(`approval_grants.json`). After a Runtime restart a thread's grants stay\nsuspended, and matching calls prompt again, until an authenticated client\nresumes the thread. A grant store that cannot be read holds no grants, so every\ncall prompts. A forced (non-bypassable) prompt is never answered by a grant.\n\n**User input**\n- `POST /v1/user-input/{thread_id}/{input_id}` with body\n  `{ \"answers\": [{ \"id\": \"question-id\", \"label\": \"Choice\", \"value\": \"Choice\" }] }`\n\nSubmitted values are delivered to the active model turn but are deliberately\nexcluded from durable Runtime items and events. The settled tool item contains\nonly a neutral receipt and a machine-readable `response_redacted` marker. The\nRuntime accepts only an exact pending `(thread_id, input_id)` request; an\nunknown, concurrently settling, or already settled id returns 404 and is never\nplaced in the engine mailbox. It commits the secret-free\n`user_input.answered` receipt before removing the snapshot-authoritative prompt\nor delivering the answer to the engine. That settlement runs independently of\nthe HTTP connection, so disconnecting after submission cannot leave a prompt\nhalf accepted. Terminal-turn cancellation follows the same receipt-before-\nremoval ordering through `user_input.canceled`.\n\n**Client-executed dynamic tools**\n- `POST /v1/threads/{thread_id}/turns/{turn_id}/tool-calls/{call_id}/result`\n\nThe thread and turn in the result route must match the pending call. A call is\nsettled at most once; wrong-route and duplicate results return 404. Terminal\nlifecycle events carry identifiers and status only, never tool result content.\nThe Runtime commits the terminal lifecycle event before making a submitted\nresult available to the model. Result delivery, timeout, and terminal-turn\ncancellation race through one settlement owner, so exactly one of these events\nis durable for a call:\n\n- `tool_call.requested` — the typed client-executed call became pending;\n- `tool_call.resolved` — a result was durably accepted by the Runtime\n  (`result_accepted: true`; `success` is result metadata, but result content is\n  excluded);\n- `tool_call.timeout` — no result won before the bounded wait expired;\n- `tool_call.canceled` — the turn terminated before a submitted result won.\n\nHTTP `202 Accepted` and `tool_call.resolved` share that durable-acceptance\nmeaning. Neither claims that the model consumed the result: a concurrent turn\nshutdown may close the model receiver after acceptance. Once the Runtime has\naccepted the result, that call is terminal and a duplicate result returns 404.\n\n**Events** (SSE replay + live stream)\n- `GET /v1/threads/{id}/events?since_seq=&replay_limit=&progress=true`\n\nCursors:\n\n- `since_seq` is the per-thread cursor: events with `seq > since_seq` are sent.\n  Omitted (and no `Last-Event-ID`), the stream starts from the beginning of the\n  thread's history.\n- Every journal frame carries `id: `, so a browser `EventSource` can\n  resume through the `Last-Event-ID` header it sends on reconnect. An explicit\n  `since_seq` wins over the header, so a deliberate replay from `0` is never\n  overridden by a stale id. A header value that is not a decimal integer is\n  ignored.\n- `replay_limit` (at most 4096) returns only the newest tail of the requested\n  history; `previous_seq` on the first returned event advances past exactly the\n  omitted history.\n\nDurable history parsing runs off the async server workers and reaches SSE in\nbounded batches of at most 256 events through a backpressured channel. Broadcast\ndelivery is only a wake-up optimization: a lagged receiver opens the same\nbounded durable replay from its last accepted cursor.\n\n`progress=true` adds `stream.progress` transport frames\n(`{schema_version, event, kind, thread_id, seq, state}`, `state` is\n`replaying` or `live`) at the current cursor and advertises them with\n`x-codewhale-event-progress: 1`. The stream reports `live` only after durable\nhistory and the already-queued live tail are both drained; broadcast-lag\nrecovery returns it to `replaying`. Progress frames never carry a new sequence\nnumber.\n\nFailures before the stream opens are ordinary HTTP errors with the JSON error\nbody, never SSE:\n\n| Status | When |\n| --- | --- |\n| `401` / `403` | Missing or wrong Runtime credential |\n| `404` | Unknown thread |\n| `400` | `replay_limit` above 4096 |\n| `500` | The durable history could not be opened (including a replay worker crash before the first cursor) |\n\nOnce the response is `200`, every end the server chooses is a final\n`stream.end` frame; see [Ending and resuming a thread\nstream](#ending-and-resuming-a-thread-stream).\n\n**Snapshots** (side-git restore point listing + restore)\n- `GET /v1/snapshots?limit=20`\n- `POST /v1/snapshots/{id}/restore`\n\n`/v1/snapshots` lists recent side-git restore points for the runtime workspace.\n`limit` defaults to `20` and must be between `1` and `100`. `POST\n/v1/snapshots/{id}/restore` restores workspace files from the snapshot and\nreturns `{\"restored\": \"\"}`. It is the direct operator surface for\nthe server's own workspace (the same action as the TUI's `/restore `): it is\ngated by the Runtime API bearer token, not by any thread's trust flag, and it\nis refused with `409` while a turn is active in an overlapping workspace (see\nbelow). A `pre-restore:` safety snapshot is taken first.\n\n```json\n[\n  {\n    \"id\": \"snap_...\",\n    \"label\": \"post-turn:1\",\n    \"timestamp\": 1780730580\n  }\n]\n```\n\n### Workspace restore endpoints\n\nThree routes mutate workspace files from side-git snapshots. They share one\nadmission rule and one safety net, and they differ in scope and trust.\n\n| Route | Scope | Trust | Forks the thread |\n| --- | --- | --- | --- |\n| `POST /v1/snapshots/{id}/restore` | whole server workspace | bearer token only (operator action) | no |\n| `POST /v1/threads/{id}/patch-undo` | the files the dropped turns changed | thread `trust_mode` or `auto_approve` when files would change | yes |\n| `POST /v1/threads/{id}/file-revert` | exactly one regular file | thread `trust_mode` or `auto_approve`, always | no |\n\n**Admission.** A restore reserves the same admission the Runtime uses for\nconfig reloads and session checkpoints, so no new turn starts and no saved\nhistory changes while files are being rewritten. If any thread already has an\nactive turn in the same workspace, a nested checkout of it, or a parent of it,\nthe request is refused with `409` and the message `already has an active turn`.\nThe reservation is owned by the worker performing the Git mutation, so a client\nthat disconnects mid-request cannot release it early; the operation completes\nor fails as a whole. Concurrent restores serialize. The reservation is\nruntime-wide: while a restore's safety snapshot and checkout run, new turns,\nsteering, compaction and user-input delivery on every thread wait for it to\nfinish, so a large workspace can add seconds of latency elsewhere during a\nrestore. A thread whose workspace directory is not available (unmounted\nvolume, disconnected share, missing directory) is refused with `409` rather\nthan treated as having nothing to restore.\n\n**Ownership.** A thread owns exactly the workspace restore points recorded on\nits own turns. While a turn runs, the engine reports each snapshot it takes —\n`pre_turn` before the turn, `tool` before and `post_tool` after each tool call\nthat may write (every call that is not read-only: file tools, shell commands,\nprograms, write-capable MCP tools), `post_turn` when it ends (always before\nthe turn settles) — and the Runtime appends it to the turn record's\n`workspace_snapshots`, in order:\n\n```json\n\"workspace_snapshots\": [\n  { \"kind\": \"pre_turn\", \"snapshot_id\": \"\", \"tree_id\": \"\", \"session_id\": \"thr_1a2b3c4d\" },\n  { \"kind\": \"tool\", \"snapshot_id\": \"\", \"tree_id\": \"\", \"session_id\": \"thr_1a2b3c4d\", \"tool_call_id\": \"call_…\", \"write_paths\": [\"src/lib.rs\"], \"changed_paths\": [] },\n  { \"kind\": \"post_tool\", \"snapshot_id\": \"\", \"tree_id\": \"\", \"session_id\": \"thr_1a2b3c4d\", \"tool_call_id\": \"call_…\", \"changed_paths\": [\"src/lib.rs\"] },\n  { \"kind\": \"post_turn\", \"snapshot_id\": \"\", \"tree_id\": \"\", \"session_id\": \"thr_1a2b3c4d\", \"changed_paths\": [] }\n]\n```\n\n`changed_paths` lists the workspace-relative paths whose content changed since\nthe turn's previous receipt — what happened in the span the receipt closes; it\nis absent on `pre_turn` and when it could not be computed (a snapshot in\nbetween failed). `write_paths` is set on the `tool` receipt of a file tool\n(`write_file`, `edit_file`, `apply_patch`) to the paths the call declared, as\nit named them; a tool without it (a shell command) may write any path. On a\nuser shell turn the `pre_turn` receipt carries the command's `tool_call_id`,\nsince the command runs from it to `post_turn`.\n\nEach receipt is also published as a `turn.workspace_snapshot` event (payload:\nthe receipt). The engine runs every Runtime thread under the thread's own id,\nacross restarts and engine eviction, so `session_id` is the thread id for turns\nthe thread ran itself; it does not follow the thread's saved-session binding\n(`PUT`/`POST /v1/sessions`, resume), which only names a document. A fork clones\nits source's turn records and so owns the restore points of the turns it\ninherited. Another thread's, or a TUI session's, snapshots in the same\nworkspace are never candidates. `tree_id` is the durable identity: a prune\nrebuilds the side repo and rewrites every commit id but keeps each tree, and a\nrestore point resolves only to a stored snapshot with the same tree, session\ntag and kind. The count prune after each snapshot keeps the newest 50\nsnapshots plus the newest 50 turn boundaries (`pre-turn:`/`post-turn:`), so a\nturn with more tool calls than that, or a burst from another thread, never\npushes out a recent turn's own restore points. Turns recorded before receipts existed, turns imported by\n`resume-thread`, and turns run with snapshots off or unavailable have none.\n\n**Safety net.** Every restore first records a `pre-restore:` snapshot\nof the current workspace. That label is never a `/undo`, `patch-undo` or\n`file-revert` candidate, so the net does not change what later undos select.\nFor `file-revert` the backup is mandatory: if it cannot be written, or the\nrequested file is excluded from it (for example by `.gitignore`), the request\nfails and nothing is changed.\n\n**`patch-undo`.** Undoes whole turns. For each dropped turn's `pre_turn` →\n`post_turn` window, the paths that differ between the two snapshots must all be\nthe turn's own: changed only in the span of one of the turn's tool calls (a\n`tool` → `post_tool` span, or a shell turn's whole window), and, inside a file\ntool's span, a path that call declared. A path that changed while none of the\nturn's tools could have written it — another thread, an editor, a background\nprocess — is someone else's change, and the undo is refused rather than revert\nit. Each of the turn's paths goes back to its content before the first dropped\nturn that changed it, and nothing else is touched, so later work by the user or\nanother thread in the same workspace survives. Then the conversation forks\nexactly as `/undo` does.\n\nSnapshots never hold paths excluded by the workspace's `.gitignore` files or\nthe built-in snapshot exclusions (`node_modules/`, `target/`, `dist/`, build\ncaches, binary artifacts), nor paths outside the workspace. A dropped file-tool\ncall that declared such a path is refused with `path_not_snapshotted`, because\nno snapshot can put it back. A shell command declares no paths: what it writes\nunder an excluded path (build output, dependency installs) is outside what\n`patch-undo` restores and is not reported. `201` means either files were restored (`files_restored: true`, one\n` ` line per file in `summary`, `snapshot_label` naming the\npre-turn snapshot) or there was provably nothing to restore\n(`files_restored: false`): every dropped turn ran here without a tool call,\nchanged no file, or its files are already back at their pre-turn content.\nAnything that cannot be restored aborts the whole undo with `409`, nothing is\nchanged and no fork is published; `error.code` says why:\n\n| `error.code` | Meaning |\n| --- | --- |\n| `restore_point_unavailable` | a dropped turn that may have changed files has no complete recorded restore point (older record, imported by `resume-thread`, snapshots off, or the snapshot failed) |\n| `restore_point_pruned` | the restore point is no longer in the snapshot store |\n| `path_not_snapshotted` | a dropped file-tool call wrote a path snapshots do not hold (ignored, built-in exclusion, or outside the workspace) |\n| `workspace_changed_since_turn` | a path the turns changed was changed afterwards (or between two dropped turns), a path changed while a dropped turn ran but outside its own tool calls, or a path that is not a regular file |\n| `restore_requires_trust` | there is something to restore and the thread is not in trusted mode or Full Access |\n| `workspace_unavailable` | the workspace directory is not available |\n\nFor the first four a client can offer the conversation-only\n`POST /v1/threads/{id}/undo` instead, and `file-revert` for individual files.\nSnapshot repository, listing or comparison failures abort with `500` and also\npreserve the conversation, so a turn is never dropped while its file changes\nstay on disk. Depth and history are validated before any file changes. If the\nfork cannot be persisted after files were restored, the response is a `500`\nthat names the restored snapshot; the original thread still holds the turn and\nthe `pre-restore:` snapshot holds the previous files.\n\n**`file-revert`.** Request body:\n\n```json\n{\n  \"path\": \"src/lib.rs\",\n  \"snapshot_id\": \"3f2a…40-or-64 hex…\",\n  \"expected_hash\": \"sha256:<64 lowercase hex digits>\"\n}\n```\n\n- `path`: workspace-relative, or absolute inside the thread workspace. The\n  name is literal (brackets, spaces and glob characters are filename bytes;\n  Git runs with `--literal-pathspecs`). It must name a regular file: directories,\n  symlinks anywhere in the path, and `.git` components are `400`.\n- `snapshot_id`: the exact `tool` or `pre_turn` restore point of the change\n  the user selected, from the thread's own turn records: a receipt's\n  `snapshot_id` or `tree_id` (for a tool call, the receipt whose\n  `tool_call_id` matches), or the current commit id `GET /v1/snapshots` lists\n  for it. It must be a restore point recorded on one of this thread's turns;\n  the server never picks \"the newest snapshot that differs\", because an\n  unrelated newer snapshot can erase later user edits while leaving the tool's\n  change.\n- `expected_hash`: `sha256:` of the current file bytes the client displayed,\n  or `absent` when the client saw the file as deleted. It is checked before the\n  safety backup and again immediately before the mutation.\n\nResponses:\n\n- `200 {\"path\", \"action\", \"snapshot_id\", \"snapshot_label\"}` — `action` is\n  `modified`, `recreated` (file was missing) or `removed` (the snapshot does\n  not contain the file, so the file the tool created is deleted; its parent\n  directories are left in place).\n- `400`: malformed `snapshot_id`/`expected_hash`, path outside the workspace,\n  or a path that is not a regular file on either side.\n- `404`: unknown thread.\n- `409`: thread not in trusted mode or Full Access; active turn in an\n  overlapping workspace; workspace directory not available; snapshot unknown,\n  pruned, not recorded on this thread's turns (another thread's or a TUI\n  session's), or not a restore point (refresh the change record); file already\n  matches the\n  snapshot (nothing to revert); or the file changed after the reviewed\n  `expected_hash` (refresh and review again). Nothing is changed in any of\n  these cases.\n- `422`: missing or mistyped body fields.\n- `500`: Git or filesystem failure; a failure after the safety snapshot names\n  that snapshot so the previous bytes can be recovered with\n  `POST /v1/snapshots/{id}/restore` or `/restore`.\n\nCapability probe: `GET` on the route returns `405` where the endpoint exists\nand `404` on an older engine; clients treat any non-`404` as available and\ndegrade with an explanation otherwise.\n\n**Compatibility stream** (one-shot, backwards-compatible)\n- `POST /v1/stream`\n\n**Tasks** (durable background work)\n- `GET /v1/tasks`\n- `POST /v1/tasks`\n- `GET /v1/tasks/{id}`\n- `POST /v1/tasks/{id}/cancel`\n\n**Automations** (scheduled recurring work)\n- `GET /v1/automations`\n- `POST /v1/automations`\n- `GET /v1/automations/{id}`\n- `PATCH /v1/automations/{id}`\n- `DELETE /v1/automations/{id}`\n- `POST /v1/automations/{id}/run`\n- `POST /v1/automations/{id}/pause`\n- `POST /v1/automations/{id}/resume`\n- `GET /v1/automations/{id}/runs?limit=20`\n\nCreate and update requests accept an optional `model`. When present, each\nscheduled or manually triggered run uses that model; omitting it keeps the\nruntime's default task model.\n\n**Automation mutation preconditions.** `GET /v1/runtime/info` advertises\n`capabilities.automation_mutation_preconditions: true`; require it before\nrelying on the behavior below (older Engines ignore both inputs).\n\n- Every `AutomationRecord` carries an integer `revision`: `1` on create, `+1`\n  on each `PATCH`, `pause` and `resume`; legacy records read as `0`. Scheduler\n  bookkeeping (`next_run_at`, `last_run_at`) does not change it.\n- `expected_revision` is an optional precondition on `PATCH`, `run`, `pause`\n  and `resume`: a JSON body field on `PATCH`, or the `?expected_revision=N`\n  query parameter on any of the four. On mismatch nothing is written and the\n  response is `409` with\n  `{\"error\":{\"code\":\"revision_conflict\",\"current_revision\":N,...}}`. Omitting\n  it behaves as before.\n- `Idempotency-Key` (1-255 visible ASCII characters) is optional on `POST\n  /v1/automations`, `PATCH`, `run`, `pause` and `resume`. The first successful\n  response (2xx) is persisted in `idempotency.json` beside the automation\n  store, bounded to 1024 entries and a 24 hour TTL. A retry with the same key\n  and identical request returns the stored status and body with\n  `Idempotency-Replayed: true` and performs no second create or run. The same\n  key with a different request is `422` `idempotency_key_reuse`. Failed\n  attempts are not stored, so they can be retried. Keys are scoped to the\n  route and automation id. If the record cannot be persisted after the\n  mutation committed, the success response is still returned (the failure is\n  logged), so a retry of that request is not deduplicated. An unparseable\n  `idempotency.json` is renamed to `idempotency.corrupt-.json` and\n  that request fails with `500`; later requests start from an empty store.\n  `DELETE` is not covered.\n\n**Operate** (always-on named operation; same `OperateRecord` as CWC\n`20de981` / PR #284)\n\n- `GET /v1/operate` — current operation + plan board\n- `POST /v1/operate` — create (`direction`, optional `burnRate`)\n- `PATCH /v1/operate` — steer direction, `burnRate`, or `leadPlan`\n- `PUT /v1/operate/plan` — set `leadPlan` (`{ slices: [...] }`)\n- `POST /v1/operate/keepalive` — observe spend / burn; never stops\n- `POST /v1/operate/cancel` — explicit cancel (`/v1/operate/stop` aliases);\n  also pauses the `cw-operate` keepalive so nothing keeps spending after cancel\n- `POST /v1/operate/auto-merge/check` — call landed\n  `scripts/check-auto-merge.py --repo --pr --agent` (does not merge)\n\nThe operation record (`current.json`) is persisted under a cross-process\nfile lock with atomic temp+rename writes; every PATCH / keepalive / plan\nsave reloads the latest state inside the lock, so concurrent saves merge\ninstead of losing writes. A `PATCH` that changes `direction` invalidates\nthe recorded `leadPlan` (workers stop executing superseded slices) and\npulls the keepalive lead run forward to re-plan. `POST /v1/operate`\ninstalls the hourly `cw-operate` keepalive and kicks its first lead-plan\nrun immediately instead of waiting out the first recurrence; credentials\nresolve through the normal Z.ai provider resolution (config, `api_key_env`,\nsecret store, or provider env vars — blank values count as missing).\n\n`burnRate` is `{ \"kind\": \"usd_per_hour\", \"amountUsdPerHour\": number }`,\na positive number, or `null` (unbounded). Status is\n`planning | running | idle_blocked | cancelled`. Pace\n(`unbounded | hold | throttle | widen`) is not a status: over-target\nthrottles, under-target widens, no wallet-cap stop. Idle-blocked only\nfor empty direction, awaiting lead plan, missing credentials, or a\nhuman gate. Auto-merge is `scripts/check-auto-merge.py --repo … --pr …\n--agent …` from codewhale-ops `origin/main` (exit 0), then\n`scripts/auto-merge-pr.py`. Do not invent a second checker.\n\n**Introspection**\n- `GET /v1/workspace/status`\n- `GET /v1/workspace/files/search?query=&limit=<1-100>` (see workspace file suggestions above)\n- `GET /v1/workspace/files?path=&limit=<1-2000>`, `GET /v1/workspace/files/read?path=&offset=&limit=`\n  and `PUT /v1/workspace/files` (see workspace files and session artifacts above)\n- `GET /v1/skills`\n- `GET /v1/skills/{name}` — one skill's routing metadata (`source`,\n  `invocation`, `aliases`, `bundled_tier`, `enabled`) plus its full\n  `SKILL.md` body, so a client can compose an activation instruction for its\n  own next turn the way TUI's `/skill ` does. `404` for a name no\n  discovery root holds; `403` for a plugin snapshot whose authority is no\n  longer current; native rows whose file has since been deleted also `404`\n  rather than serving the stale body. Advertised as\n  `capabilities.skill_detail` on `GET /v1/runtime/info`, which is the source a\n  client should use rather than probing this path: a `404` here means \"no such\n  skill\" and is indistinguishable from \"no such route\".\n- `GET /v1/apps/mcp/servers`\n- `GET /v1/apps/mcp/tools?server=`\n\nSkill activation toggles are persisted under a cross-process transaction lock.\nEach mutation reloads and merges the latest exact-name state before an atomic\nwrite, and `GET /v1/skills` refreshes that shared state so another Codewhale\nprocess's successful toggle is visible without restarting the Runtime API.\n\nSkill rows on `GET /v1/skills` carry `invocation`, `aliases`, and\n`bundled_tier` alongside the fields they always carried, so a client can build\na picker, autocomplete, or activation gate without a second request per row.\n\n**Usage** (token/cost aggregation across threads)\n- `GET /v1/usage?since=&until=&group_by=`\n\n`since` / `until` are inclusive RFC 3339 timestamps and may be omitted (no\nbound). `group_by` defaults to `day`. Buckets are sorted by ascending key.\nEmpty time ranges produce empty `buckets` (never a 404). Cost is computed via\nthe model→pricing map; turns whose model has no pricing entry contribute\ntokens but `0.0` cost. Added in v0.8.10 (#564).\n\n```json\n{\n  \"since\": \"2026-04-01T00:00:00Z\",\n  \"until\": \"2026-04-30T23:59:59Z\",\n  \"group_by\": \"day\",\n  \"totals\": {\n    \"input_tokens\": 12345,\n    \"output_tokens\": 6789,\n    \"cached_tokens\": 0,\n    \"reasoning_tokens\": 0,\n    \"cost_usd\": 0.012,\n    \"turns\": 42\n  },\n  \"buckets\": [\n    {\n      \"key\": \"2026-04-30\",\n      \"input_tokens\": 1234,\n      \"output_tokens\": 678,\n      \"cached_tokens\": 0,\n      \"reasoning_tokens\": 0,\n      \"cost_usd\": 0.001,\n      \"turns\": 3\n    }\n  ]\n}\n```\n\n### Native client routes (GPUI desktop)\n\nThese families serve the GPUI desktop client over the same bearer-token\ntransport. They reuse the runtime's existing authorities — the engine's shell\nmanager, the durable thread store, the workspace confinement layer, the\nconfig's credential plumbing — and add no second runtime, session store,\nscheduler, or credential store.\n\n**Terminal sessions** (the persistent Engine-owned shell)\n\nThe jobs family above runs one command per job. A terminal pane needs the\n*other* authority: the stateful PTY-backed shell the agent's own terminal\ntools drive, which keeps cwd and environment across inputs. These routes\nattach to that session and never create one — a name with no live session is\n`404`, because conjuring a shell from an HTTP request would give the client a\nterminal the Engine does not know about. Input is attributable by route:\n`input` is the client's writer, `terminal_send` is the agent's.\n\n- `GET /v1/terminal/{name}/output?cursor=&max_bytes=<1-64KiB>&format=\n  ` — the resumable byte stream. `{name, offset, next_cursor,\n  total, dropped, encoding, data, running, exit_code}`: pass `next_cursor`\n  back to continue; reads never consume, so several clients may hold\n  independent cursors; `dropped` reports bytes the 512 KiB ring discarded,\n  and a cursor past `total` is answered from `total` rather than echoed back\n- `POST /v1/terminal/{name}/input` — `{ \"data\", \"encoding\"? }`, `base64` by\n  default (exact bytes) or `text` for UTF-8 → `{ \"name\", \"written\" }`\n- `POST /v1/terminal/{name}/resize` — `{ \"rows\", \"cols\" }` → the kernel\n  window the child draws for\n- `POST /v1/terminal/{name}/kill` — end the shell; observe the exit through\n  `output` (`running` / `exit_code`) rather than the acknowledgement\n\n`GET /v1/runtime/info` advertises `terminal_stream`, `terminal_input`,\n`terminal_resize` and `terminal_kill`. All four are `false` on Windows and OpenHarmony builds\ntoday: the owner is Unix-only, those routes answer `501`, and a client\nshould gate its terminal controls on these flags rather than discovering it\nfrom a failed request. Known limitations, stated because a reader would\notherwise assume them: there is no `wait_ms` long poll (poll the cursor),\nscrollback dropped by the ring is gone with the process, a restarted Engine\nreports no session rather than pretending to reattach, and the\n`@codewhale/runtime-sdk` package has no terminal client wrapper yet — the raw\nroutes are the contract for now.\n\n**Jobs** (operator-scoped shell jobs; the terminal surface)\n- `GET /v1/jobs` — every live and known-stale job across all threads\n- `GET /v1/threads/{id}/jobs` — jobs owned by one thread's manager:\n  model-launched, subagent-launched, and client-launched together\n- `POST /v1/threads/{id}/jobs` — `{ \"command\", \"cwd\"?, \"timeout_ms\"?,\n  \"tty\"?, \"env\"? }` → `201 { \"job\" }`; runs as a background shell under the\n  thread's projected sandbox policy. `tty: true` merges stderr into stdout\n  and gives the command a terminal (required for interactive programs);\n  background jobs are never killed at `timeout_ms`. A relative `cwd`\n  resolves against the thread workspace. Outside trust mode `cwd` must stay\n  inside it after symlinks resolve (`403` otherwise): unlike the shell tool,\n  this route does not follow `workspace_follow_symlinks` or `/trust add`\n  roots, so a symlink leading out of the workspace is refused. The job runs\n  in the resolved directory that was checked (a later symlink retarget does\n  not move it); a `cwd` that resolves to a non-UTF-8 path is a `400`\n- `GET /v1/threads/{id}/jobs/{job_id}` — one job's status + metadata\n- `GET /v1/threads/{id}/jobs/{job_id}/output?stream=&cursor=\n  &max_bytes=<1-512KiB>&wait_ms=<0-30s>&format=` — the\n  resumable byte stream. `{job_id, stream, offset, next_cursor, total,\n  dropped, encoding, data, status, exit_code, done}`: pass `next_cursor`\n  back to continue; `wait_ms` long-polls for new bytes on a running job;\n  `done` means a terminal status and nothing left past the cursor\n- `POST /v1/threads/{id}/jobs/{job_id}/stdin` — `{ \"data\", \"encoding\"?,\n  \"close\"? }`: `data` is UTF-8 text by default or `base64`, `close: true`\n  sends EOF; works for PTY and piped jobs → `204`\n- `POST /v1/threads/{id}/jobs/{job_id}/kill` — bounded SIGTERM → SIGKILL\n  escalation on the process group → `{ \"job\", \"result\" }` with the final\n  snapshot\n\nReads are non-consuming: several clients may hold independent cursors, and\npolling never steals output from the engine's own delta consumer. The\nbuffer is bounded with exact drop accounting — a reader whose `cursor`\nfalls behind the retained window gets `offset` past it and `dropped > 0`,\nand must re-anchor. Evicted jobs keep a tail snapshot which the output\nroute serves as the final retained window. Jobs are scoped to the thread\nthat created them and are killed when that thread is removed; the engine's\nbackground commands use the same per-thread manager, so `GET /v1/jobs` is\nalso how a client sees model-spawned work.\n\n**Commands** (typed command catalog, APPS-28)\n- `GET /v1/commands` — `{commands: [...]}`: every registered slash command,\n  builtin and user, as the TUI's own registry holds it. Per entry: `name`,\n  `aliases`, `summary` and `usage` (English source text — localizing is the\n  client's surface), `subcommands` (the literal verbs the usage line\n  declares), `takes_arguments`, `kind` (`builtin` registered code, or `user`\n  expanding a stored template), `binding` (`host` runs locally and never\n  reaches the model; `prompt` expands into the request the model sees),\n  `discovery` (`primary` / `advanced` / `compatibility`, builtins only),\n  `hidden` for rows the product does not advertise, and `shadowed_by` /\n  `shadowed_aliases` where a user command has taken a builtin's spelling.\n\n  Each entry also carries the composer argument shape, computed the way the\n  TUI composer computes it, so a client does not re-derive it from `usage`\n  (#6230):\n  - `requires_argument` — the usage line mentions any argument, required or\n    optional.\n  - `requires_required_argument` — the usage line has a `` argument\n    outside every `[optional]` group.\n  - `composer_wants_trailing_space` — accepting the command leaves a trailing\n    space for its arguments.\n  - `palette_runs_directly` — the palette runs the command on selection\n    instead of pasting it into the composer.\n  - `show_in_empty_discovery` — the command is listed when the slash menu\n    opens with no filter text.\n\n  User commands derive these from `takes_arguments`: their arguments are\n  never required, a template that takes arguments waits in the composer and\n  one that does not runs directly, and a `hidden` template stays out of empty\n  discovery.\n\n  The same registry the TUI palette reads, so a desktop palette can be\n  checked against it instead of drifting from it. Two rules a client must\n  respect: a `binding: \"host\"` row is never submitted as a model prompt, and\n  a user command shadowing a builtin name wins that spelling.\n\n**Hooks**\n- `GET /v1/hooks[?thread_id=...]` — `{workspace, enabled, hooks: [...],\n  problems: [...]}`: the hook set Runtime API threads run for that workspace\n  (the server workspace, or the named thread's). Per entry: `name`, `event`,\n  `command` (credential-shaped values masked; every URL keeps only its scheme and host), `background`, `timeout_secs`,\n  and `source` (`global` user config, `plugin` reviewed plugin, `project`\n  trusted and approved `.codewhale/hooks.toml`). `problems` lists hooks\n  rejected or warned about at load, one line each.\n\n  Every Runtime thread builds its engine with this set: `tool_call_before`\n  can deny a call, `shell_env` applies to shell tools, and `tool_call_after`\n  and `on_error` (for a failed tool) fire as observers, as they do in the TUI.\n  Clients read this route instead of keeping a hook table of their own.\n\n**Context** (per-thread context pressure, APPS-90)\n- `GET /v1/threads/{id}/context` — `input_tokens` (the conservative live\n  estimate the visible meter uses), `billed_input_tokens` (last\n  provider-counted prompt size when one exists), `window_tokens`,\n  `output_cap_tokens`, `input_budget_ceiling`, `available_input_tokens`,\n  `compaction_trigger_tokens`, `usage_percent` and `pressure`. Served by the\n  live engine via `Op::GetContextBudget`. Every numeric field is nullable —\n  a route that cannot express a bounded window reports `null` rather than an\n  invented number — and `live: false` marks responses where the engine\n  could not be loaded and only the store-recorded route's static window\n  resolved.\n\n**Git** (workspace repository operations, APPS-106)\n- `GET /v1/git` — status detail: `git_repo`, `branch`, `head`\n  (abbreviated, for display), `head_oid`, `index_token`, `revision`,\n  `ahead`/`behind`, counts, per-file porcelain `files[]`\n  (`{path, index, worktree, staged, status, old_path?, rev}`), `branches`,\n  `remotes`. `files[].path` and `old_path` are workspace-relative, the same\n  frame the write routes take; in a workspace that is a subdirectory of its\n  repository, rows outside the workspace are not listed (the counts stay\n  repository-wide). A rename out of the workspace appears as its source\n  deletion. Normal Git filters and untracked settings apply. Untracked\n  directories stay collapsed; only a row naming the workspace itself is\n  expanded to individually addressable files. The precondition tokens are\n  opaque:\n  - `head_oid` — the full HEAD commit id; `null` on an unborn branch or when\n    HEAD cannot be read\n  - `index_token` — the whole index (mode, blob, stage and path of every\n    entry, repository-wide). A stat-only refresh by `git status` does not\n    change it. Status remains best-effort on repository read failures;\n    unavailable tokens are `null`, and broken HEADs cannot satisfy guards\n  - `files[].rev` — one row: its index entries plus the working-tree state\n    of every file it covers (including a rename's in-workspace source).\n    Content tokens (`c-…`) include file bytes, the executable bit on Unix,\n    symlink targets, and submodule HEAD/status. Submodule dirty contents\n    are summarized by porcelain status, not recursively hashed; ordinary\n    stage/discard do not write those contents. A read hashes at most\n    64 MiB / 4,096 files; rows past that budget or containing a file over\n    16 MiB carry a display-only size-and-mtime token (`s-…`). Those tokens\n    cannot guard writes. Unreadable paths, paths beneath symlinked\n    directories, special files and broken nested repositories have `rev: null`;\n    other rows retain their tokens. If a broken tracked submodule aborts\n    porcelain, ordinary rows are recovered without submodule recursion and\n    the unreadable submodule is shown with `status: \"unknown\"`, `worktree: \"?\"`\n  - `revision` — the whole tree: `head_oid`, `index_token`, every row's\n    `rev`, including the working-tree state of rows outside a subdirectory\n    workspace. `null` when any row is stat-only or unreadable, a repository\n    read is incomplete, or untracked paths are hidden; whole-tree guarded\n    writes are unavailable in that case. Clients must not silently omit a guard\n- `GET /v1/changes` — the same porcelain `files[]` projection plus\n  `head_oid`, `index_token` and `revision`, minus repo chrome\n  (branches/remotes): one authority, so the change list can never disagree\n  with the status read\n- `GET /v1/diff?path=` — one file's unified `diff` against `base` (`HEAD`,\n  or the empty tree on an unborn branch — which reads staged adds as new\n  files). Covers staged+unstaged in one patch; `truncated` reports the\n  512 KiB cap. An untracked file answers `untracked: true` with an empty\n  diff — the client reads the file itself rather than mistaking it for\n  unchanged\n- `GET /v1/workspace/diff?limit=` — whole-tree patch (default 256 KiB,\n  max 4 MiB) plus a complete `--numstat` `files[]` inventory\n  (`{path, added, deleted}`) so every changed row renders even when the\n  patch is truncated\n- `GET /v1/git/graph?limit=` — bounded commit rows (`id`, `short`,\n  `parents`, `author`, `timestamp`, `refs`, `subject`); an unborn branch is\n  an empty graph, not an error\n- `POST /v1/git/stage` `{ \"paths\": [...] }` or `{ \"all\": true }`;\n  `POST /v1/git/unstage` same; `POST /v1/git/discard` `{ \"paths\": [...] }`\n  (tracked paths only — no `all`, an untracked path fails closed);\n  `POST /v1/git/commit` `{ \"message\", \"all\"? }`; stage, unstage, discard\n  and commit also take an optional `expect` (below); `POST /v1/git/push`\n  `{ \"remote\"?, \"set_upstream\"? }` (`remote`, or `origin` when only\n  `set_upstream` is given, must name a configured remote);\n  `POST /v1/git/branch`\n  `{ \"name\", \"create\"? }`\n\nDiffs and precondition token reads run through the hardened review command\n(filters, fsmonitor, hooks, lazy fetches and replace-objects neutralized).\nPorcelain status uses normal Git, matching the workspace counts and filters;\nwrites run through the non-interactive command path\n(`GIT_TERMINAL_PROMPT=0`, BatchMode ssh) so a credential or host-key prompt\ncan never hang a request. Path lists are workspace-relative under the same\nconfinement as the file routes (traversal → 400, `.git` → 403), passed after\n`--` under `--literal-pathspecs`, so `src/*` names a file called `*` and is\nnever a glob. (Unstage of the whole tree uses the `:/` root pathspec.)\nMutations answer `{ok, output, status, current}`: the refreshed status and\nthe full `GET /v1/git` detail, so a client re-reads nothing after an\noperation and can chain the next write from fresh tokens. A workspace that\nis not a repository answers `404`.\n\n*Preconditions.* Stage, unstage, discard and commit accept\n`expect: { head?, index?, revision?, files? }`, built from the last\n`GET /v1/git`. Each present field is checked and an absent one is not;\n`head: null` means \"HEAD must still be unborn\". Without `expect` (or with\n`expect: {}`) a write behaves exactly as before. Recommended use:\n\n| Operation | `expect` |\n| --- | --- |\n| stage / unstage `paths` | `{head, files: {path: rev}}` |\n| stage / unstage `all` | `{head, revision}` |\n| discard (always — it destroys edits) | `{head, files: {path: rev}}` |\n| commit | `{head, index}` |\n| commit `all` | `{head, revision}` |\n\nMalformed preconditions answer `400` before anything runs: `head` must be a\n40- or 64-hex id or `null`; `index` and `revision` 64 hex (an explicit\n`revision: null` is refused); `files` values a content-safe `c-` rev (`s-`\ntokens answer `400`). `files` keys\n(workspace-relative; `dir/` and `dir` are the same key)\nmust name exactly the requested paths, so no path is left unguarded by\naccident. `files` is refused with `all: true` (use `revision`) and on\ncommit. An unknown key inside `expect` is rejected like any other unknown\nfield. When the repository no longer matches, the route writes nothing and\nanswers `409`:\n\n```json\n{ \"error\": { \"message\": \"The repository changed since it was read (HEAD moved; src/a.rs changed). Nothing was written; refresh and review again.\",\n             \"status\": 409, \"code\": \"git_state_changed\" },\n  \"stale\": [\"head\", \"files\"], \"stale_paths\": [\"src/a.rs\"],\n  \"current\": { \"...\": \"the GET /v1/git detail\" } }\n```\n\n`stale` lists the components that moved (`head`, `index`, `files`,\n`revision`); `stale_paths` the `files` keys whose `rev` changed. A client\nre-renders from `current`, keeps the user's selection, and asks again.\n\nStage, unstage, discard, commit and branch from one runtime are serialized,\nso a check and its write are atomic with respect to that runtime's other\nwindows; a second concurrent write answers `409` with\n`error.code: \"git_busy\"` rather than queueing behind a long commit hook.\nPush is not serialized: it only moves a remote ref and can wait on the\nnetwork for up to 120 s. The lock does not cover processes outside this\nruntime — a terminal, an editor, or Codewhale's own agent tools — which can\nstill change the repository in the moment between the check and git taking\n`index.lock`; a truly concurrent git write then fails on git's own\n`index.lock` (a `400` carrying git's message). A compare-and-swap commit via\n`commit-tree` and `update-ref` would close that window but skip the\nrepository's hooks, which a Review-sheet commit must run, so it is not used.\n\n**Diagnostics** (read-only logs, crashes, process — APPS-103)\n- `GET /v1/logs` → `{sources: [{dir, files: [{name, size, modified}]}]}` —\n  the runtime's log directory plus `audit.log[.1]` from the codewhale home,\n  newest first, capped\n- `GET /v1/logs/{name}?offset=&limit=&tail=` →\n  `{name, size, modified, offset, bytes, truncated, encoding, content}` —\n  one bounded window; `tail` reads from the end and is mutually exclusive\n  with `offset`; `truncated` means bytes remain after the returned window\n  (a tail read at EOF is `false`), `encoding` is `utf-8` or `base64`\n- `GET /v1/crashes`, `GET /v1/crashes/{name}` — the same list/read contract\n  over the crash-dump directories (`~/.codewhale/crashes`, legacy\n  `~/.deepseek/crashes` merged)\n- `GET /v1/process` → `{pid, version, commit, started_at, uptime_seconds,\n  executable, rss_bytes}` — `rss_bytes` only where the platform reports it\n  (Linux `/proc`); absent rather than fabricated elsewhere\n\nThese routes package what already exists on disk for a client-side export;\nthere is no telemetry upload route and no second log store. Names are\nbasename-validated (no separators, no `..`), listings are capped, reads are\nbounded windows, and symlinks are never followed — a client bundles the\nfiles itself.\n\n**Targets and remote posture** (APPS-50)\n- `GET /v1/targets` → `{targets: [self], remote: {supported: true,\n  attach: \"client\", probe: \"POST /v1/remote/connect\"}, ssh: {…},\n  cloud: {…}}` — this runtime's own record as the attachable target plus\n  per-surface ownership; the runtime keeps no persistent target registry,\n  so `POST /v1/targets` and `POST /v1/targets/switch` answer\n  `501 Not Implemented` — target selection is client-owned and a switch\n  must never move a running task server-side\n- `GET /v1/remote` → `{bind_host, port, loopback_only, reachable_from_lan,\n  auth_required, mobile, tls}` — this listener's reachability posture.\n  `tls` is always `false`: the API has no TLS terminator, so non-loopback\n  reachability assumes a verified overlay (VPN/mesh), never plain LAN trust\n- `POST /v1/remote/connect` `{ \"endpoint\": \"http://host:port\" }` — probes a\n  candidate remote's unauthenticated `GET /v1/runtime/info` (origin only;\n  any pasted path is discarded). Answers `{ok, remote: {endpoint,\n  runtime_api_version, codewhale_version, auth_required, …}, attach:\n  \"client\"}` on success, and `{ok: false, reason: \"unreachable\" |\n  \"not a Codewhale runtime\" | …}` as data on failure. URLs carrying\n  credentials are refused with 400 — the remote's token is configured\n  client-side, and a connect route that forwarded one would be an\n  exfiltration primitive\n- `GET /v1/ssh`, `GET /v1/cloud` → `{supported: false, owner:\n  \"codewhale-control-plane\", reason}`; `POST /v1/ssh/connect` and\n  `POST /v1/cloud/attach` → `501`: SSH workspace provisioning and hosted\n  cloud computers belong to the Apps control plane (ASCII Box for Managed\n  Computer), not to a second authority inside Core\n\nA remote Codewhale is a `serve --http` runtime with a token — that is the\nwhole attach model. These routes describe and probe it; they never execute\na remote request on the local machine.\n\n**LSP** (workspace language intelligence, APPS-93)\n- `GET /v1/lsp` — capability: `enabled`, supported `languages` with their\n  server commands, `custom_languages`, operations, poll and diagnostic caps\n- `GET /v1/diagnostics?path=` — file diagnostics\n- `GET /v1/definition?path=&line=&character=` (1-based)\n- `GET /v1/references?path=&line=&character=` (1-based)\n- `GET /v1/symbols?path=&query=` — empty query returns document symbols\n\nOne lazily-built workspace-level `LspManager` serves these; engine threads\nkeep their own per-thread managers for the post-edit hook, and a server that\nnever serves an LSP route never spawns a language server. `path` is\nworkspace-relative under the same confinement as the file routes. Normal\nabsence is data: no language server, a disabled `[lsp]` config, or a timeout\nanswers `200` with `ok: false` and a machine-readable `reason`\n(`no_server`, `lsp_disabled`, `lsp_error`); malformed input is a 400 and a\nmissing file a 404.\n\n**Voice** (host dictation, APPS-98)\n- `GET /v1/voice` — capability: `available`, detected `recorder` command,\n  resolved `asr` `{kind, model}`, `modes`, `send_phrases`,\n  `max_record_seconds`\n- `POST /v1/voice/dictate` — record then transcribe → `{ ok, text }`\n- `POST /v1/voice/send` — same capture with the \"send it\" / 发送/發送\n  suffix contract: `send: true` tells the client to submit (empty `text`\n  with `send: true` means submit the client's current draft)\n- `POST /v1/voice/control` `{ \"composer\": \"draft text\" }` — assisted\n  dictation that shows the model the composer text; `assisted: false` in the\n  response means a free ASR backend (local whisper/Groq) handled the audio\n  and the composer context was never seen\n\nThe runtime owns the host microphone and the ASR dispatch — the same\nimplementation the TUI's `/voice` commands run, headless. Recording is one\nblocking capture per host (requests serialize; the loser gets\n`ok:false`/`no_speech`, not a fought-over device). Provider ASR resolves its\nkey lazily so local-whisper and Groq paths work without provider auth.\nInterim and final transcription use the selected ASR backend. If local whisper\nor Groq fails, the error stays on that backend: the runtime never retries the\nrecording or composer text with the active model provider. Select provider ASR\nexplicitly to use that route.\nFailure is data: `no_recorder`, `no_speech`, `no_provider_auth`,\n`transcription_failed`. `CODEWHALE_DISABLE_VOICE=1` is an operator\nkill-switch — a headless `serve --http` host reports `available: false` and\nevery dictate call fails closed.\n\n## Provider and model selection\n\nThese three routes are how a GUI renders a model picker whose contents are true\nfor *this* runtime instead of guessed from a version snapshot. They were\nundocumented until 2026-08-04, which cost a desktop integration a day: the\nclient probed `/v1/models`, `/v1/runtime/models`, and `/v1/runtime/providers`\n(all correctly 404) and concluded the capability did not exist.\n\n### `GET /v1/providers`\n\n```json\n{\n  \"current\": \"modelstudio-token-plan\",\n  \"providers\": [\n    {\n      \"id\": \"modelstudio-token-plan\",\n      \"model_provider_id\": \"modelstudio-token-plan\",\n      \"display_name\": \"Alibaba Cloud Model Studio\",\n      \"default_model\": \"qwen3.8-max\",\n      \"has_model_catalog\": true,\n      \"credentialState\": \"configured\"\n    }\n  ]\n}\n```\n\n`current` is the active generic provider id. Only the active entry carries an\nexact identity: an active built-in normally repeats its canonical id in\n`model_provider_id`, while an active named custom route has `current` set to\n`custom` and the exact configured key there (for example `lm-studio`). Other\nentries have a null exact id; a null id on the active `custom` entry identifies\nthe released legacy root-level custom route. Preserve both fields from the\nselected entry and send a non-null exact id back as `POST /v1/threads`'s\n`model_provider_id`; dropping a named custom id would collapse the selection to\nthe legacy root custom route. `credentialState` is a stable, non-secret\nprojection of the Runtime's existing structural credential classification:\n\n- `configured`: credential material is structurally available;\n- `login_required`: the route needs login or a usable login capability;\n- `missing`: an API-style credential is unavailable;\n- `no_auth`: the route explicitly disables credential use;\n- `local`: the exact route is local and keyless;\n- `legacy`: the compatibility route cannot be classified more precisely.\n\nFor an active named custom provider, this state is calculated from the exact\nroute named by `model_provider_id`, not from a generic custom-provider default.\nIt deliberately collapses saved-key and imported-token details into\n`configured`, and login/consent-source details into `login_required`.\n\nThe response never includes endpoint URLs, credential environment-variable\nnames, filesystem paths, credential values, consent-source details, or token\nmetadata. `credentialState` is not a provider canary: `configured` does not\nprove endpoint reachability, credential validity, model entitlement, or a\nsuccessful request. The models route below is also only a selection catalog; a\nnon-empty list does not prove that the route can currently serve a request.\n\n### `GET /v1/providers/{id}/models`\n\n```json\n{\n  \"provider\": \"deepseek\",\n  \"models\": [\n    {\n      \"id\": \"deepseek-v4-flash-vision-exp\",\n      \"image_input\": \"supported\",\n      \"reasoning_effort\": \"unknown\",\n      \"reasoning_effort_levels\": [],\n      \"reasoning_effort_source\": null\n    }\n  ]\n}\n```\n\nFor an exact configured route, supply `?model_provider_id=vision-work` and\nrequire the response to echo that same `model_provider_id`. The Runtime resolves\nthat identity under the requested provider kind before reading model support.\nUnknown or mismatched identities return `400`. Named pagination cursors bind the\nconfiguration identity, endpoint and catalog snapshot; changing any of those\nrequires restarting pagination. Omitting the query preserves the legacy catalog\nprojection and omits the identity echo.\n\nThe catalog for one provider. Returns `400` for an unknown id, and for the\nlegacy `deepseek-cn` alias, which has no provider metadata — use `deepseek`.\nAn empty `models` array means the Runtime has no discoverable or configured\nmodel ids for that provider; it does not report credential presence.\n\nThe ids returned here are exactly the values accepted by `POST /v1/threads`'s\n`model` field and by the switch route below. `image_input` is the exact resolved\nprovider/model route's capability state: `supported`, `unsupported`, or\n`unknown`. Keep `unknown` unknown rather than inferring from the model name or\nwire protocol. `supported` describes the model route; it does not mean a given\nclient implements an image-upload control.\n\n`reasoning_effort` uses the same three capability states and describes whether\nthe exact model's metadata publishes a selectable effort ladder.\n`reasoning_effort_levels` contains only canonical, recognized active effort levels\nfrom that metadata. Off and provider synonyms such as none are excluded: the\nApps/Chat protocol treats off as omission, which does not prove support for an\nexplicit provider disable command. A model capable of reasoning may still have\nan unknown active effort ladder.\nCodex levels are also excluded when native compatibility would change their\nwire value (currently minimal and auto). This projection does not change native\ncompatibility behavior or advertise a tier the Runtime cannot send unchanged.\nNo levels are inferred from a provider-wide default or a familiar model name\non a custom endpoint. `reasoning_effort_source` identifies `catalog`,\n`codex_cli_cache`, or `codex_app_server`; missing, stale, and unrecognized model\nmetadata stays unknown. Codex roster metadata describes the external CLI's\nroster, not proof that a separately configured Runtime credential belongs to\nthe same account or that an authentication boundary is approved.\n\nPass `?model_provider_id=` when selecting a named route.\nThe Runtime validates the provider kind and exact identity together, returns\n`model_provider_id` alongside that route's model list, and leaves the active\nconfiguration unchanged. An empty or unknown requested identity, or a mismatched kind, returns\n`400`; it never falls back to another named route.\n\nThe Runtime Chat relay publishes the same effort fields in camelCase\n(`reasoningEffort`, `reasoningEffortLevels`, `reasoningEffortSource`). These\nmodel facts do not enable tool execution or establish account entitlement.\n\nFor a thread-scoped choice, send the provider fields from the selected entry\nalongside the selected model. Omit `model_provider_id` when it is null:\n\n```json\n{\n  \"model_provider\": \"custom\",\n  \"model_provider_id\": \"lm-studio\",\n  \"model\": \"local-vision-model\"\n}\n```\n\nThis creates one thread on the exact named custom route without changing the\nRuntime's provider or model defaults.\n\n### `PUT /v1/providers/{id}/key` — write-only credential\n\n```json\n// request\n{ \"key\": \"sk-…\" }\n\n// response\n{ \"provider\": \"openai-codex\", \"stored\": true, \"backend\": \"keychain\",\n  \"credentialState\": \"configured\", \"configPath\": \"/…/config.toml\" }\n```\n\nStores a provider API key through the same transactional write as\n`codewhale auth set --provider  --api-key-stdin`: the secret store under\nthe provider write lock, plus the `[providers.] auth_mode` metadata\nmarker persisted to the config document and mirrored into the live runtime\nconfig so `GET /v1/providers` reports the new state immediately. `backend`\nnames which secret backend holds the key and `configPath` which config\ndocument carries the marker (the user-global file when the ambient config\nis workspace-scoped).\n\nThe key is never returned — there is no read route for credential material,\nand neither the key nor its length appears in the response, errors, or\nlogs; the response carries only the readiness projection\n(`credentialState`). An unknown provider id, the `deepseek-cn` legacy\nalias, an empty key, a key over 4 KiB, or one containing control characters\nis `400`. `credentialState: \"local\"` after a successful write is honest\noutput for a keyless local route: the key is stored, but the route\nclassifies as not needing one.\n\n### `DELETE /v1/providers/{id}/key` — clear a Codewhale-owned credential\n\n```json\n// response\n{ \"provider\": \"openai\", \"cleared\": true, \"credentialState\": \"missing\" }\n```\n\nClears the credential through the same shared owner as\n`codewhale auth clear`: the config document is snapshotted and restored if\nits save fails, the secret store is only touched once that save has landed,\nand the cleared markers are mirrored into the live runtime config so\n`GET /v1/providers` reports `missing` on the next read rather than after a\nrestart.\n\nClearing an already-clear route returns `cleared: true` — a client retrying\na revoke must not be told something went wrong. If the config entry is\ncleared but the secret backend refuses the delete, the route answers `500`\nand names the slot: reporting success while the key is still in the keyring\nwould be a lie about a security action.\n\n### Credential ownership: `credentialSource` and `credentialWritable`\n\nBoth credential verbs refuse a route whose credential Codewhale does not\nown, and `GET /v1/providers` carries the same classification so a client can\ndisable its control *before* submitting instead of failing late:\n\n| `credentialSource` | `credentialWritable` | Meaning |\n| --- | --- | --- |\n| `secret_store` | `true` | Codewhale's own durable backend. The only writable source. |\n| `config` | `false` | A literal key in a config file, which still wins at request time. |\n| `external_auth` | `false` | An active external consent (OAuth) owns the credential. |\n| `none` | `false` | The route sends no credential, or has no credential slot. |\n\nWhen `credentialWritable` is `false`, `credentialWritableReason` carries\nuser-facing copy naming the owner, and both `PUT` and `DELETE` answer `409`\nwith that same reason. The classification is structural: it reads declared\nauth mode, consent state and the *kind* of any configured `api_key` value,\nand never resolves a secret, an environment value, or an auth command. It is\na class and never a value, a path, or an environment variable name.\n\n### `POST /v1/providers/{id}/switch`\n\n```json\n// request  (model is optional; omit to take the provider default)\n{ \"model\": \"qwen3.8-max\" }\n\n// response\n{ \"provider\": \"modelstudio-token-plan\", \"model\": \"qwen3.8-max\",\n  \"message\": \"…\", \"persisted\": true }\n```\n\n**Use this rather than simulating a switch with repeated `POST /v1/config`\nwrites plus a reload.** Provider and model move together here, the change is\nvalidated against the provider's catalog before it is applied, and `persisted`\nreports whether it was written to config or applied to the live session only.\nRejects an unknown provider id and the `deepseek-cn` alias with `400`.\n\n## Runtime data model\n\nThe runtime uses a durable Thread/Turn/Item lifecycle.\n\n- **ThreadRecord** — `id`, `created_at`, `updated_at`, `model`,\n  `model_provider` (generic kind), `model_provider_id` (optional exact configured\n  route), `workspace`, `mode`, `task_id`, `system_prompt`, `latest_turn_id`,\n  `latest_response_bookmark`, `archived`\n- **TurnRecord** — `id`, `thread_id`, `status` (`queued|in_progress|completed|\n  failed|interrupted|canceled`), `effective_provider`, `effective_model`,\n  `effective_billing_surface`, timestamps, duration, usage, error summary,\n  `artifacts` and `workspace` (see [Turn artifacts](#turn-artifacts))\n- **TurnItemRecord** — `id`, `turn_id`, `kind` (`user_message|agent_message|\n  tool_call|file_change|command_execution|context_compaction|status|error`),\n  lifecycle `status`, `metadata`, `artifacts` and the legacy\n  `artifact_refs` projection\n\nEvents are append-only with a global monotonic `seq` for replay/resume.\n\n`effective_billing_surface` is a non-secret classification derived from the\nendpoint that served the turn. Recognized StepFun routes use `stepfun-payg` or\n`stepfun-plan`; unknown and custom endpoints leave it unset. The raw base URL is\nnot persisted in `TurnRecord`.\n\n### Restart semantics\n\n- If the process restarts while a turn or item is `queued` or `in_progress`,\n  the recovered record is marked `interrupted` with an `\"Interrupted by\n  process restart\"` error.\n- The trailing newline is an event append's commit marker. On startup, a final\n  JSONL fragment without that delimiter is truncated and fsynced even when its\n  bytes form valid JSON; it is an uncommitted append, and its already-reserved\n  sequence number is not reused. Newline-terminated malformed records are not\n  identifiable crash debris and continue to fail closed during replay.\n- If a terminal turn record reached disk but its terminal event sequence did\n  not, the first async read reconciles any unresolved dynamic calls as\n  `tool_call.canceled` and then emits one `turn.completed`. Existing terminal\n  call and turn receipts are detected and never duplicated.\n- Turn-operation bindings survive restart. Retrying the same `operation_key`\n  and request returns the original recovered turn (including an `interrupted`\n  turn recovered from an in-progress process exit); mismatched reuse remains a\n  conflict. A crash-created binding that never acquired a turn is discarded at\n  startup because engine submission happens only after both records are\n  durable.\n- Task execution performs its own recovery on top of the same persisted\n  thread/turn store.\n\n### Approval model\n\n- The `auto_approve` flag applies to the runtime approval bridge and engine\n  tool context. When enabled for a thread/turn/task, approval-required tools\n  are auto-approved in the non-interactive runtime path, shell safety checks\n  run in auto-approved mode, and spawned sub-agents inherit that setting.\n- When omitted, `auto_approve` defaults to `false`.\n- [Authorization order](AUTHORIZATION_ORDER.md) describes where typed rules,\n  registered tool requirements, safety floors, repository law, approval\n  transport, and sandbox enforcement sit relative to one another.\n\n### SSE event stream\n\nThe SSE event payload shape for `/v1/threads/{id}/events`:\n\n```json\n{\n  \"schema_version\": 1,\n  \"seq\": 42,\n  \"previous_seq\": 38,\n  \"event\": \"item.delta\",\n  \"kind\": \"item.delta\",\n  \"thread_id\": \"thr_1234abcd\",\n  \"turn_id\": \"turn_5678efgh\",\n  \"item_id\": \"item_90ab12cd\",\n  \"timestamp\": \"2026-02-11T20:18:49.123Z\",\n  \"created_at\": \"2026-02-11T20:18:49.123Z\",\n  \"payload\": {\n    \"delta\": \"partial output\",\n    \"kind\": \"agent_message\"\n  }\n}\n```\n\nCompatibility notes:\n\n- `schema_version` is the HTTP/SSE envelope schema version. It is independent of\n  the runtime store schema used for persisted thread/turn/event records.\n- `event` remains the SSE event name in existing clients; it is preserved as-is.\n- `kind` mirrors `event` in the stable envelope for typed clients.\n- `seq` is allocated globally across all Runtime threads. Consequently, gaps\n  between a thread's events are normal when other threads interleave. On this\n  per-thread SSE stream, `previous_seq` is the sequence of the last event\n  delivered for this thread (or the requested replay cursor for the first\n  event); clients detect loss by comparing it with their accepted per-thread\n  cursor, not by requiring `seq == previous_seq + 1`. Sequence allocation is\n  also not rewound after an append is transactionally rolled back, so a retry\n  can intentionally skip an unused value without implying a missing event.\n- `thread.started`, `turn.started`, and `turn.completed` are emitted as SSE event\n  names exactly as before.\n- `timestamp` remains the canonical event time for schema version 1. `created_at`\n  is an equivalent alias for clients that use `created_at` naming elsewhere; do\n  not require both fields to be present.\n\n### Ending and resuming a thread stream\n\nWhenever the server ends a `/v1/threads/{id}/events` stream that already\nreturned `200`, the last frame is `stream.end`, sent exactly once and always\n(it does not need `progress=true`):\n\n```\nevent: stream.end\ndata: {\"schema_version\":1,\"event\":\"stream.end\",\"kind\":\"stream.end\",\"thread_id\":\"thr_1234abcd\",\"reason\":\"replay_failed\",\"last_seq\":42,\"retryable\":true}\n```\n\n- It is a transport frame, not a journal event: it has **no `seq`** and **no\n  SSE `id:`**. Clients that acknowledge on `seq` skip it, and a browser's\n  `Last-Event-ID` stays on the last real event.\n- `last_seq` is the stream's cursor when it ended: the last journal `seq`\n  delivered on this connection, or the effective start cursor if none was\n  (after a `replay_limit` tail, that is already past the omitted history). It\n  is exactly the `since_seq` that resumes with no loss and no repeats.\n- `retryable` says whether resuming from `last_seq` can succeed. Key the client\n  on it, not on the reason list. Treat an unknown `reason` by its `retryable`.\n- There is no free-text message. The underlying error is in the Runtime log,\n  and it can contain store paths.\n\n| `reason` | Meaning | `retryable` |\n| --- | --- | --- |\n| `replay_failed` | The durable history read that feeds the opening replay failed, including a replay worker crash after the first cursor | `true` |\n| `catch_up_failed` | After broadcast lag, the durable re-read from the stream's cursor could not be opened or failed | `true` |\n| `runtime_shutdown` | The Runtime API server is stopping (SIGINT, SIGTERM, or SIGHUP; Ctrl+C or Ctrl+Break on Windows). Open streams get this frame within a bounded drain window before the process exits | `true` |\n\nEvery `200` response carries `x-codewhale-stream-end: 1`. With that header, an\nEOF **without** `stream.end` means the connection or the Runtime process died\nwithout the server choosing to end the stream: a network or proxy drop, a\ncrash, or a kill that allows no drain (for example `SIGKILL`). A Runtime that\npredates this frame sends no header; there EOF stays ambiguous, so treat it as\nconnection loss.\n\nClient resume rule:\n\n1. Keep `cursor` = the `seq` of the last journal frame you accepted. Ignore\n   frames with `seq <= cursor`. If a frame's `previous_seq` is not your\n   `cursor`, you missed events: reload the thread snapshot rather than trust\n   local state.\n2. On `stream.end` with `retryable: true`, reconnect with\n   `since_seq = last_seq` after a bounded backoff, and tell the user what the\n   Runtime said (for example, \"Runtime is shutting down — reconnecting\").\n   With `retryable: false`, stop, show the reason, and fall back to the\n   snapshot.\n3. On EOF or a transport error without `stream.end`, reconnect with\n   `since_seq = cursor` after a bounded backoff and show a connection problem,\n   not a Runtime error.\n4. A `401`/`403` or `404` before the stream opens is terminal (a credential or\n   thread problem). Retry `5xx` with backoff.\n5. Never reconnect from `0` to \"start over\": replay is idempotent only by\n   cursor.\n\nFleet streams (`/v1/fleet/runs/{run_id}/events`) keep their own end frames,\n`fleet.stream.error {retryable}` and `fleet.replay.cursor_unavailable`. They\ndiffer from `stream.end`: they carry no cursor, because a Fleet client resumes\nfrom the opaque `cursor` of the last Fleet event it accepted.\n\n### Steer delivery\n\nPutting a steer into the engine's mailbox is not the same as the model reading\nit. The engine discards a steer whose turn has already moved on, and an\ninterrupted or failed turn drops whatever it had queued. The API reports the\nengine's real verdict rather than the attempt:\n\n- The item is persisted `queued` when the steer is accepted into the mailbox.\n- **Delivered.** The engine committed the text into the turn's record: the item\n  becomes `completed`, `steer_count` rises, and `turn.steered` + `item.completed`\n  are emitted. `POST .../steer` returns `200` with that turn.\n- **Not delivered.** The turn moved on, was interrupted, or failed first: the\n  item becomes `canceled`, `steer_count` does not rise, and `turn.steer_dropped`\n  is emitted carrying `input`, `reason`, and the settled `item`. `POST .../steer`\n  returns `409`, so a client can keep the user's text and resend it rather than\n  clearing a composer over guidance that was never seen.\n- **Still pending.** A steer sent while the engine is inside a long tool call\n  cannot settle until that call returns, and the request does not hang for it.\n  After a short wait `POST .../steer` returns `200` with the item still `queued`;\n  the eventual `turn.steered` or `turn.steer_dropped` event carries the verdict.\n\nA client that treats `200` as \"the model saw it\" is therefore wrong in the third\ncase: read the item's status, or wait for the event.\n\nCommon event names: `thread.started`, `thread.forked`, `turn.started`,\n`turn.lifecycle`, `turn.steered`, `turn.steer_dropped`, `turn.interrupt_requested`,\n`turn.completed`, `turn.artifacts`, `item.started`, `item.delta`, `item.completed`,\n`item.failed`, `item.interrupted`, `approval.required`, `approval.decided`,\n`approval.timeout`, `user_input.required`, `user_input.answered`,\n`user_input.canceled`, `tool_call.requested`, `tool_call.resolved`,\n`tool_call.timeout`, `tool_call.canceled`, `sandbox.denied`,\n`turn.workspace_snapshot`, `runtime.store_failure`.\n\n`runtime.store_failure` is the runtime reporting a fault in the operator's own\non-disk state: a thread, turn, or item record under the session's runtime\nstore could not be read, parsed, or written. The payload carries `operation`\n(`read` | `parse` | `write`), `record_kind` (`thread` | `turn` | `item`),\n`record_id`, `path`, the full `error` chain, the root-cause `reason`, a\n`next_action` (which file to move aside, or where to check free space and\npermissions), and a one-line `message`. When `terminal` is `true`, the turn's\nown record is unreadable or unwritable and no `turn.completed` will follow;\nclients waiting on that turn should treat it as failed.\n\nAgent-message and reasoning deltas are materialized into the item projection\nbefore their corresponding `item.delta` event is sequenced. To avoid an fsync\nfor every provider fragment, adjacent deltas are coalesced to configured bounds\nof at most 32 ms or approximately 16 KiB before publication (an indivisible\nupstream chunk can itself exceed the byte target). A process crash inside that\nunpublished window can lose the recent suffix; no durable event claims that\nsuffix existed. Once an `item.delta` is durable, snapshots at or beyond its\ncursor include the same materialized prefix.\n\n`approval.required` events may include a `matched_rule` string when an\nexecution-policy rule caused the prompt. This field is explanatory metadata for\nclients and does not grant or persist permissions.\n\n`approval.required`, `approval.decided`, and `approval.timeout` carry two\ndistinct identifiers. `approval_id` is the Runtime-minted, single-use capability\ndescribed under **Approvals** — the only value `POST /v1/approvals/{id}` accepts\n— and `approval.required` also repeats it in the legacy `id` field for older\nclients. `tool_call_id` is the provider's raw tool-call ID, present for\ncorrelation only. Automatically resolved prompts (thread `auto_approve`, and the\nAuto-Review posture, which never opens a modal) mint an `approval_id` as well, so\nthe field has one meaning on every path; those IDs register no waiter and are\ninert against the endpoint. Clients must never treat `tool_call_id` as an\napproval capability or assume it is unique across threads.\n\nThe thread event stream forwards these payloads intact. The compatibility turn\nstream carries `approval_id`, its `id` alias and `tool_call_id`; the pending\nsnapshot carries the same capability and correlator so reconnecting clients can\nattach an approval prompt to its tool row. Resolutions carry their resolution\nflags through that projection too: `timeout: true` marks the deny produced when\nthe configured decision budget expires, and `cancelled: true` marks a deny\nforced by a turn interrupt or turn teardown where no user selection\nwas made — clients should clear the pending prompt rather than report a\nrefusal.\n\n## Security boundary\n\n- **Localhost by default**. The server binds to `127.0.0.1` by default.\n  `--mobile` is also loopback-only and rejects a non-loopback host until a TLS\n  or verified-overlay transport boundary exists. The runtime does not provide\n  user isolation or TLS.\n- **Optional token guard**. `--auth-token` or `DEEPSEEK_RUNTIME_TOKEN`\n  requires a matching bearer token for `/v1/*` routes. This is a local\n  convenience guard, not a replacement for TLS, VPN, or a trusted reverse\n  proxy on public networks.\n- **No provider-token custody**. The server never returns the API key. The\n  `api_key.source` capability field reports `env`, `config`, or `missing` —\n  never the key itself.\n- **No hosted relay**. The app-server is a local process under the user's\n  control. There is no cloud component.\n- **Capability responses** never leak secrets, file contents, or session\n  message bodies. They report *metadata*: presence, counts, status flags.\n\n### CORS allow-list\n\nThe runtime API ships with a built-in dev-origin allow-list:\n`http://localhost:3000`, `http://127.0.0.1:3000`, `http://localhost:1420`,\n`http://127.0.0.1:1420`, `tauri://localhost`. To add additional origins (e.g.\nwhen developing a UI on Vite's default `:5173`), use any of:\n\n- CLI flag (repeatable): `codewhale serve --http --cors-origin http://localhost:5173`\n- Env var (comma-separated): `DEEPSEEK_CORS_ORIGINS=\"http://localhost:5173,http://localhost:8080\"`\n- Config (`~/.codewhale/config.toml`):\n  ```toml\n  [runtime_api]\n  cors_origins = [\"http://localhost:5173\"]\n  ```\n\nUser-supplied origins **stack on top of** the built-in defaults; they do not\nreplace them. Wildcard origins are not supported — the explicit allow-list\nmodel is preserved. Cross-origin preflights advertise only `Authorization`,\n`Content-Type`, `Accept`, `X-Codewhale-Runtime-Token`, and the compatibility\n`X-DeepSeek-Runtime-Token` request header; custom request headers are not\nallowed. Added in v0.8.10 (#561), tightened in v0.9.1 (#4454).\n\n## Managed Fleet Runtime and SDK helpers\n\nThe Runtime SDK lives in `npm/runtime-sdk` and is exposed as\nthe `@codewhale/runtime-sdk` workspace package. It is deliberately thin: every\nhelper calls the local Rust Runtime API and therefore cannot bypass Codewhale's\nsandbox, approval prompts, provider configuration, or fleet ledger authority.\n\n```js\nimport { createRuntimeClient } from \"@codewhale/runtime-sdk\";\n\nconst client = createRuntimeClient({\n  baseUrl: \"http://127.0.0.1:7878\",\n  token: process.env.CODEWHALE_RUNTIME_TOKEN,\n});\n\nconst created = await client.createFleetRun({\n  target: \"this_computer\",\n  roles: [{ name: \"reviewer\" }, { name: \"verifier\" }],\n  workflow: {\n    id: \"release-check\",\n    kind: \"parallel\",\n    tasks: [\n      { id: \"review\", name: \"Review\", instructions: \"Review locally.\", worker: { role: \"reviewer\" } },\n      { id: \"verify\", name: \"Verify\", instructions: \"Verify locally.\", worker: { role: \"verifier\" } },\n    ],\n  },\n});\n\n// POST /runs only prepares durable work. This call crosses the launch gate.\nawait client.startFleetRun(created.run.id);\n\nlet cursor;\nfor await (const event of client.fleetEvents(created.run.id, { after: cursor })) {\n  if (event.cursor) cursor = event.cursor;\n  if (event.event === \"fleet.replay.cursor_unavailable\") {\n    // Reload getFleetRun(created.run.id), then reconnect without the old cursor.\n  }\n}\n```\n\nThe managed path is deliberately two-step. `POST /v1/fleet/runs` validates and\npersists the run and queue without starting a worker. A separate authenticated\n`POST /start` activates it and schedules the executor driver; its `202` response\nreports `leased: 0` because the driver performs all leasing after it owns the\nrun. Creation requires named roles, one task owner per role, a `parallel`\nWorkflow, and an explicit Runtime target. v0.9.4 executes\nonly `this_computer`; `another_computer` and `cloud` return `501` rather than\nsilently executing locally. Worker IDs are generated per run; caller-assigned\n`worker_specs` return `501` until custom workers can be given collision-free\nmanaged identities. Parallel tasks with overlapping effective write roots are\nrejected before the run is journaled. Managed `security_policy` overrides also\nfail closed until that document can be enforced end to end; executable\nauthority comes from each named role's tool posture and bounded task workspace\nscope.\n\nFleet helpers cover this HTTP surface:\n\n| Helper | Runtime API route |\n|---|---|\n| `createFleetRun(spec)` | `POST /v1/fleet/runs` |\n| `startFleetRun(runId)` | `POST /v1/fleet/runs/{run_id}/start` |\n| `listFleetRuns()` | `GET /v1/fleet/runs` |\n| `getFleetRun(runId)` | `GET /v1/fleet/runs/{run_id}` |\n| `listFleetWorkers(runId)` | `GET /v1/fleet/runs/{run_id}/workers` |\n| `getFleetWorker(workerId)` | `GET /v1/fleet/workers/{worker_id}` |\n| `interruptWorker(workerId)` | `POST /v1/fleet/workers/{worker_id}/interrupt` |\n| `stopWorker(workerId)` | `POST /v1/fleet/workers/{worker_id}/stop` |\n| `restartWorker(workerId)` | `POST /v1/fleet/workers/{worker_id}/restart` |\n| `stopFleetRun(runId)` | `POST /v1/fleet/runs/{run_id}/stop` |\n| `replayFleetEvents(runId, options)` | `GET /v1/fleet/runs/{run_id}/events/replay` |\n| `fleetEvents(runId, options)` | `GET /v1/fleet/runs/{run_id}/events` (SSE) |\n\n`stopWorker` durably cancels that worker's active task and leaves the rest of\nthe Fleet running. `interruptWorker` is the compatibility name for the same\nattempt-fenced cancellation transition. `stopFleetRun` cancels every queued or\nactive task and marks the whole run cancelled.\n\nReplay covers aggregate run/task transitions and privacy-bounded individual\nworker transitions. Event bodies omit prompts, tool call IDs, completion text,\nartifact paths/checksums, and cancellation identities; bounded failure reasons\npass through secret redaction. `cursor` is opaque and stable across ordinary\nappends and Runtime restarts. Clients reconnect with `after=`. A fresh\nrequest returns a bounded newest tail and marks `history_truncated` when older\nhistory exists. Ledger compaction can remove an old cursor; the JSON endpoint\nthen returns `409`, while the SSE endpoint emits\n`fleet.replay.cursor_unavailable`, so the client reloads the current run\nprojection instead of accepting a silent gap.\n\n`GET /v1/runtime/info` advertises `fleet_run_create`, `fleet_run_start`,\n`fleet_event_replay`, `fleet_event_stream`, and `fleet_local_target`. Older\nruntimes without a requested route still produce a typed SDK\n`RuntimeCapabilityError`.\n\nVerification:\n\n```bash\nnpm test --workspace @codewhale/runtime-sdk\n```\n\n## Agent Run Receipts\n\nSub-agent lanes persist compact run receipts in\n`.codewhale/state/subagents.v1.json`. The Runtime API exposes those receipts as\na read-only inspection surface:\n\n| Operation | Endpoint |\n|---|---|\n| List persisted agent runs | `GET /v1/agent-runs` |\n| Inspect one run | `GET /v1/agent-runs/{run_id}` |\n| Stop one run | `POST /v1/agent-runs/{run_id}/cancel` |\n\nThe response is the same worker-record shape surfaced by `agent` receipts:\n`spec.run_id`, `actor_kind`, lifecycle `status`, bounded `events`,\n`follow_up`, `takeover`, `artifacts`, `usage`, and `verification`. `run_id`\nfalls back to the worker id for older records, and `{run_id}` may be either the\nrun id or the worker id.\n\nThese endpoints do not start or steer sub-agents. The API surface exists so\napp/editor/headless clients can inspect the same handoff receipts that the TUI\nand parent model see, and stop a run they are showing.\n\n`POST /v1/agent-runs/{run_id}/cancel` takes no body. It stops the run through\nthe same session-scoped path as the TUI's stop and the `agent/cancel` tool:\ndescendants stop with it, and a write-scoped child's changed files are named in\nits result rather than dropped. It answers with the worker record:\n\n- `200` when the record is terminal (stopping an already-finished run is a\n  no-op that returns its receipt);\n- `202` when the owning engine accepted the stop but has not recorded the\n  terminal receipt within a few seconds; poll `GET /v1/agent-runs/{run_id}`;\n- `404` for an unknown run;\n- `409` when the run belongs to a session this runtime is not hosting (for\n  example a separate terminal session); stop it from that session.\n\n## Session lifecycle (native UI supervision)\n\n| Operation | Endpoint |\n|---|---|\n| List sessions | `GET /v1/sessions` |\n| List session summaries | `GET /v1/sessions/summary` |\n| Get session | `GET /v1/sessions/{id}` |\n| Rename / archive session | `PATCH /v1/sessions/{id}` |\n| Delete session | `DELETE /v1/sessions/{id}` |\n| Session store repair summary | `GET /v1/sessions/repair` |\n| Resume into thread | `POST /v1/sessions/{id}/resume-thread` |\n| Create thread | `POST /v1/threads` |\n| List threads | `GET /v1/threads` |\n| Attach to events | `GET /v1/threads/{id}/events?since_seq=0` |\n| Send message | `POST /v1/threads/{id}/turns` |\n| Steer | `POST /v1/threads/{id}/turns/{turn_id}/steer` |\n| Interrupt | `POST /v1/threads/{id}/turns/{turn_id}/interrupt` |\n| Compact | `POST /v1/threads/{id}/compact` |\n\n## Compatibility tests\n\nContract snapshots live in `crates/protocol/tests/`. Run:\n\n```bash\ncargo test -p codewhale-protocol --test parity_protocol --locked\n```\n\nThis validates that the app-server's event schema hasn't drifted from the\ndocumented contract. CI runs this on every push to `main` and on release tags.\n\nThe app-server stdio control surface has its own drift guard — the advertised\n`capabilities` method set is pinned in `crates/app-server/src/lib.rs`:\n\n```bash\ncargo test -p codewhale-app-server capabilities\n```\n\nBefore a release, run the headless smoke (stdio probe + optional provider\nmatrix, no secrets leaked):\n\n```bash\nscripts/release/app-server-smoke.sh --matrix        # dry-run plan\nbash scripts/release/app-server-smoke.test.sh       # parser self-test (fake binary)\n```\n\n## Profile constitution\n\n`profile_constitution` in runtime capabilities enables profile snapshots on\n`POST /v1/threads/{id}/turns`. The optional `profile_constitution` field contains\n`{accountId, revision, constitution}`. `constitution` is exactly\n`{schemaVersion: 1, detail, initiative, collaboration, notes}`; choices are\n`brief|balanced|detailed`, `check|judgment|moving`, and `direct|critical|coach`.\nNotes are limited to 4,000 Unicode characters. Invalid data is refused.\n\nThe authenticated account transport supplies the snapshot, which participates in\nthe turn's replay identity. The Engine renders it through its existing personal\nconstitution renderer and records it in native session history. The snapshot is\nunchanged across provider retries and compaction. A new snapshot fully replaces\nearlier personal preferences. Permissions and approval policy are unaffected.\nInternal follow-ups and RLM child calls inherit the admitted preferences;\nthey do not re-read the host operator's account in the middle of that work.\n\nWithout a supplied snapshot, the Engine reads the signed-in profile from the\nconfigured account service at turn admission. When that Engine-loaded profile is\nunavailable or invalid, the turn uses the signed-out local constitution and shows\na notice once per session; an invalid host-supplied `profile_constitution` snapshot still fails\nthe turn, and `GET /v1/constitution` still reports the error. An account without saved\npreferences uses an explicit default snapshot; a signed-out account uses the\nexisting local constitution. Hosted transports always supply the owning\naccount snapshot, including defaults, so local preferences cannot leak between\naccounts.\n\n`GET /v1/constitution` reads the next-turn profile and its model guidance. It is\nnot a receipt that an active turn adopted the edit. `POST /v1/constitution/preview`\naccepts a constitution document and returns `{modelGuidance, saved:false}` without\nsaving anything. `modelGuidance` is the Engine-rendered guidance body: GET and\npreview return identical text for the same document. It excludes the session\nrecord's attribution line or envelope; `source` and `revision` carry provenance.\nBoth routes use normal Runtime authorization. Older runtimes\nmust be upgraded before account transports submit profile-bearing turns.\n"
       }
     ]
   },
diff --git a/web/data/features.generated.json b/web/data/features.generated.json
index 1e9f2e7d68..23b5ffb85f 100644
--- a/web/data/features.generated.json
+++ b/web/data/features.generated.json
@@ -2,7 +2,7 @@
   "format": "codewhale.features.v1",
   "source": {
     "path": "docs/features.toml",
-    "sha256": "253d29b798ab2194172144bb0254fd0572105ebcac4a772e3da07c063c20fc69"
+    "sha256": "f743040315781deb470abe1e6911d69cdfe2513a56eec1b01699e9e2813c49c4"
   },
   "features": [
     {
@@ -758,7 +758,7 @@
       "surfaces": {
         "tui": "preview"
       },
-      "since": "unreleased",
+      "since": "0.10.2",
       "flag": null,
       "docs": null,
       "registryLine": 577
@@ -890,7 +890,7 @@
         "api": "partial",
         "web": "none"
       },
-      "since": "unreleased",
+      "since": "0.10.1",
       "flag": null,
       "docs": "docs/PROVIDERS.md#sign-in-with-chatgpt",
       "registryLine": 663
@@ -905,7 +905,7 @@
         "api": "partial",
         "web": "none"
       },
-      "since": "unreleased",
+      "since": "0.10.2",
       "flag": null,
       "docs": "docs/PROVIDERS.md#sign-in-with-claude",
       "registryLine": 673
@@ -920,7 +920,7 @@
         "api": "partial",
         "web": "none"
       },
-      "since": "unreleased",
+      "since": "0.10.1",
       "flag": null,
       "docs": "docs/PLUGIN_PROVIDERS.md",
       "registryLine": 683
@@ -935,7 +935,7 @@
         "api": "none",
         "web": "none"
       },
-      "since": "unreleased",
+      "since": "0.10.1",
       "flag": null,
       "docs": "docs/PROVIDERS.md",
       "registryLine": 693
@@ -964,7 +964,7 @@
         "tui": "preview",
         "api": "preview"
       },
-      "since": "unreleased",
+      "since": "0.10.1",
       "flag": null,
       "docs": "docs/RUNTIME_API.md#profile-constitution",
       "registryLine": 714
@@ -977,7 +977,7 @@
       "surfaces": {
         "tui": "preview"
       },
-      "since": "unreleased",
+      "since": "0.10.2",
       "flag": null,
       "docs": "docs/PLUGINS.md",
       "registryLine": 724

From 7ef75652a561056dbff83a9269f5844a13b39cc5 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Tue, 13 Oct 2026 03:39:46 +0000
Subject: [PATCH 04/26] fix(tui): a gateway borrowing the catalog ladder keeps
 the user's Off

The #6396 seed (models.dev, fetched 2026-10-12) dropped the `toggle` option
from Moonshot `kimi-k3`: the bundled row now says K3 is always-thinking
(low/high/max). normalize_for_route clamps against that provider-wide row on
every route that has no exact-route row, so a Moonshot provider pointed at a
custom gateway turned the user's Off into the catalog default (High).
reasoning_effort_normalizes_each_exact_k3_route_without_neighbor_leakage
failed on exactly that: "a neighboring gateway must not inherit
direct-platform always-thinking semantics".

The ladder itself is model metadata and still applies on a custom endpoint
(reasoning_effort_preserves_grok_46_ladder_only_on_exact_xai_route keeps
Medium on a gateway). Whether a gateway can switch thinking off is not, so on
an endpoint outside the provider's official family (provider_base_url_is_official;
Custom routes keep their own declared rows) a requested Off stays Off instead
of being clamped. Direct K3 and Kimi Code K3 keep their explicit route
branches above this point.

A first attempt that dropped the provider-wide fallback on non-official
endpoints fixed K3 but broke the Grok gateway ladder (15279 passed, 1 failed);
this narrower guard satisfies both.

Evidence (non-root, scripts/dev-test.sh tui -> cargo nextest run -p
codewhale-tui --lib --locked, hermetic HOME, proxy env stripped, tini
subreaper):
- Before: Summary 15280 tests run: 15279 passed (1 slow), 1 failed, 26 skipped
  (the K3 test above).
- After: Summary [454.858s] 15280 tests run: 15280 passed (1 slow), 26 skipped.
  Includes route_runtime unpinned_spawn_route_is_conservative_and_returns_
  exact_wire_id, tools::subagent providerless_spawn_model_gate_rejects_known_
  foreign_route_before_spawn, and all tools::test_runner tests.
- Container-only failures seen in a first run with the cloud proxy env
  (child_env proxy credentials, mcp::{http_client,oauth,sse} loopback,
  fleet::host reaping under a non-reaping PID 1) all pass once the proxy env
  is stripped and a subreaper is present; fleet::host alone: 25 passed.

Refs #6396

Co-Authored-By: Claude Opus 5.5 
Claude-Session: https://claude.ai/code/session_01YCbYZQhRvzf3jQZA1cNhcP
---
 crates/tui/src/reasoning_preference.rs | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/crates/tui/src/reasoning_preference.rs b/crates/tui/src/reasoning_preference.rs
index 2e57f1a9ca..16581eefd3 100644
--- a/crates/tui/src/reasoning_preference.rs
+++ b/crates/tui/src/reasoning_preference.rs
@@ -286,6 +286,12 @@ impl ReasoningEffort {
         if let Some(offering) = Self::offering_for_reasoning(provider, base_url, wire_model)
             && let Some(values) = Self::catalog_effort_values_for_offering(&offering)
         {
+            if normalized == Self::Off
+                && provider != ProviderKind::Custom
+                && !codewhale_config::provider_base_url_is_official(provider, base_url)
+            {
+                return Self::Off;
+            }
             return Self::clamp_to_catalog_efforts(
                 normalized,
                 Self::catalog_default_for_offering(&offering),

From 2407838344ae51ea06ee01eccf4ad7838b6c85eb Mon Sep 17 00:00:00 2001
From: Claude 
Date: Tue, 13 Oct 2026 04:20:02 +0000
Subject: [PATCH 05/26] fix(cli): model resolve keeps the registry gate and
 reports route refusals

The #6396 slice moved `codewhale model resolve  [--provider X]` from
ModelRegistry to the endpoint-scoped route resolver and dropped the registry
call. The route resolver passes unknown and aggregator-class ids through, so
the diagnostic stopped failing closed: `glm-5.2 --provider moonshot` and an
unknown id on Z.ai printed `resolved:` and exited 0, and a route refusal
(`k3` on the direct Moonshot endpoint) aborted the command instead of being
reported. Five crates/cli/tests/model_resolve_provenance tests went red; the
handoff ran only `cargo check -p codewhale-cli`, not these tests.

The hypothetical branch now does what crates/app-server/src/chat_completions.rs
already does: ModelRegistry::resolve gates the requested id inside the
selected provider (same call and error text as before the slice), and the
route resolver stays authoritative for the wire id, canonical model, limits
and capabilities it adds. A route error is printed as `route_error:` and the
command still exits 0, matching the runtime-route branch above it. No test
assertion changed.

Evidence (non-root, hermetic HOME, proxy env stripped, tini subreaper;
target/debug/codewhale rebuilt first, as CI does):
- Before: cargo nextest run -p codewhale-cli --locked:
  541 tests run: 536 passed, 5 failed (model_resolve_provenance)
- After: Summary [27.189s] 541 tests run: 541 passed, 0 skipped
- Rebuilt binary: unknown id on zai and glm-5.2 on moonshot exit 1 with
  "model '' is not available from provider '

'"; k3 on direct Moonshot prints resolved: k3 plus the runtime's route_error; kimi-k3 and GLM-5.3 resolve with canonical_model. Refs #6396 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YCbYZQhRvzf3jQZA1cNhcP --- crates/cli/src/lib.rs | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index e04be82a69..cb0ea485bc 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -15,6 +15,7 @@ use std::process::Command; use anyhow::{Context, Result, anyhow, bail}; use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum}; use clap_complete::{Shell, generate}; +use codewhale_agent::ModelRegistry; use codewhale_app_server::RuntimeControlFrontend; use codewhale_config::credentials::{ clear_provider_api_key_from_config, provider_slot, set_provider_api_key, @@ -5899,6 +5900,7 @@ fn run_model_command( // the configured runtime provider remains authoritative. Model // text never authorizes switching providers or credential slots. let provider = subcommand_provider.unwrap_or(resolved_runtime.provider); + let registered = ModelRegistry::default().resolve(queried, Some(provider))?; let identity = if provider == ProviderKind::Custom && subcommand_provider.is_none() { store .config @@ -5918,27 +5920,39 @@ fn run_model_command( .clone() .unwrap_or_else(|| provider.provider().default_base_url().into()) }; - let resolved = codewhale_tui::route_preferences::resolve_model( + let candidate = codewhale_tui::route_preferences::resolve_model( store.path(), identity, &base, queried, - )?; + ); println!("requested: {}", queried.unwrap_or_default()); - println!("resolved: {}", resolved.wire_model_id().as_str()); + println!( + "resolved: {}", + candidate + .as_ref() + .map_or(registered.resolved.id.as_str(), |route| { + route.wire_model_id().as_str() + }) + ); println!("provider: {identity}"); println!( "used_fallback: {}", queried.is_none() || queried == Some("auto") ); - if let Some(canonical) = resolved.canonical_model() { - println!("canonical_model: {}", canonical.as_str()); + match candidate.as_ref() { + Ok(route) => { + if let Some(canonical) = route.canonical_model() { + println!("canonical_model: {}", canonical.as_str()); + } + println!("limits: {}", serde_json::to_string(&route.limits())?); + println!( + "capabilities: {}", + serde_json::to_string(&route.capabilities())? + ); + } + Err(error) => println!("route_error: {error}"), } - println!("limits: {}", serde_json::to_string(&resolved.limits())?); - println!( - "capabilities: {}", - serde_json::to_string(&resolved.capabilities())? - ); println!( "provider_source: {}", if subcommand_provider.is_some() { From 2f0cac7b8720aa55c2134392216a986422e7bb0c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 04:46:01 +0000 Subject: [PATCH 06/26] style(tui): clear the two clippy errors the #6396 slice introduced `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings -A clippy::uninlined_format_args -A clippy::too_many_arguments -A clippy::unnecessary_map_or` (CI's Lint job) failed on codewhale-tui with: - collapsible_if in client/anthropic.rs: the route-facts budget lookup is now one let-chain; same conditions, same body. - needless_borrow in runtime_api.rs: from_catalog_offering(offering). No behavior change. Evidence: the same clippy command now finishes with no warnings or errors ("Finished `dev` profile ... in 10m 21s", exit 0); cargo fmt --all -- --check clean. Refs #6396 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YCbYZQhRvzf3jQZA1cNhcP --- crates/tui/src/client/anthropic.rs | 19 +++++++++---------- crates/tui/src/runtime_api.rs | 2 +- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/crates/tui/src/client/anthropic.rs b/crates/tui/src/client/anthropic.rs index efc93ad445..e60803abfc 100644 --- a/crates/tui/src/client/anthropic.rs +++ b/crates/tui/src/client/anthropic.rs @@ -203,20 +203,19 @@ impl CodewhaleClient { .route_facts .as_ref() .filter(|facts| facts.wire_model_id == request.model) - { - if let Some(option) = facts.reasoning_options.iter().find(|option| { + && let Some(option) = facts.reasoning_options.iter().find(|option| { matches!( option.get("type").and_then(Value::as_str), Some("budget" | "budget_tokens") ) - }) { - minimum = option.get("min").and_then(Value::as_u64).unwrap_or(minimum); - let maximum = option - .get("max") - .and_then(Value::as_u64) - .unwrap_or(u64::from(request.max_tokens.saturating_sub(1))); - budget = budget.min(u32::try_from(maximum).unwrap_or(u32::MAX)); - } + }) + { + minimum = option.get("min").and_then(Value::as_u64).unwrap_or(minimum); + let maximum = option + .get("max") + .and_then(Value::as_u64) + .unwrap_or(u64::from(request.max_tokens.saturating_sub(1))); + budget = budget.min(u32::try_from(maximum).unwrap_or(u32::MAX)); } if u64::from(budget) >= minimum { body["thinking"] = json!({ "type": "enabled", "budget_tokens": budget }); diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index f8e1cb1700..b0cafc20cd 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -10649,7 +10649,7 @@ fn provider_model_entry_for_api( crate::route_runtime::resolve_runtime_route_for_identity(config, identity, Some(&entry.id)) .ok(); if let Some(offering) = offering.as_ref() { - let pricing = codewhale_config::pricing::OfferingPricing::from_catalog_offering(&offering); + let pricing = codewhale_config::pricing::OfferingPricing::from_catalog_offering(offering); entry.facts = Some(serde_json::json!({ "wire_model_id": offering.wire_model_id, "canonical_model_id": offering.canonical_model, From 9e8fa55a2da4322fa50795e116f9fbdcfd949fe0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 05:24:47 +0000 Subject: [PATCH 07/26] docs(release): correct four 0.10.2 notes found while triaging recent issues Checked each claim against the code at 2f0cac7b8: - "`task_shell_start` is removed; use `bash` with `background=true`" was wrong: the model-visible `bash` schema has no `background` field and sets additionalProperties false (crates/tui/src/tools/shell.rs input_schema; docs/TOOL_SURFACE.md "Long-running work"). The note now says only that `task_shell_start` is removed; the sentence before it already describes the automatic move to the background. - #6923: the body retry delay (9dd6b4b35) is used only when the 429 is classified RateLimited. LlmError::from_http_response classifies any 400/402/403/429 whose body says "exceeded your current quota" with plan and billing guidance as QuotaExhausted, which is never retried (llm_client/mod.rs has_explicit_quota_phrase; pinned by llm_client/tests.rs explicit_400_402_and_429_quota_responses_are_typed_and_non_retryable). Gemini's free-tier rate-limit 429 uses that wording, so the reporter's case still ends the turn. The Fixed line now describes what changed, and a Known limit states what did not. - #6955: a session whose current branch alone is over 8 MiB is still refused (session_manager.rs load_session_snapshot_with_limit); the note says so. - #6933 had no line although the model-visible catalog changed: fim_edit is registered only when can_request_fim_completion holds (Chat Completions, not OpenCode Zen; tools/registry.rs with_fim_tool), and the default DeepSeek Flash route resolves to Responses (route/tests.rs resolver_routes_only_official_deepseek_flash_over_responses). Added it, and the docs/PROVIDERS.md FIM cell (and zh_hans mirror) no longer says Flash has DeepSeek beta FIM. Evidence: scripts/release/check-versions.sh and --require-dated-release ("Feature release-note receipts OK: 8 linked issue reference(s)"; "Version state OK"); scripts/sync-changelog.sh --check up to date and the two 0.10.2 sections are identical; node web/scripts/check-facts.mjs OK after derive-facts; git diff --check clean. Refs #6923, #6933, #6955 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YCbYZQhRvzf3jQZA1cNhcP --- CHANGELOG.md | 17 ++++++++++++++--- crates/tui/CHANGELOG.md | 17 ++++++++++++++--- docs/PROVIDERS.md | 2 +- docs/zh_hans/PROVIDERS.md | 2 +- web/data/agent-docs.generated.json | 4 ++-- 5 files changed, 32 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34d5d2cdba..a37d2af24d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -132,7 +132,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 or run commands. - `bash` now moves a foreground command to the background after 30 s and reports the elapsed time and how long ago it last printed output. - `task_shell_start` is removed; use `bash` with `background=true`. + `task_shell_start` is removed. - Every change of effective permission now writes a `Permissions changed to …` line in the transcript, and runtime threads record it as a `status` item and a `thread.permissions_changed` event. @@ -317,6 +317,10 @@ launcher guidance. - `file_search` is one of the model's default tools from the first turn instead of being loaded through `tool_search` ([#6934](https://github.com/codewhale-hq/Codewhale/issues/6934)). +- `fim_edit` is offered only on routes that can run it: Chat Completions + routes other than OpenCode Zen. The default DeepSeek Flash route speaks + Responses and no longer lists it + ([#6933](https://github.com/codewhale-hq/Codewhale/issues/6933)). - Runtime session grants (`"remember": true`) are stored in the Runtime data directory instead of memory. After a restart a thread's grants stay suspended, and matching calls ask again, until an authenticated client @@ -332,6 +336,8 @@ launcher guidance. holds a raw sub-agent handoff (#6954). - A saved session over 8 MiB can be resumed and forked again. Journal entries off the current branch are archived instead of refusing the session (#6955). + A session whose current branch alone is over 8 MiB is still refused, and the + refusal now gives its size. - Snapshot restore and `/undo` work with older git, such as the 2.43 that Ubuntu 24.04 ships. They failed there with "only one reference expected". - Cancelling your own background shell job no longer reports it as finished @@ -368,8 +374,8 @@ launcher guidance. a recorded session message outside the system-prompt layer. - The pet shows sub-agents while they run, and a finished one no longer stays "working". Half-block pet sprites are drawn crisply instead of blurred. -- A provider's retry delay is honored when a 429 carries it in the body - instead of a `Retry-After` header (#6923). +- A rate-limit 429 that gives its retry delay in the body instead of a + `Retry-After` header is retried after that delay, up to 120 s (Refs #6923). - A Codewhale home reached through a symlink or Windows junction keeps saving artifacts and compacting (#6947). - A workflow gate that blocks its own role is rejected when the plan is @@ -500,6 +506,11 @@ launcher guidance. ### Known limits +- A 429 whose body says the current quota is exceeded and points to plan and + billing details is treated as exhausted quota and ends the turn, even when it + also names a short retry delay. Gemini's free-tier rate-limit responses use + that wording, so they are not yet retried + ([#6923](https://github.com/codewhale-hq/Codewhale/issues/6923)). - Idle CPU has not been measured on FreeBSD or Linux for this release ([#6728](https://github.com/codewhale-hq/Codewhale/issues/6728)). - Shell commands run by delegated agents appear in `/jobs` and the work dock diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 60a1388a18..6432505ee8 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -132,7 +132,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 or run commands. - `bash` now moves a foreground command to the background after 30 s and reports the elapsed time and how long ago it last printed output. - `task_shell_start` is removed; use `bash` with `background=true`. + `task_shell_start` is removed. - Every change of effective permission now writes a `Permissions changed to …` line in the transcript, and runtime threads record it as a `status` item and a `thread.permissions_changed` event. @@ -317,6 +317,10 @@ launcher guidance. - `file_search` is one of the model's default tools from the first turn instead of being loaded through `tool_search` ([#6934](https://github.com/codewhale-hq/Codewhale/issues/6934)). +- `fim_edit` is offered only on routes that can run it: Chat Completions + routes other than OpenCode Zen. The default DeepSeek Flash route speaks + Responses and no longer lists it + ([#6933](https://github.com/codewhale-hq/Codewhale/issues/6933)). - Runtime session grants (`"remember": true`) are stored in the Runtime data directory instead of memory. After a restart a thread's grants stay suspended, and matching calls ask again, until an authenticated client @@ -332,6 +336,8 @@ launcher guidance. holds a raw sub-agent handoff (#6954). - A saved session over 8 MiB can be resumed and forked again. Journal entries off the current branch are archived instead of refusing the session (#6955). + A session whose current branch alone is over 8 MiB is still refused, and the + refusal now gives its size. - Snapshot restore and `/undo` work with older git, such as the 2.43 that Ubuntu 24.04 ships. They failed there with "only one reference expected". - Cancelling your own background shell job no longer reports it as finished @@ -368,8 +374,8 @@ launcher guidance. a recorded session message outside the system-prompt layer. - The pet shows sub-agents while they run, and a finished one no longer stays "working". Half-block pet sprites are drawn crisply instead of blurred. -- A provider's retry delay is honored when a 429 carries it in the body - instead of a `Retry-After` header (#6923). +- A rate-limit 429 that gives its retry delay in the body instead of a + `Retry-After` header is retried after that delay, up to 120 s (Refs #6923). - A Codewhale home reached through a symlink or Windows junction keeps saving artifacts and compacting (#6947). - A workflow gate that blocks its own role is rejected when the plan is @@ -500,6 +506,11 @@ launcher guidance. ### Known limits +- A 429 whose body says the current quota is exceeded and points to plan and + billing details is treated as exhausted quota and ends the turn, even when it + also names a short retry delay. Gemini's free-tier rate-limit responses use + that wording, so they are not yet retried + ([#6923](https://github.com/codewhale-hq/Codewhale/issues/6923)). - Idle CPU has not been measured on FreeBSD or Linux for this release ([#6728](https://github.com/codewhale-hq/Codewhale/issues/6728)). - Shell commands run by delegated agents appear in `/jobs` and the work dock diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index b6fdb87b4d..9c2ca5b4b2 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -1227,7 +1227,7 @@ while bare `k3` can use an entitled 1M override. | Provider/model class | Context window | Max output metadata | Thinking support | Cache telemetry | FIM endpoint | | --- | --- | --- | --- | --- | --- | -| DeepSeek V4 (`deepseek-v4-pro`, `deepseek-v4-flash`) | 1,000,000 | 384,000 | yes | yes | DeepSeek beta only | +| DeepSeek V4 (`deepseek-v4-pro`, `deepseek-v4-flash`) | 1,000,000 | 384,000 | yes | yes | DeepSeek beta only, on Chat Completions routes; not on the default Flash route, which speaks Responses | | DeepSeek V4 Flash Vision experimental (`deepseek-v4-flash-vision-exp`) | 1,000,000 inherited from Flash | 384,000 inherited from Flash | yes, inherited | yes, inherited | not claimed; Chat Completions route only | | DeepSeek compatibility aliases (`deepseek-chat`, `deepseek-reasoner`) | 1,000,000 | 384,000 | yes | yes | DeepSeek beta only | | NVIDIA NIM V4 registry models | 1,000,000 | 384,000 | yes | yes | not documented in code | diff --git a/docs/zh_hans/PROVIDERS.md b/docs/zh_hans/PROVIDERS.md index 546d4c6dcc..a0ccd50c85 100644 --- a/docs/zh_hans/PROVIDERS.md +++ b/docs/zh_hans/PROVIDERS.md @@ -726,7 +726,7 @@ AtlasCloud 与配置层保持相同的默认模型,并为 Pro 和 Flash 行添 | 提供商/模型类别 | 上下文窗口 | Max output 元数据 | 思考支持 | 缓存遥测 | FIM 端点 | | --- | --- | --- | --- | --- | --- | -| DeepSeek V4(`deepseek-v4-pro`, `deepseek-v4-flash`) | 1,000,000 | 384,000 | 是 | 是 | 仅 DeepSeek beta | +| DeepSeek V4(`deepseek-v4-pro`, `deepseek-v4-flash`) | 1,000,000 | 384,000 | 是 | 是 | 仅 DeepSeek beta,且仅限 Chat Completions 路由;默认的 Flash 路由使用 Responses,不提供 | | DeepSeek V4 Flash Vision 实验版(`deepseek-v4-flash-vision-exp`) | 继承自 Flash,1,000,000 | 继承自 Flash,384,000 | 是,继承 | 是,继承 | 未声明;仅限 Chat Completions 路由 | | DeepSeek 兼容别名(`deepseek-chat`, `deepseek-reasoner`) | 1,000,000 | 384,000 | 是 | 是 | 仅 DeepSeek beta | | NVIDIA NIM V4 注册表模型 | 1,000,000 | 384,000 | 是 | 是 | 代码中未记录 | diff --git a/web/data/agent-docs.generated.json b/web/data/agent-docs.generated.json index de25b8434e..2c3d622885 100644 --- a/web/data/agent-docs.generated.json +++ b/web/data/agent-docs.generated.json @@ -48,8 +48,8 @@ "sources": [ { "path": "docs/PROVIDERS.md", - "sha256": "bd1b13b1f8e7c4c83dcb921c9ac7510e2b5d4238508681d86b17403181c2b9f8", - "content": "# Provider Registry\n\n> 阅读简体中文版:[zh_hans/PROVIDERS.md](zh_hans/PROVIDERS.md)\n\nSpeech recognition, synthesis, translation and video have separate execution\npaths. See [media models](MEDIA_MODELS.md) for current support and reviewed\nupstream candidates; a chat catalogue row alone does not add a media backend.\n\nThis registry describes provider behavior that is wired into the current\nCodewhale codebase. It is intentionally conservative: shipped entries are\nlimited to provider IDs, config keys, auth paths, base URLs, model resolution,\nand capability metadata that the code already knows about.\n\nDeepSeek remains the default provider, but every entry in `ProviderKind::ALL`\nis a first-class selectable provider route. `ALL` is the catalog/picker\nsurface — one identity per vendor. Dual-wire dialect kinds (`*Anthropic`, e.g.\n`deepseek-anthropic`) and the Model Studio plan variants stay on the enum for\nserde and `provider_for_kind` but are deliberately **not** catalog rows:\na plan is `mode`/`base_url` and a dialect is `wire = openai|anthropic` on the\nprimary provider config (`crates/config/src/provider_kind.rs:221-226`). Hosted\nroutes, generic OpenAI-compatible endpoints, the OpenAI Codex/ChatGPT route,\nnative Anthropic, and local runtimes all run the same terminal harness against\nthe selected provider/model/base URL.\n\nA host reached over plain Chat Completions is an ordinary named provider,\nnot a `ProviderKind`: enum variants are reserved for distinct *wires*\n(Anthropic Messages, Codex Responses, Google thought signatures). Any such\nhost is a `[providers.]` table with a base URL, a model, and a key env\n(`docs/CONFIGURATION.md`); `/provider` and `/setup` keep a \"paste a Base URL\nand a key\" path for exactly this. Offerings come from live `GET /v1/models`\nplus the Codewhale catalog rather than a compiled roster (#5350, #6289).\n\nKnown-good hosts. These ship as bundled descriptor rows in\n`crates/config/assets/provider_descriptors.json` (compiled in by\n`crates/config/src/descriptors.rs`): each row says how to reach the host — wire,\nbase URL, key env, aliases — while model ids stay live from `GET /v1/models`\nand the Codewhale catalog; the example model is only a bootstrap hint. Verify\nagainst the vendor's own docs before trusting any value here:\n\n| Host | Base URL | Example models | API key env |\n| --- | --- | --- | --- |\n| SenseNova | `https://token.sensenova.cn/v1` | `deepseek-v4-flash` | `SENSENOVA_API_KEY` |\n| Baseten | `https://inference.baseten.co/v1` | `deepseek-ai/DeepSeek-V4-Pro` | `BASETEN_API_KEY` |\n| Groq | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | `GROQ_API_KEY` |\n| Cerebras | `https://api.cerebras.ai/v1` | `llama-3.3-70b` | `CEREBRAS_API_KEY` |\n| Command Code | `https://api.commandcode.ai/provider/v1` | `deepseek/deepseek-v4-flash` | `COMMAND_CODE_API_KEY` |\n| Alibaba Model Studio (DashScope) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `qwen3.8-flash` | `DASHSCOPE_API_KEY` |\n| AICraft | `https://aicraftapi.com/v1` | `claude-4.6-sonnet`; DeepSeek / Claude / Gemini / Qwen / GLM / MiniMax / Doubao families | `AICRAFT_API_KEY` |\n| Tsubasa | `https://api.tsubasa.sh/v1` | `tsubasa-pro`, `tsubasa-fast` (32,768-token context) | `TSUBASA_API_KEY` |\n| Cheaper Inference | `https://api.cheaperinference.com/v1` | `gpt-5.4-mini`, `claude-sonnet-5`, `gemini-3.1-pro` | `CHEAPER_INFERENCE_API_KEY` |\n| Yolo-Auto | `https://yolo-auto.com/v1` | `qwen3.8-flash`; `yolo` / `yolo-small` | `YOLO_AUTO_API_KEY` |\n\nAICraft's roster spans DeepSeek, Anthropic Claude, Google Gemini, Qwen, GLM,\nMiniMax and Doubao ids on its OpenAI-compatible endpoint. The authority is\n`GET https://aicraftapi.com/v1/models` with your key — pick a model from that\nlist, not from this table.\nTsubasa implements only `GET /v1/models` and Chat Completions. Its two public\nmodel ids share a 32,768-token context, smaller than the 128,000 tokens Codewhale assumes\nfor an unknown model, so set it on the route after saving:\n`codewhale config set providers.tsubasa.context_window 32768`.\n\nCheaper Inference is an OpenAI-compatible gateway with one key for models from\nseveral labs. Model ids are bare, such as `gpt-5.4-mini` or `claude-sonnet-5`.\nPricing varies by model and route; consult the provider’s current catalog.\nThe authority is `GET https://api.cheaperinference.com/v1/models` with your key.\nDocs: .\n\nOpenCode Zen and OpenCode Go are first-class provider routes, configured like\nany other provider below; they are not part of this table. In `/provider`,\ntype to filter the list (letters not bound to a row action); `Ctrl+T` probes the\nselected row's `/models` and records reachability only (a 2xx is not\nmodel-ready).\n\nSources to keep in sync:\n\n- `crates/config/assets/provider_descriptors.json` - built-in and compatible-host\n labels, defaults, aliases, key-env lists, config/secret slots, and credential\n guidance. `crates/config/build.rs` generates immutable typed views and the\n existing constant projections from this one data owner.\n- `crates/config/src/provider_kind.rs` and `src/lib.rs` - legacy identity serde,\n config schema, environment precedence and Rust route/auth behavior.\n- `crates/tui/src/config.rs` - captures and verifies exact configured provider\n identities and keeps provider-specific credential and route policy in Rust.\n- `crates/config/assets/catalog_corrections.json` `reviewed` - intrinsic facts,\n scoped selector aliases, completion references and pure transport metadata.\n The existing seed renderer embeds this reviewed supplement in Models.dev.\n- `crates/tui/src/provider_lake.rs` and `route_runtime.rs` - resolve current\n model facts for both CLI model commands, the picker and Runtime API. Public\n catalog facts, provider/account rosters and explicit configuration retain\n their separate ownership. `crates/agent/src/lib.rs` keeps the legacy selector\n compatibility projection; it does not decide the CLI's current route.\n- `config.example.toml` and `docs/CONFIGURATION.md` - user-facing config\n examples and environment variable reference.\n- `scripts/check-provider-registry.py` - drift check for canonical provider\n IDs, live TUI provider IDs, TOML table names, static registry rows, and\n documented defaults.\n\n## Captured Provider Identity\n\nPresentation names, labels, aliases, and historical wire tags come from\n`provider_descriptors.json`. `ProviderKind` remains the Rust-owned intrinsic\ncredential, protocol, and region distinction. A configured route captures both\nits kind and exact table key; a custom table named `openai` stays custom and\ndoes not acquire OpenAI credentials or protocol rules from its name. Case is\nsignificant for custom keys.\n\nDeepSeek China has three preserved representations: `deepseek_c_n` in released\nTUI serde wrappers, `deepseek-cn` as its captured route ID, and `deepseek_cn` as\nits config leaf. Its intrinsic kind is DeepSeek, while its table and endpoint\nstay distinct.\n\nAn old custom route without an additive provider ID can resume only with a\nverified root `base_url` migration into the active `providers.custom` table.\nThat private receipt is bound to the parsed table generation. A table-only,\nprofile-only, conflicting, or later replaced table cannot establish the\nmissing identity; an explicit empty ID is refused. Writes reuse the existing\nlocked config mutation and undo comparison, obtain the fresh migration receipt\nunder that lock, and verify the captured table before changing a leaf.\n\nIn-flight requests retain their captured identity, endpoint, and credential\ngeneration. Health lookup cannot treat an opaque credential reference as ready\nmerely because an earlier request used the same authentication class.\n\n## Provider Selection\n\nWith no saved model, no `default_text_model`, and no `CODEWHALE_MODEL` or\nprovider-specific model variable, a fresh install runs `deepseek-flash` on the\nDeepSeek provider. Precedence is the active provider's configured default, then\nits catalog default — so an explicit `default_text_model` is not silently\noverridden by whichever model the shipped catalog lists first.\n\nRefresh model catalogs without installing a new Codewhale release:\n\n```sh\ncodewhale models --update\ncodewhale models --update --provider openai\ncodewhale models --provider openai --json\n```\n\n`models --update` (also `--refresh`) updates the shared Models.dev metadata\nand calls the existing `/models` endpoint for each configured provider with\nits own credentials. `--provider ID` restricts the refresh to that exact\nprovider, including named custom endpoints. It makes no inference requests\nand never changes the saved provider or model. A command-line API key is\nconfined to the active provider; other routes are reported as skipped for\nthat invocation.\n\nPlain `models` lists the active provider's saved catalog without provider requests.\nFor ChatGPT, it validates the selected registration locally before reading\nthat account's saved roster. Successful refreshes are saved under Codewhale's\ncatalog directory and used by the model/provider pickers. Cache files are\nscoped to provider identity and endpoint; a failed refresh preserves prior\nrows. Text output reports source, last successful fetch time (Unix seconds),\nand freshness. `--update --json` adds per-source receipts and aggregate counts;\npartial failures return a nonzero exit code after writing those receipts.\nOrdinary `models --json` keeps its model-array format. Bundled/configured\nfallbacks are not proof that an account can use every listed model.\n\n### Sign in with ChatGPT\n\nThe `openai-codex` provider ID now uses OpenAI's official open-source\n[Sign in with ChatGPT flow](https://developers.openai.com/siwc/token-sharing-open-source).\nSign in and refresh the selected account's catalog:\n\n```sh\ncodewhale auth chatgpt\ncodewhale models --update --provider openai-codex\ncodewhale --provider openai-codex\n```\n\nSign-in opens your system browser, uses a loopback callback and PKCE, validates\nthe returned identity, and stores the issued registration and renewable tokens\nin Codewhale's protected credential storage. Permission to use your ChatGPT\nplan is separate from identity sign-in. A declined or missing plan grant stops\ninference; choose another provider explicitly if you want another billing path.\n`codewhale auth chatgpt-revoke` signs out of the Codewhale-owned session.\nThis experimental implementation retains one active registration. A picker\nfor multiple saved ChatGPT accounts is not implemented yet.\n\nModel discovery uses your granted bearer token at\n`GET https://api.openai.com/v1/models`; inference uses the public\n`POST https://api.openai.com/v1/responses` endpoint. The model picker keeps\nOpenAI's display labels and order and lists only entries with `visibility: list`.\nThe catalog contains no credentials and is bound to the verified issuer,\nissued client ID, and account subject. Another account or workspace never\ninherits those model choices. Missing, invalid, or older-than-one-day rosters\nare reported explicitly and provide no account entitlement evidence. Run the\nrefresh command after signing in. Neither model discovery nor ordinary listing\nstarts an inference request. Imported Codex CLI tokens and legacy process-token\nvariables cannot authorize this official plan route.\n\nEligible requests consume your ChatGPT plan or credits. ChatGPT Plus's\nfive-hour allowance is shared across apps; each app receives no separate\nallowance. The documented five-hour limit does not apply to Pro. App-specific\nlimits can also apply. Review limits and access in\n[ChatGPT usage settings](https://chatgpt.com/settings/usage). Codewhale does\nnot silently switch to an API key or another provider when a limit is reached.\n\nThis is an OpenAI preview for open-source/local apps. It does not grant access\nto ChatGPT conversation history. Requests stream with `store: false` and\n`stream: true`, while Codewhale retains its own session history and tools.\nOpenAI-hosted image generation, file search, Code Interpreter, native computer\nuse, hosted MCP/connectors, and Responses `tool_search` are unavailable on this\nroute; Codewhale's own tools use supported function/custom tool calls.\nThe ChatGPT preview requests one function call at a time.\nSee [preview limitations](https://developers.openai.com/siwc/token-sharing-open-source/preview-limitations).\nPaid or remotely hosted applications require the\n[commercial partner interest process](https://openai.com/form/sign-in-with-chatgpt-interest/);\nthis local integration is not commercial approval.\n\nThe canonical provider IDs are the entries of `ProviderKind::ALL`\n(`crates/config/src/provider_kind.rs`), in that order:\n\n`deepseek`, `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`,\n`openrouter`, `orcarouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `arcee`,\n`siliconflow-CN`, `moonshot`, `sglang`, `vllm`, `ollama`, `ollama-cloud`, `huggingface`,\n`together`, `qianfan`, `openai-codex`, `anthropic`, `openmodel`, `zai`,\n`stepfun`, `minimax`, `deepinfra`, `sakana`, `longcat`, `opencode-go`,\n`opencode-zen`, `meta`, `xai`, `mistral`, `telecomjs`, `modelstudio-token-plan`, `modelscope`,\n`google`, `edenai`, `zenmux`, `csdn`, `concentrate`, `codewhale`, and `custom`.\n\n`deepseek-anthropic` is *not* on this list — it is a wire dialect of\n`deepseek`, reached with `wire = \"anthropic\"`, not a separate route to select.\n\nUse any of these surfaces to select a provider:\n\n- CLI: `codewhale --provider `\n- TUI: `/provider ` or the provider picker\n- Env: `CODEWHALE_PROVIDER=`; `DEEPSEEK_PROVIDER=` is the legacy alias\n- Config: `provider = \"\"`\n\n`deepseek-cn`, `deepseek_china`, `deepseekcn`, and `deepseek-china` are accepted\nas legacy aliases for `deepseek`. They do not select a different official host;\nDeepSeek uses the same official API host worldwide.\n\n`deepseek_anthropic`, `deepseek-claude`, and `deepseek_claude` select\n`deepseek-anthropic`, the opt-in DeepSeek route that speaks the Anthropic\nMessages API at `https://api.deepseek.com/anthropic`. It keeps the normal\nDeepSeek API key path but uses `x-api-key` plus `anthropic-version: 2023-06-01`\ninstead of Bearer auth. If the key already lives in official DeepSeek Harness\n(`dsh`) at `$DSH_HOME/.credentials.yaml`, grant read-only access with\n`codewhale auth external-consent --provider deepseek --mode read-only`.\nCodewhale never writes that file and only reads `DEEPSEEK_API_KEY`.\n\n`huggingface`, `hugging-face`, `hugging_face`, and `hf` all select the\nHugging Face Inference Providers route. This is the OpenAI-compatible router\npath for chat/inference, not Hub browsing, model-card inspection, uploads, or\nartifact export.\n\n`telecomjs`, `telecom-js`, `telecom_js`, `telecomjs-cn`, and `tokenhub` all\nselect the TelecomJS TokenHub route. Its authenticated `/models` catalog is\nkey-scoped and remains isolated from every other provider's live snapshot.\n\nFresh shared config writes to `~/.codewhale/config.toml`. Existing\n`~/.deepseek/config.toml` files are still read for compatibility.\n\n### Legacy Antigravity tombstone\n\nAntigravity is not a Codewhale provider and cannot be selected or run. Existing\nlegacy Antigravity provider state is recognized only as a non-runnable migration\ntombstone. Run `codewhale auth clear --provider antigravity` to forget only\nCodewhale-owned legacy configuration and consent metadata. This does not sign\nout of, revoke, read, or otherwise alter any official Google or Antigravity\nsession. For Gemini, select the supported `google` provider and supply\n`GEMINI_API_KEY`.\n\n### Wire Protocol Compatibility\n\nProvider selection is explicit. A model string prefix such as\n`deepseek-ai/...`, `deepseek/...`, `qwen/...`, or `arcee-ai/...` is a\nprovider-owned wire ID or catalog namespace hint under the selected provider.\nIt is not a provider switch and must not be treated as proof that the route is\nDeepSeek, OpenRouter, or any other provider.\n\nSet the route with `provider = \"\"`, `CODEWHALE_PROVIDER=`, or\n`codewhale --provider `. Set the request model with `CODEWHALE_MODEL`, a\nprovider-specific model env var, top-level `default_text_model`, or\n`[providers.

].model`. Set the endpoint with `CODEWHALE_BASE_URL`, a\nprovider-specific base URL env var, or `[providers.
].base_url`. Set auth\nwith `codewhale auth set --provider `, `[providers.
].api_key`, or\nthe listed provider env vars.\n\n| Provider ID | TOML table | Wire protocol | Auth env vars |\n| --- | --- | --- | --- |\n| `deepseek` | `[providers.deepseek]` | Model-aware: Responses default (`deepseek-flash`); Chat Completions (`deepseek-v4-pro`) | `DEEPSEEK_API_KEY` |\n| `deepseek-anthropic` | `[providers.deepseek_anthropic]` | Anthropic Messages | `DEEPSEEK_API_KEY` |\n| `nvidia-nim` | `[providers.nvidia_nim]` | OpenAI Chat Completions | `NVIDIA_API_KEY`, `NVIDIA_NIM_API_KEY` |\n| `openai` | `[providers.openai]` | OpenAI Chat Completions | `OPENAI_API_KEY` |\n| `atlascloud` | `[providers.atlascloud]` | OpenAI Chat Completions | `ATLASCLOUD_API_KEY` |\n| `wanjie-ark` | `[providers.wanjie_ark]` | OpenAI Chat Completions | `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, `WANJIE_MAAS_API_KEY` |\n| `volcengine` | `[providers.volcengine]` | OpenAI Chat Completions | `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, `ARK_API_KEY` |\n| `openrouter` | `[providers.openrouter]` | OpenAI Chat Completions | `OPENROUTER_API_KEY` |\n| `xiaomi-mimo` | `[providers.xiaomi_mimo]` | OpenAI Chat Completions | `XIAOMI_MIMO_TOKEN_PLAN_API_KEY`, `MIMO_TOKEN_PLAN_API_KEY`, `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, `MIMO_API_KEY` |\n| `novita` | `[providers.novita]` | OpenAI Chat Completions | `NOVITA_API_KEY` |\n| `fireworks` | `[providers.fireworks]` | OpenAI Chat Completions | `FIREWORKS_API_KEY` |\n| `siliconflow` | `[providers.siliconflow]` | OpenAI Chat Completions | `SILICONFLOW_API_KEY` |\n| `arcee` | `[providers.arcee]` | OpenAI Chat Completions | `ARCEE_API_KEY` |\n| `siliconflow-CN` | `[providers.siliconflow_cn]` | OpenAI Chat Completions | `SILICONFLOW_API_KEY` |\n| `moonshot` | `[providers.moonshot]` | OpenAI Chat Completions | `MOONSHOT_API_KEY`, `KIMI_API_KEY` |\n| `sglang` | `[providers.sglang]` | OpenAI Chat Completions | `SGLANG_API_KEY` |\n| `vllm` | `[providers.vllm]` | OpenAI Chat Completions | `VLLM_API_KEY` |\n| `ollama` | `[providers.ollama]` | Local OpenAI-compatible Chat Completions | `OLLAMA_API_KEY` (optional; only for an authenticated local route) |\n| `ollama-cloud` | `[providers.ollama_cloud]` | Hosted OpenAI-compatible Chat Completions | `OLLAMA_CLOUD_API_KEY`, `OLLAMA_API_KEY` |\n| `huggingface` | `[providers.huggingface]` | OpenAI Chat Completions | `HUGGINGFACE_API_KEY`, `HF_TOKEN` |\n| `modelscope` | `[providers.modelscope]` | OpenAI Chat Completions | `MODELSCOPE_API_KEY` |\n| `together` | `[providers.together]` | OpenAI Chat Completions | `TOGETHER_API_KEY` |\n| `qianfan` | `[providers.qianfan]` | OpenAI Chat Completions | `QIANFAN_API_KEY`, `BAIDU_QIANFAN_API_KEY` |\n| `openai-codex` | `[providers.openai_codex]` | OpenAI Responses | Official Sign in with ChatGPT (`codewhale auth chatgpt`) with a validated Codewhale-owned plan grant |\n| `anthropic` | `[providers.anthropic]` | Anthropic Messages | `ANTHROPIC_API_KEY` or `codewhale auth claude` |\n| `openmodel` | `[providers.openmodel]` | Anthropic Messages | `OPENMODEL_API_KEY` |\n| `zai` | `[providers.zai]` | OpenAI Chat Completions | `ZAI_API_KEY`, `Z_AI_API_KEY` |\n| `stepfun` | `[providers.stepfun]` | OpenAI Chat Completions | `STEPFUN_API_KEY`, `STEP_API_KEY` |\n| `minimax` | `[providers.minimax]` | OpenAI Chat Completions | `MINIMAX_API_KEY` |\n| `deepinfra` | `[providers.deepinfra]` | OpenAI Chat Completions | `DEEPINFRA_API_KEY`, `DEEPINFRA_TOKEN` |\n| `sakana` | `[providers.sakana]` | OpenAI Chat Completions | `FUGU_API_KEY`, `SAKANA_API_KEY` |\n| `longcat` | `[providers.longcat]` | OpenAI Chat Completions | `LONGCAT_API_KEY` |\n| `opencode-go` | `[providers.opencode_go]` | OpenAI Chat Completions | `OPENCODE_GO_API_KEY` |\n| `opencode-zen` | `[providers.opencode_zen]` | Model-aware: OpenAI Responses, Anthropic Messages, or OpenAI Chat Completions | `OPENCODE_ZEN_API_KEY`, `OPENCODE_API_KEY` |\n| `meta` | `[providers.meta]` | OpenAI Chat Completions | `META_MODEL_API_KEY`, `MODEL_API_KEY` |\n| `telecomjs` | `[providers.telecomjs]` | OpenAI Chat Completions | `TELECOMJS_API_KEY` |\n| `xai` | `[providers.xai]` | OpenAI Chat Completions | `XAI_API_KEY` |\n| `mistral` | `[providers.mistral]` | OpenAI Chat Completions | `MISTRAL_API_KEY` |\n| `google` | `[providers.google]` | OpenAI Chat Completions (official Gemini OpenAI-compat route; captures and replays thought signatures on tool calls) | `GOOGLE_API_KEY`, `GEMINI_API_KEY` |\n| `edenai` | `[providers.edenai]` | OpenAI Chat Completions | `EDENAI_API_KEY` |\n| `zenmux` | `[providers.zenmux]` | OpenAI Chat Completions | `ZENMUX_API_KEY` |\n| `csdn` | `[providers.csdn]` | OpenAI Chat Completions | `CSDN_API_KEY` |\n| `concentrate` | `[providers.concentrate]` | OpenAI Responses (`/v1/responses`) | `CONCENTRATE_API_KEY` |\n| `codewhale` | `[providers.codewhale]` | Model-aware: OpenAI Chat Completions (`/v1/chat/completions`) or Anthropic Messages (`/v1/messages`), chosen per model by the account catalog | `CODEWHALE_API_KEY` |\n| `modelstudio-token-plan` | `[providers.modelstudio_token_plan]` | OpenAI Chat Completions | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` |\n| `modelstudio-token-plan-anthropic` | `[providers.modelstudio_token_plan_anthropic]` | Anthropic Messages | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` |\n| `modelstudio-coding-plan` | `[providers.modelstudio_coding_plan]` | OpenAI Chat Completions | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` |\n| `modelstudio-coding-plan-anthropic` | `[providers.modelstudio_coding_plan_anthropic]` | Anthropic Messages | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` |\n\nDefault base URLs and models for each route are listed in the shipped provider\ntable below. The wire protocol values above are derived from\n`crates/config/src/provider.rs`: `ChatCompletions` is the default,\n`openai-codex` overrides to `Responses`; `deepseek-anthropic`, `anthropic`, and\n`openmodel` override to `AnthropicMessages`; `opencode-zen` resolves the\nprotocol from the selected model's curated offering; and `deepseek` is\nmodel-aware — the shipped default `deepseek-flash` (and legacy\n`deepseek-v4-flash`) rides the Responses endpoint while `deepseek-v4-pro`\nstays on Chat Completions.\n\n## Auth And Env Rules\n\nFor hosted providers, `codewhale auth set --provider ` saves an API key for\nthat provider. API-key environment variables are fallback inputs after saved\nconfig and keyring credentials; an explicit process-level `--api-key` still\nwins for that launch.\n\nFor base URL and model selection, prefer:\n\n- `CODEWHALE_BASE_URL` / `CODEWHALE_MODEL` for the active provider.\n- Provider-specific base URL/model env vars when listed below.\n- `DEEPSEEK_BASE_URL`, `DEEPSEEK_MODEL`, and `DEEPSEEK_DEFAULT_TEXT_MODEL` as\n legacy aliases.\n\nNon-local `http://` base URLs are rejected unless\n`DEEPSEEK_ALLOW_INSECURE_HTTP=1` is set. Loopback HTTP URLs are allowed for\nself-hosted runtimes.\n\n## Custom DeepSeek-Compatible Endpoints\n\nMost custom DeepSeek-compatible deployments can use an existing provider ID.\nDo not create `[providers.deepseek_custom]`; the provider table names are fixed.\nInstead, choose the closest shipped route and override its endpoint/model:\n\n- DeepSeek-compatible hosted API: keep `provider = \"deepseek\"` and set\n `[providers.deepseek].base_url` plus `[providers.deepseek].model`, or launch\n with `DEEPSEEK_BASE_URL` and `DEEPSEEK_MODEL`.\n- Generic OpenAI-compatible gateway: use `provider = \"openai\"` with\n `[providers.openai].base_url` plus `[providers.openai].model`, or launch with\n `OPENAI_BASE_URL` and `OPENAI_MODEL`.\n- Multiple named OpenAI-compatible gateways, or local routes you want to pin\n from an AgentProfile, can use a custom table such as\n `[providers.lm-studio] kind = \"openai-compatible\"` and select it with\n `provider = \"lm-studio\"` or a profile `provider = \"lm-studio\"`.\n- Local OpenAI-compatible runtimes: use `provider = \"vllm\"`, `\"sglang\"`, or\n `\"ollama\"` with the matching provider-specific base URL/model values.\n\nExample user config for a DeepSeek-compatible host:\n\n```toml\nprovider = \"deepseek\"\n\n[providers.deepseek]\napi_key = \"YOUR_API_KEY\"\nbase_url = \"https://your-provider.example/v1\"\nmodel = \"deepseek-ai/DeepSeek-V4-Pro\"\n```\n\nExample user config for a generic gateway:\n\n```toml\nprovider = \"openai\"\n\n[providers.openai]\napi_key = \"YOUR_GATEWAY_API_KEY\"\nbase_url = \"https://gateway.example/v1\"\nmodel = \"your-deepseek-compatible-model\"\n```\n\nAlibaba Cloud Model Studio (Bailian / DashScope) is a first-class provider as\nof v0.9.4 with two plan profiles: Token Plan (Personal / Team) and Coding Plan.\nBoth plans expose an OpenAI-compatible Chat Completions endpoint and an\nAnthropic-compatible Messages endpoint.\n\n**Token Plan** (Personal and Team share the same AP-Southeast endpoint):\n\n```toml\nprovider = \"modelstudio-token-plan\"\n\n[providers.modelstudio_token_plan]\napi_key = \"YOUR_MODELSTUDIO_API_KEY\"\n# base_url defaults to https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1\nmodel = \"qwen3.8-max\" # or qwen3.8-max-preview | qwen3.7-plus | qwen3.7-max |\n # qwen3.6-flash | deepseek-v4-pro | deepseek-v4-flash-0731 |\n # glm-5.2\n```\n\n**Coding Plan** (separate international endpoint):\n\n```toml\nprovider = \"modelstudio-coding-plan\"\n\n[providers.modelstudio_coding_plan]\napi_key = \"YOUR_MODELSTUDIO_API_KEY\"\n# base_url defaults to https://coding-intl.dashscope.aliyuncs.com/v1\nmodel = \"qwen3.8-max\"\n```\n\n**Anthropic-compatible dialect** — both plans also expose a native Anthropic\nMessages path. Select it with the `-anthropic` provider suffix:\n\n```toml\nprovider = \"modelstudio-token-plan-anthropic\"\n\n[providers.modelstudio_token_plan_anthropic]\napi_key = \"YOUR_MODELSTUDIO_API_KEY\"\n# base_url defaults to https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic\nmodel = \"qwen3.8-max\"\n```\n\nCreate or copy a Model Studio API key from the\n[Bailian console](https://bailian.console.aliyun.com/). The API key is shared\nacross all four provider IDs above; only the base URL and wire protocol differ.\n\n**Thinking / reasoning.** Reasoning surfaces in the TUI's Thinking view on both\ndialects, per Model Studio's\n[deep-thinking docs](https://www.alibabacloud.com/help/en/model-studio/deep-thinking).\n\nOn the OpenAI-compatible routes the top-level controls are **route- and\nmodel-specific**, and Codewhale fails closed: they are sent only when the\nconfigured `base_url` is an official Alibaba Chat Completions host\n(`*.maas.aliyuncs.com/compatible-mode/v1`, including workspace-scoped hosts, or\n`coding-intl.dashscope.aliyuncs.com/v1`). A custom `base_url` on the same\nprovider ID gets `thinking`, `enable_thinking`, `preserve_thinking`, and\n`reasoning_effort` stripped, so an arbitrary OpenAI-compatible gateway is never\nhanded Alibaba's dialect. On a verified host:\n\n- **Hybrid models** (`qwen3.7-*`, `qwen3.6-*`, `deepseek-v4*`, `glm-*`,\n `kimi-k2.6*`) get `enable_thinking`: `false` for `off`, `true` otherwise.\n- **Thinking-only models** — `qwen3.8-max` (catalogued `thinking: always_on`),\n `qwen3.8-max-preview` (effort/budget options, no toggle), and\n `kimi-k2.7-code` — get **no** enable/disable switch at all. Sending one is at\n best ignored.\n- `preserve_thinking` is sent for the models documented to accept it\n (`qwen3.7-max`/`-plus`, `qwen3.6-max-preview`/`-plus`/`-flash`, `kimi-k2.6*`,\n `kimi-k2.7-code`), so the next turn keeps the assistant's trace.\n- `reasoning_effort` is sent only for the two families with a documented ladder\n — `deepseek-v4*` and `glm-5`/`5.1`/`5.2` — mapped to `high` or `max`.\n\nReasoning streams back as `delta.reasoning_content`. It is replayed to the\nprovider on later turns only for the `preserve_thinking` models above and the\nthinking-only models; `deepseek-v3.1`, `deepseek-v3.2`, and `glm-*` history\nstays stripped pending live confirmation that DashScope accepts\n`reasoning_content` in input messages. (`deepseek-v4*` replays regardless — the\nDeepSeek thinking-mode contract requires it on every provider.)\n\nOn the Anthropic-compatible routes, thinking uses the documented\n`{\"type\":\"enabled\",\"budget_tokens\":N}` / `{\"type\":\"disabled\"}` shapes from the\n[Anthropic-compatible Messages API](https://www.alibabacloud.com/help/en/model-studio/anthropic-api-messages),\nwith `budget_tokens` derived from the effort level.\n\nDeepSeek (`deepseek-v4-pro`, `deepseek-v4-flash-0731`) and GLM (`glm-5.2`)\nmodels served by Model Studio are provider-scoped and do not collide with the\nfirst-party DeepSeek or Zhipu/Z.ai routes. Model Studio publishes no `glm-5.3`\nentry, so Codewhale does not offer one on this route.\nPay-as-you-go workspace-id templating is not yet in the built-in provider; use\na custom provider entry for that plan until a follow-up adds it.\n\nPrivate gateways with broken or intercepted certificates should use\n`SSL_CERT_FILE` with a trusted CA bundle. The legacy\n`insecure_skip_tls_verify = true` key is still parsed so `codewhale doctor` can\nreport stale configs, but provider clients reject it instead of skipping TLS\ncertificate verification.\n\nKeep `provider`, `api_key`, and `base_url` in user config or process\nenvironment. Project-local config overlays intentionally cannot set those keys,\nso a repository cannot silently redirect prompts or credentials to another\nendpoint.\n\n## Local Models (DS4, Ollama, vLLM, SGLang)\n\nSelf-hosted OpenAI-compatible runtimes are first-class routes and are keyless\nby default — set an API key only when your server requires one. Start your\nrunner, then point Codewhale at it with `--provider` / `/provider` or a config\ntable.\n\n| Runner | Default base URL | Default model | Base URL override |\n| --- | --- | --- | --- |\n| `ollama` | `http://localhost:11434/v1` | live tag from `GET /v1/models` (pre-refresh: `unknown`) | `OLLAMA_BASE_URL` |\n| `vllm` | `http://localhost:8000/v1` | `deepseek-ai/DeepSeek-V4-Pro` | `VLLM_BASE_URL` |\n| `sglang` | `http://localhost:30000/v1` | `deepseek-ai/DeepSeek-V4-Pro` | `SGLANG_BASE_URL` |\n\n### DS4 (DwarfStar)\n\n[DS4](https://github.com/antirez/ds4/tree/84cc882352757baf628a1776badf7cc54d584e28)\nserves DeepSeek V4 Flash and Pro locally\nthrough an OpenAI-compatible API. Start DS4, then open Codewhale's prefilled,\nkeyless setup form:\n\n```bash\n./ds4-server --ctx 100000 --kv-disk-dir /tmp/ds4-kv --kv-disk-space-mb 8192\ncodewhale\n# In Codewhale: /setup provider ds4\n```\n\nReview the prefilled route and press Enter to save it. The preset budgets a\n100,000-token context to match that starter command and defaults to the Flash\ncompatibility alias. Check the local route explicitly with\n`codewhale doctor --probe-local`.\n\nDS4 loads the actual GGUF when the server starts. Its `deepseek-v4-flash` and\n`deepseek-v4-pro` API ids are compatibility aliases; changing `/model` does\nnot swap the resident model. To run Pro, download the supported Pro weights\nand start `ds4-server -m ...` as described by DS4. Update\n`context_window` whenever the server's `--ctx` value changes.\n\nThe equivalent config is:\n\n```toml\nprovider = \"ds4\"\n\n[providers.ds4]\nkind = \"openai-compatible\"\nbase_url = \"http://127.0.0.1:8000/v1\"\nmodel = \"deepseek-v4-flash\"\nauth_mode = \"none\"\ncontext_window = 100000\n```\n\nCodewhale reuses its existing OpenAI-compatible transport and DeepSeek\nreasoning/tool-call shaping for DS4. It does not invent an API key, confuse an\nAPI alias with the loaded GGUF, or silently switch to a hosted DeepSeek route.\nThe pinned DS4 [agent-client contract](https://github.com/antirez/ds4/blob/84cc882352757baf628a1776badf7cc54d584e28/README.md#agent-client-usage)\ndocuments Chat Completions at `/v1`, DeepSeek thinking replay, streamed usage,\n`max_tokens`, and no strict-tool mode; Codewhale follows those exact route\nfacts instead of inheriting unsupported capabilities from a generic gateway.\nThe primary sources for model-facing behavior are DeepSeek's official\n[thinking-mode](https://api-docs.deepseek.com/guides/thinking_mode),\n[tool-call](https://api-docs.deepseek.com/guides/tool_calls), and\n[Chat Completion](https://api-docs.deepseek.com/api/create-chat-completion)\ncontracts. The pinned\n[DeepSeek Harness adapter](https://github.com/deepseek-ai/deepseek-harness/blob/47f943859bef60e4160492346772ded9b24f765a/packages/llm/llm-deepseek/README.md)\nis only a secondary implementation cross-check; it is not the API contract.\n\n### Ollama\n\n```bash\nollama serve # if not already running\nollama pull # e.g. deepseek-v4-flash, or any tag you prefer\ncodewhale --provider ollama --model \n```\n\nProvider-hinted model names are sent as-is, so `--model qwen3:8b` works with\nany tag Ollama has pulled.\n\n### Ollama Cloud\n\nOllama Cloud is a separate hosted provider. It uses the authenticated\nOpenAI-compatible `/v1/chat/completions` route and defaults to `gpt-oss:120b`:\n\n```toml\nprovider = \"ollama-cloud\"\n\n[providers.ollama_cloud]\nbase_url = \"https://ollama.com/v1\"\nmodel = \"gpt-oss:120b\"\n```\n\nCreate a key in [Ollama account settings](https://ollama.com/settings/keys),\nthen run `codewhale auth set --provider ollama-cloud`. For ambient auth,\n`OLLAMA_CLOUD_API_KEY` wins over Ollama's official `OLLAMA_API_KEY`.\n`OLLAMA_CLOUD_BASE_URL` and `OLLAMA_CLOUD_MODEL` override the Cloud defaults;\narbitrary provider-owned model IDs pass through unchanged. Local `ollama`\nremains a separate, keyless-by-default provider.\n\nCompatibility is read-only and in memory: a released config that selected\n`provider = \"ollama\"` with the exact normalized\n`[providers.ollama] base_url = \"https://ollama.com/v1\"` tuple is treated as\n`ollama-cloud` at runtime. Only that exact tuple may fall back to the legacy\n`ollama` secret slot. Codewhale does not rewrite the config, copy or delete a\nsecret, migrate neighboring paths, or make an explicit `ollama-cloud` route\nconsume the legacy slot.\n\n### vLLM\n\n```bash\nvllm serve --port 8000\n# or: python -m vllm.entrypoints.openai.api_server --model --port 8000\ncodewhale --provider vllm --model \n```\n\nvLLM's OpenAI-compatible server listens on port 8000 by default, matching\nCodewhale's `VLLM_BASE_URL`.\n\n### SGLang\n\n```bash\npython -m sglang.launch_server --model-path --port 30000\ncodewhale --provider sglang --model \n```\n\nSGLang's default port 30000 matches Codewhale's `SGLANG_BASE_URL`.\n\n### Pinning a local route in config\n\n```toml\nprovider = \"ollama\" # or \"vllm\" / \"sglang\"\n\n[providers.ollama]\nmodel = \"qwen3:8b\" # default is deepseek-v4-flash\n# base_url defaults to http://localhost:11434/v1\n```\n\nLocal models that print tool-call JSON without the wire markers: see\n[When a Local Model Prints Tool JSON](#when-a-local-model-prints-tool-json).\n\n## Credential Links\n\nProvider setup surfaces use the same typed credential metadata as onboarding,\n`/provider`, `/links`, setup receipts, and doctor output. A missing URL is\nintentional: local, OAuth-only, and user-defined routes show their supported\nconfiguration path instead of guessing a vendor page.\n\n| Provider ID | Credential or console link |\n| --- | --- |\n| `deepseek`, `deepseek-anthropic` | [DeepSeek API keys](https://platform.deepseek.com/api_keys) |\n| `nvidia-nim` | [NVIDIA NIM API keys](https://build.nvidia.com/settings/api-keys) |\n| `openai` | [OpenAI API keys](https://platform.openai.com/api-keys) |\n| `atlascloud` | [Atlas Cloud API keys](https://atlascloud.ai/docs/en/api-keys) |\n| `wanjie-ark` | [Wanjie MaaS APIKEY docs](https://docs.wanjiedata.com/maas/maas-openapi-v1.html) |\n| `volcengine` | [Volcengine Ark API keys](https://console.volcengine.com/ark/apiKey) |\n| `openrouter` | [OpenRouter keys](https://openrouter.ai/settings/keys) |\n| `xiaomi-mimo` | [Xiaomi MiMo Token Plan](https://platform.xiaomimimo.com/token-plan) |\n| `novita` | [Novita key management](https://novita.ai/en/settings/key-management) |\n| `fireworks` | [Fireworks API keys](https://fireworks.ai/api-keys) |\n| `siliconflow` | [SiliconFlow global API keys](https://cloud.siliconflow.com/account/ak) |\n| `siliconflow-CN` | [SiliconFlow China API keys](https://cloud.siliconflow.cn/account/ak) |\n| `arcee` | [Arcee API key guide](https://docs.arcee.ai/other/create-your-first-api-key) |\n| `moonshot` | [Kimi API platform keys](https://platform.kimi.ai/console/api-keys) or [Kimi Code membership console](https://www.kimi.com/code/console) |\n| `zai` | [Z.ai model API](https://z.ai/model-api) |\n| `stepfun` | [StepFun Open Platform](https://platform.stepfun.ai/) |\n| `minimax`, `minimax-anthropic` | [MiniMax interface keys](https://platform.minimax.io/user-center/basic-information/interface-key) |\n| `huggingface` | [Hugging Face tokens](https://huggingface.co/settings/tokens) |\n| `modelscope` | [ModelScope API Keys](https://modelscope.cn/my/settings/token) |\n| `deepinfra` | [DeepInfra API keys](https://deepinfra.com/dash/api_keys) |\n| `together` | [Together API keys](https://api.together.ai/settings/api-keys) |\n| `qianfan` | [Baidu Cloud access keys](https://console.bce.baidu.com/iam/#/iam/accesslist) |\n| `anthropic` | [Anthropic API keys](https://console.anthropic.com/settings/keys) |\n| `openmodel` | [OpenModel console](https://console.openmodel.ai/) ([authentication guide](https://docs.openmodel.ai/en/docs/getting-started/authentication)) |\n| `openai-codex` | Official Sign in with ChatGPT via `codewhale auth chatgpt` (eligible ChatGPT plan or credits, Codewhale-owned tokens). The `openai` API-key route has separate billing. Legacy imported Codex credentials do not authorize this route. |\n| `sglang`, `vllm` | Local OpenAI-compatible endpoints are keyless by default; configure a key only when the server requires one. |\n| `ollama` | Local Ollama is keyless by default; configure a key only when the local server requires one. |\n| `ollama-cloud` | Create an [Ollama API key](https://ollama.com/settings/keys), save it with `codewhale auth set --provider ollama-cloud`, or set `OLLAMA_CLOUD_API_KEY` / `OLLAMA_API_KEY` in that precedence order. |\n| `sakana` | [Sakana AI API keys](https://console.sakana.ai/api-keys) ([get started](https://console.sakana.ai/get-started)) |\n| `longcat` | [Meituan LongCat platform](https://longcat.chat/platform) |\n| `opencode-go` | [OpenCode Go](https://opencode.ai/docs/go/) |\n| `opencode-zen` | [OpenCode Zen](https://opencode.ai/docs/zen/) |\n| `meta` | [Meta Model API](https://developer.meta.com/ai/) |\n| `telecomjs` | [TelecomJS TokenHub](https://aigw.telecomjs.com/) |\n| `xai` | [xAI Console](https://console.x.ai/) for an API key, Codewhale-owned device login, or explicitly consented read-only Grok CLI credentials. |\n| `mistral` | [Mistral Console (la Plateforme)](https://console.mistral.ai/api-keys) |\n| `google` | [Google AI Studio](https://aistudio.google.com/apikey) — Codewhale uses the official Gemini OpenAI-compatible endpoint and never reads Google OAuth files. |\n| `edenai` | [Eden AI API keys](https://app.edenai.run/settings/api-keys) |\n| `zenmux` | [ZenMux API keys](https://zenmux.ai/platform/pay-as-you-go) |\n| `csdn` | [CSDN 星图 console](https://ai.csdn.net/workbench/api-key) — choose the Coding Plan key type for the `glm_for_coding` plan route; a general key bills metered. Docs: [Coding Plan](https://ai.csdn.net/coding-plan). |\n| `concentrate` | [Concentrate dashboard](https://concentrate.ai/) → API Keys → Create API Key (a Universal API key); docs: [API introduction](https://concentrate.ai/docs/api-reference/introduction). BYOK only — the key stays in the local secret store. |\n| `modelstudio-token-plan`, `modelstudio-token-plan-anthropic`, `modelstudio-coding-plan`, `modelstudio-coding-plan-anthropic` | [Alibaba Cloud Model Studio (Bailian console)](https://bailian.console.aliyun.com/) — create or copy a Model Studio API key. |\n| `codewhale` | [Codewhale account settings](https://app.codewhale.net/settings?section=api) — create an API key with the `models:infer` scope, or run `codewhale account api-keys create --name --use`. |\n| `custom` | Set the named provider's `base_url` and `api_key_env` or `api_key`; no canonical vendor credential page exists. |\n\nFor Kimi, the official [quickstart](https://platform.kimi.ai/docs/overview)\ndirects users to sign in, open **API Keys**, create and copy a key, and keep it\nsecret. Codewhale links straight to that console and accepts the copied key.\nIt never probes or impersonates `kimi_cli`/`kimi_code_cli`; first-class Kimi\nOAuth remains blocked on a vendor-registered Codewhale identity.\n\n### Subscription sign-in accounts (ChatGPT, xAI)\n\nCodewhale keeps one sign-in of its own per subscription provider. It is\nseparate from the browser, the ChatGPT or Grok apps, and the Codex or Grok\nCLIs, so those can be signed in to a different account. A successful login\nreplaces the previous owned grant; there is no picker for multiple saved\nChatGPT accounts.\n\n- **See which account is signed in.** `codewhale auth status --provider\n openai-codex` (or `--provider xai`) and `codewhale auth list` show an account\n label when the ID token contains an email. ChatGPT plan and workspace\n details appear only when those claims are present; a workspace label uses\n the first characters of its account ID. The label is display metadata, not\n proof of plan entitlement. A valid sign-in may have no email label. Status\n reads local credential state without contacting the issuer: an expired\n access token with a stored refresh token remains structurally usable, while\n missing or invalid storage, or an expired token without a refresh token,\n is reported unusable. The provider picker shows the label of the sign-in\n the route would use (for xAI this includes a consented Grok CLI import).\n Successful login reports the account when known and whether it replaced\n an earlier sign-in, in the TUI's selected language. No token is printed.\n- **Reauthorize ChatGPT.** Run `/auth chatgpt` inside Codewhale to update the\n running session, or `codewhale auth chatgpt` from a shell and restart open\n Codewhale sessions. Ordinary login reuses the selected account/workspace's\n issued client ID and checks that the returned account is the same. The\n browser opens with `prompt=login`; `CODEWHALE_CHATGPT_OAUTH_NO_PROMPT=1`\n omits that parameter if the issuer refuses it.\n- **Replace the ChatGPT account or workspace.** From a shell, run\n `CODEWHALE_CHATGPT_NEW_ACCOUNT=1 codewhale auth chatgpt`, then refresh its\n model catalog and restart open Codewhale sessions. If the browser holds the\n wrong account, sign out there or open the printed URL in a private window.\n Only a fully validated new grant replaces the active owned sign-in.\n- **Switch xAI accounts.** Run `/auth xai-device` inside Codewhale, or\n `codewhale auth xai-device` from a shell and restart open sessions. The\n device page approves with whichever account the browser holds; sign out\n there or use a private window to approve another account. The login names\n the account it replaced, or says it is the same account as before.\n- **Usage limits.** ChatGPT plan requests can fail with\n `subscription_sharing_usage_limit_exceeded` or\n `subscription_sharing_usage_unavailable`. These errors, and subscription\n quota errors from other providers, name the account that made the request\n when its label is known and offer recovery guidance. Codewhale does not\n retry these terminal errors or silently choose another billing route. An\n xAI route set to OAuth that fell back to an API key gets no sign-in\n guidance, since no sign-in was used.\n- **ChatGPT credential authority.** The official ChatGPT plan route uses\n only its selected, verified Codewhale-owned grant.\n `OPENAI_CODEX_ACCESS_TOKEN`, `CODEX_ACCESS_TOKEN`, and consented Codex CLI\n tokens cannot override or authorize this route.\n\n### External CLI credential consent\n\nCredential files owned by another CLI are disabled by default. Without an\nexplicit grant, provider discovery, setup, routing, `auth status`, and doctor\ndo not stat, read, refresh, contact an identity provider for, or rewrite Codex,\nGrok, Kimi, or future external credential files.\n\nCodewhale currently supports exact-path, provider-scoped **read-only** grants\nfor the Codex CLI and Grok CLI. Codex grants remain available for legacy\ncredential inspection only; they cannot authorize the official ChatGPT plan\nroute. Use `codewhale auth chatgpt` for that route.\n\n```bash\n# Legacy Codex credential inspection; does not authorize ChatGPT plan requests.\ncodex login\ncodewhale auth external-consent --provider openai-codex --mode read-only\n\ngrok login\ncodewhale auth external-consent --provider xai --mode read-only\n\ncodewhale auth status --provider openai-codex\ncodewhale auth external-revoke --provider openai-codex\n```\n\nPass `--path /absolute/path/to/auth.json` when the external CLI uses a custom\nlocation. Consent persists the provider, external owner, exact absolute path,\nand consent schema version. Later environment-variable changes do not redirect\nthat authority to a different file. Read-only grants never refresh, contact an\nidentity/discovery service, or rewrite the external file. Supported routes such\nas xAI may use the external token after explicit selection. The official ChatGPT\nplan route ignores imported Codex tokens and requires its own verified grant.\nAn expired external token fails with login guidance. Doctor reports structural\nconsent/config state without\nopening credential files and is always non-mutating.\n\n`managed` is reserved for a future provider-specific preservation adapter.\nv0.9.1 rejects it before file or network I/O because no reviewed adapter can\nyet preserve every unknown external schema field safely. Codewhale-started xAI\ndevice login instead atomically activates a Codewhale-owned generation named\n`$CODEWHALE_HOME/credentials/xai-auth-.json`, stores only that\nvalidated basename in config, and revokes any Grok-file grant. Superseded\ngenerations are cleaned only after the new config pointer commits.\nKimi remains API-key-only; external consent for Kimi is rejected.\n\nThe official DeepSeek Harness (`dsh`) is a third read-only credential owner:\n`codewhale auth external-consent --provider deepseek --mode read-only` grants\nexact-path read access to `DEEPSEEK_API_KEY` in `$DSH_HOME/.credentials.yaml`\n(or `~/.dsh/.credentials.yaml`), which Codewhale never writes, refreshes, or\nloads into the process environment. This is separate from the DSH *harness*\nintegration (`codewhale integrations dsh …`, see\n[INTEGRATIONS_DSH.md](INTEGRATIONS_DSH.md)), which never touches credentials\nin either direction: it pins Codewhale's route identity into a `--patch`\noverlay and lets DSH resolve its own keys.\n\n## Shipped Providers\n\n| Provider ID | TOML table | Auth env | Base URL env and default | Default or static models | Notes |\n| --- | --- | --- | --- | --- | --- |\n| `deepseek` | `[providers.deepseek]` | `DEEPSEEK_API_KEY` | `CODEWHALE_BASE_URL` / `DEEPSEEK_BASE_URL`; default `https://api.deepseek.com/beta` | `deepseek-flash` (shipped default; V4.1 Flash, unversioned id), `deepseek-v4-pro`, `deepseek-v4-flash`, experimental `deepseek-v4-flash-vision-exp`; vision aliases `flash-vision`, `deepseek-v4flashvisionexp`; compatibility aliases `deepseek-chat`, `deepseek-reasoner` | First-class default. The live Pro backend is labeled `DeepSeek-V4-Pro-0813`; the callable API ID remains `deepseek-v4-pro`. Beta URL enables strict tool mode, chat prefix completion, and FIM completion. The documented V4 routes can use provider-native web search through a separate bounded Responses request; compatible custom endpoints do not inherit that capability. Set `https://api.deepseek.com` or `/v1` explicitly to opt out of beta-only features. The shipped default `deepseek-flash` speaks the Responses API (DeepSeek's documented path for Codex-style integration, since the 2026-07-31 Flash production update); the Chat-only controls (the `thinking` toggle and strict-tool `/beta` routing) apply to Chat Completions routes, and `deepseek-v4-pro` stays on Chat Completions until its announced Responses rollout. Reasoning effort follows DeepSeek's documented requested-to-actual mapping: `minimal`/`low` land on `low`, `medium`/`xhigh` on `high`, and `max`/`ultra` on `max`; `off` disables thinking (`thinking: {\"type\":\"disabled\"}` on Chat, `reasoning.effort: \"none\"` on Responses). The experimental vision ID was observed in the authenticated `/models` roster on 2026-08-21 and is advertised as image-input capable on the direct Chat Completions route only. Its limits, reasoning, and tool-call flags provisionally inherit Flash; pricing remains unknown, and no funded image round trip was made during this release work. |\n| `deepseek-anthropic` | `[providers.deepseek_anthropic]` | `DEEPSEEK_API_KEY` | `DEEPSEEK_ANTHROPIC_BASE_URL`; default `https://api.deepseek.com/anthropic` | `deepseek-v4-pro`, `deepseek-v4-flash`; compatibility aliases `deepseek-chat`, `deepseek-reasoner` | Opt-in DeepSeek route for the Anthropic Messages wire protocol. Uses `/v1/messages`, `x-api-key`, and `anthropic-version: 2023-06-01`. Keep `provider = \"deepseek\"` for the default Chat Completions path. |\n| `nvidia-nim` | `[providers.nvidia_nim]` | `NVIDIA_API_KEY`, `NVIDIA_NIM_API_KEY` | `NVIDIA_NIM_BASE_URL`, `NIM_BASE_URL`, `NVIDIA_BASE_URL`; default `https://integrate.api.nvidia.com/v1` | `deepseek-ai/deepseek-v4-pro`, `deepseek-ai/deepseek-v4-flash` | Hosted DeepSeek V4 through NVIDIA NIM. `NVIDIA_NIM_MODEL` is accepted by the TUI config path. |\n| `openai` | `[providers.openai]` | `OPENAI_API_KEY` | `OPENAI_BASE_URL`; default `https://api.openai.com/v1` | `gpt-5.6` (default), `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` | Generic OpenAI-compatible route whose built-in endpoint and fallback catalog are native to OpenAI. The [GPT-5.6 family](https://developers.openai.com/api/docs/models/gpt-5.6-sol) uses OpenAI's documented 1.05M context, 128K max output, and reasoning levels. Custom gateways remain free to select an explicit gateway-owned model. `OPENAI_MODEL` is accepted. |\n| `atlascloud` | `[providers.atlascloud]` | `ATLASCLOUD_API_KEY` | `ATLASCLOUD_BASE_URL`; default `https://api.atlascloud.ai/v1` | Default `deepseek-ai/deepseek-v4-flash`; explicit `vendor/model-id` values pass through when AtlasCloud is selected | OpenAI-compatible hosted route. `ATLASCLOUD_MODEL` is accepted by the TUI config path, the static `ModelRegistry` keeps DeepSeek V4 fallback rows, and provider-hinted CLI model IDs are sent to AtlasCloud exactly as requested. Use Atlas Cloud's own catalog or Coding Plan page for the current provider-owned model list and pricing. |\n| `wanjie-ark` | `[providers.wanjie_ark]` | `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, `WANJIE_MAAS_API_KEY` | `WANJIE_ARK_BASE_URL`, `WANJIE_BASE_URL`, `WANJIE_MAAS_BASE_URL`; default `https://maas-openapi.wanjiedata.com/api/v1` | `deepseek-reasoner` | OpenAI-compatible hosted route. `WANJIE_ARK_MODEL`, `WANJIE_MODEL`, and `WANJIE_MAAS_MODEL` are accepted. |\n| `volcengine` | `[providers.volcengine]` | `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, `ARK_API_KEY` | `VOLCENGINE_BASE_URL`, `VOLCENGINE_ARK_BASE_URL`, `ARK_BASE_URL`; default `https://ark.cn-beijing.volces.com/api/coding/v3` | `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash` | Volcengine/Volcano Engine Ark OpenAI-compatible coding endpoint. `VOLCENGINE_MODEL` and `VOLCENGINE_ARK_MODEL` are accepted. |\n| `openrouter` | `[providers.openrouter]` | `OPENROUTER_API_KEY` | `OPENROUTER_BASE_URL`; default `https://openrouter.ai/api/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`; recent large IDs include `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`, `qwen/qwen3.6-flash`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`, `qwen/qwen3.6-plus`, `google/gemma-4-31b-it`, `z-ai/glm-5.1`, `z-ai/glm-5.2`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6` | Additive open-model routing layer. It does not replace DeepSeek; it lets users route supported model IDs through OpenRouter when they choose it. |\n| `orcarouter` | `[providers.orcarouter]` | `ORCAROUTER_API_KEY` | `ORCAROUTER_BASE_URL`; default `https://api.orcarouter.ai/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`; router alias `orcarouter/auto`; recent large IDs mirror the OpenRouter namespaced catalog | [OrcaRouter](https://www.orcarouter.ai) OpenAI-compatible aggregation gateway. Shares the namespaced `vendor/model` wire-model format and DeepSeek model set with OpenRouter, so the OpenRouter base-URL and model-normalization rules apply. `ORCAROUTER_MODEL` is accepted. Provider aliases: `orcarouter`, `orca_router`, `orca`. |\n| `xiaomi-mimo` | `[providers.xiaomi_mimo]` | `XIAOMI_MIMO_TOKEN_PLAN_API_KEY`, `MIMO_TOKEN_PLAN_API_KEY`, `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, `MIMO_API_KEY` | `XIAOMI_MIMO_BASE_URL`, `MIMO_BASE_URL`, `XIAOMI_MIMO_MODE`, `MIMO_MODE`; default `https://token-plan-sgp.xiaomimimo.com/v1` | Chat: `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, `mimo-v2.5`; speech/TTS: `mimo-v2.5-tts`, `mimo-v2.5-tts-voicedesign`, `mimo-v2.5-tts-voiceclone`, `mimo-v2-tts` | Xiaomi MiMo OpenAI-compatible chat completions route. `mimo-v2.5-pro` and `mimo-v2.5` can use the documented provider-native web-search plugin; ultraspeed, speech/TTS, and custom-compatible routes do not inherit it. Token Plan keys (`tp-...`) use `api-key` auth and the token-plan endpoint by default; pay-as-you-go mode uses standard API keys (`sk-...`) and `https://api.xiaomimimo.com/v1`. It sends `max_completion_tokens` and uses MiMo's `thinking` field for reasoning control. Token Plan cost/usage is credit/quota based; Codewhale shows it as unknown until Xiaomi exposes a reliable balance API. `codewhale speech` / `tts` uses the TTS models. |\n| `novita` | `[providers.novita]` | `NOVITA_API_KEY` | `NOVITA_BASE_URL`; default `https://api.novita.ai/openai/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | OpenAI-compatible hosted route for DeepSeek model IDs. Use config or `CODEWHALE_MODEL` / `DEEPSEEK_MODEL` for model overrides. |\n| `fireworks` | `[providers.fireworks]` | `FIREWORKS_API_KEY` | `FIREWORKS_BASE_URL`; default `https://api.fireworks.ai/inference/v1` | `accounts/fireworks/models/deepseek-v4-pro` | OpenAI-compatible hosted route. Use config or `CODEWHALE_MODEL` / `DEEPSEEK_MODEL` for model overrides. |\n| `siliconflow` | `[providers.siliconflow]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.com/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | OpenAI-compatible hosted route. Official docs use the `.com` endpoint. `SILICONFLOW_MODEL` is accepted. Reasoning aliases `deepseek-reasoner` and `deepseek-r1` map to Pro; `deepseek-chat` and `deepseek-v3` map to Flash. |\n| `siliconflow-CN` | `[providers.siliconflow_cn]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.cn/v1` | Uses the SiliconFlow model set | China regional SiliconFlow route. Falls back to `[providers.siliconflow]` for api_key / base_url / model when unset. Select it with `provider = \"siliconflow-CN\"` or `CODEWHALE_PROVIDER=siliconflow-CN`. |\n| `arcee` | `[providers.arcee]` | `ARCEE_API_KEY` | `ARCEE_BASE_URL`; default `https://api.arcee.ai/api/v1` | `trinity-large-thinking`, `trinity-large-preview` | Arcee AI direct OpenAI-compatible route, tracked as 256K-context BF16 serving. `ARCEE_MODEL` is accepted. OpenRouter's `arcee-ai/trinity-large-thinking` remains the OpenRouter namespaced model ID; direct Arcee uses the bare `trinity-large-thinking` ID. |\n| `moonshot` | `[providers.moonshot]` | `MOONSHOT_API_KEY`, `KIMI_API_KEY` | `MOONSHOT_BASE_URL`, `KIMI_BASE_URL`; default `https://api.moonshot.ai/v1` | Direct Moonshot: `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.7-code-highspeed`, `kimi-k2.6`; Kimi Code membership: `k3`, `kimi-for-coding`, `kimi-for-coding-highspeed` at `https://api.kimi.com/coding/v1` | Moonshot/Kimi route. Exact direct `kimi-k3` routes use the documented Formula web-search tool/fiber loop; direct `kimi-k2.6` retains the built-in `$web_search` contract, and exact Kimi Code membership routes use their structured `/search` service. Adjacent paths, K2.7 direct models, and cross-product model IDs do not inherit native search. `kimi` and `kimi-k2` aliases select `kimi-k2.7-code`; `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, and `KIMI_MODEL` are accepted. Kimi thinking streams through `reasoning_content`; Codewhale keeps it in Thinking cells and replays it for thinking/tool-call continuity. For direct K3, use exact `base_url = \"https://api.moonshot.ai/v1\"` and `model = \"kimi-k3\"`; it is always-thinking and receives top-level `reasoning_effort = \"low\" | \"high\" | \"max\"` (`off` normalizes to `low`), uses only `max_completion_tokens`, and omits `temperature`/`top_p` per the [K3 quickstart](https://platform.kimi.ai/docs/guide/kimi-k3-quickstart). For Kimi Code K3, use a key from the [Kimi Code console](https://www.kimi.com/code/console), exact `base_url = \"https://api.kimi.com/coding/v1\"`, and bare `model = \"k3\"`; `off` becomes enabled `low`, while normal dispatched `auto` selects and sends a concrete Codewhale tier. Only an omitted reasoning setting leaves the provider default in control. That membership route defaults safely to 262,144 context tokens; the [Kimi Code model-tier table](https://www.kimi.com/code/docs/en/kimi-code/models.html) grants Allegretto and higher plans up to 1M, which those plans may express as `context_window = 1048576`. `k3[1m]` is Claude Code-only and Codewhale rejects it. `kimi-for-coding` remains the valid K2.7 membership route, and `kimi-for-coding-highspeed` is its own high-speed roster entry (262,144 context); membership ids are rejected on the direct platform endpoint, and `kimi-k3` stays rejected on the membership endpoint. Billing is decided by the endpoint the route resolves to, judged once against the two exact product endpoints: direct Moonshot (`https://api.moonshot.ai/v1` or the default) bills metered with dollar estimates, the exact Kimi Code membership endpoint bills as Kimi Code quota and never shows dollar estimates, and anything else — a gateway host, a neighboring Kimi-hosted path — reports `cost: unknown` rather than borrowing either product. An imported Kimi Code token with no `base_url` in its table still resolves to the membership endpoint, so it bills as Kimi Code quota and never accrues dollars. A completed turn, parent or sub-agent, is billed from the immutable endpoint receipt its own client was built with, never from a later config re-read: `MOONSHOT_BASE_URL`/`KIMI_BASE_URL` are merged into the *active* provider's table only, and an in-turn provider switch can move the ambient config off the route that actually ran. Legacy `auth_mode = \"kimi_oauth\"` fails to API-key guidance without probing Kimi CLI files. Codewhale does not impersonate `kimi_cli` or `kimi_code_cli`. **China-region keys:** contributor field evidence (@vFONGv, PR #5229, verified on Windows 10) reports that a China-region Moonshot key must be paired with `base_url = \"https://api.moonshot.cn/v1\"`; left on the default international host (`https://api.moonshot.ai/v1`) it fails authentication. We have no China-region key to verify this ourselves, so it is recorded as a user report rather than a tested route. Note also that editing `base_url` alone does not take effect until `codewhale auth set` is re-run for that provider. |\n| `google` | `[providers.google]` | `GOOGLE_API_KEY`, `GEMINI_API_KEY` | `GOOGLE_BASE_URL`, `GEMINI_BASE_URL`; default `https://generativelanguage.googleapis.com/v1beta/openai/` | `gemini-3.1-pro-preview` (default); `/model` also lists `gemini-3-pro-preview`, `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-2.5-pro`, `gemini-2.5-flash` | Google Gemini as its own backend on the official OpenAI-compatible Chat Completions route. Thinking models capture `extra_content.google.thought_signature` on tool calls and replay it with the assistant tool-call messages; replaying a tool call whose signature was not captured fails closed with an actionable error instead of letting the tool loop break. `gemini-2.5-flash-lite` ships thinking off and degrades with a warning instead. Reasoning effort maps onto the documented `google.thinking_config.thinking_level` (`low`/`high`). The dialect binds to the exact official base URL: a `google` row pointed at another gateway gets plain OpenAI semantics and no signature requirements. Codewhale never reads Google OAuth files; only an AI Studio API key is used. Not live-tested against the real endpoint in this environment. |\n| `zai` | `[providers.zai]` | `ZAI_API_KEY`, `Z_AI_API_KEY`, `ZHIPU_API_KEY`, `GLM_API_KEY` | `ZAI_BASE_URL`, `Z_AI_BASE_URL`; default `https://api.z.ai/api/coding/paas/v4`; general APIs `https://api.z.ai/api/paas/v4` and `https://open.bigmodel.cn/api/paas/v4` | `GLM-5.3` default; `/model` also lists `GLM-5.3-Flash`, `GLM-5.2`, `GLM-5.1`, and `GLM-5-Turbo` | Z.AI GLM Coding Plan route. All three first-party Chat routes (both api.z.ai products and BigModel's general platform endpoint) share one request dialect, so reasoning controls — the GLM-5.2 thinking toggle, tiered effort, and the forced-thinking GLM-5.3 rewrite that sends `off` as `enabled` + `reasoning_effort: \"low\"` — apply on BigModel too; neighboring paths such as `/preview` stay fail-closed. The two general API products expose structured provider-native web search (`search-prime` globally, `search_std` in China); Coding Plan and compatible custom endpoints do not inherit it. `GLM-5.3` is the default and a first-class picker row (`model = \"GLM-5.3\"` or `ZAI_MODEL=GLM-5.3`); `GLM-5.3-Flash` is the 1M multimodal fast sibling (`model = \"GLM-5.3-Flash\"`). An explicit `GLM-5.2` selection keeps its own id. Limits and reasoning options for 5.3 are inherited from `GLM-5.2` until Z.ai publishes distinct 5.3 metadata; 5.3 carries no price. Flash ships the published $0.15/$0.50 list. A live call can still 429 with entitlement code 1311 on accounts that are not provisioned for 5.3. |\n| `stepfun` | `[providers.stepfun]` | `STEPFUN_API_KEY`, `STEP_API_KEY` | `STEPFUN_BASE_URL`, `STEP_BASE_URL`; default `https://api.stepfun.ai/v1`; Coding Plan endpoint `https://api.stepfun.ai/step_plan/v1` | `step-3.7-flash` | StepFun / StepFlash direct OpenAI-compatible route. `/provider` setup asks which billing route the key belongs to — pay-as-you-go or Step Plan — validates the key against the chosen endpoint, and writes the answer to `[providers.stepfun].base_url` only. A base URL that is neither recognized route is left alone and the question is skipped. You can also set `[providers.stepfun].base_url` or `STEP_BASE_URL` to the Coding Plan URL by hand. Offline accounting labels recognized routes as `stepfun-payg` or `stepfun-plan` without persisting the raw endpoint, and only the standard PAYG route receives token pricing. `STEPFUN_MODEL` and `STEP_MODEL` are accepted. |\n| `minimax` | `[providers.minimax]` | `MINIMAX_API_KEY` | `MINIMAX_BASE_URL`; default `https://api.minimax.io/v1`; China `https://api.minimaxi.com/v1` | `MiniMax-M3`, `MiniMax-M2.7`, `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `MiniMax-M2.1`, `MiniMax-M2.1-highspeed`, `MiniMax-M2` | MiniMax direct OpenAI-compatible route. Codewhale sends `reasoning_split = true` so MiniMax thinking arrives separately from answer text. Both MiniMax dialects sell pay-as-you-go and Token Plan over the same endpoints and the same key, so billing is classified from the credential *product*, never from the endpoint or from a default. `mode = \"token-plan\"` in `[providers.minimax]`/`[providers.minimax_anthropic]`, or a Token Plan key shaped `sk-cp…`, bills as MiniMax Token Plan quota with no dollar estimates; an explicit pay-as-you-go mode (`pay-as-you-go`/`payg`/`metered`) wins over key shape. The key's product prefix is only visible when the key is in config, bound by `api_key_env`, or exported as `MINIMAX_API_KEY` on an official endpoint — a key saved through `codewhale auth set` (secret store / OS keyring) is deliberately not read to classify billing. With no explicit mode and no visible product marker the route reports `cost: unknown` rather than assuming pay-as-you-go, so a Token Plan account is never charged invented dollars. Custom/gateway endpoints also fail closed with `cost: unknown`. Official M3 input modalities are text, image, and video; M2.7 is text-only. |\n| `minimax-anthropic` | `[providers.minimax_anthropic]` | `MINIMAX_API_KEY` | `MINIMAX_ANTHROPIC_BASE_URL`; default `https://api.minimax.io/anthropic`; China `https://api.minimaxi.com/anthropic` | `MiniMax-M3`, `MiniMax-M2.7` | MiniMax direct Anthropic-compatible Messages route. Keep the `/anthropic` suffix because Codewhale appends `/v1/messages`; the route uses `x-api-key`. M3 supports adaptive or disabled thinking. M2.7 always keeps thinking enabled. |\n| `sglang` | `[providers.sglang]` | Optional `SGLANG_API_KEY` | `SGLANG_BASE_URL`; default `http://localhost:30000/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Self-hosted OpenAI-compatible route. Localhost deployments commonly omit auth. `SGLANG_MODEL` is accepted. |\n| `vllm` | `[providers.vllm]` | Optional `VLLM_API_KEY` | `VLLM_BASE_URL`; default `http://localhost:8000/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Self-hosted vLLM OpenAI-compatible route. Localhost deployments commonly omit auth. `VLLM_MODEL` is accepted. |\n| `ollama` | `[providers.ollama]` | Local optional `OLLAMA_API_KEY` | `OLLAMA_BASE_URL`; default `http://localhost:11434/v1` | live tag from the local catalog; pre-refresh placeholder `unknown`; provider-hinted custom tags pass through | Local Ollama is keyless by default. `OLLAMA_MODEL` is accepted. The header must not paint a hosted id the local daemon did not list. |\n| `ollama-cloud` | `[providers.ollama_cloud]` | `OLLAMA_CLOUD_API_KEY`, then `OLLAMA_API_KEY` | `OLLAMA_CLOUD_BASE_URL`; default `https://ollama.com/v1` | `gpt-oss:120b`; arbitrary provider-owned IDs pass through | Hosted OpenAI-compatible `/v1/chat/completions` route. Save credentials under `ollama-cloud`; the exact released `ollama` + Cloud URL tuple has bounded read-only in-memory compatibility with its legacy table and secret slot. `OLLAMA_CLOUD_MODEL` is accepted. |\n| `huggingface` | `[providers.huggingface]` | `HUGGINGFACE_API_KEY`, `HF_TOKEN` | `HUGGINGFACE_BASE_URL`, `HF_BASE_URL`; default `https://router.huggingface.co/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Hugging Face Inference Providers OpenAI-compatible router route. Accepted aliases: `huggingface`, `hugging-face`, `hugging_face`, `hf`. Org-prefixed model IDs pass through. `HUGGINGFACE_MODEL` and `HF_MODEL` are accepted. Hub browsing/export are separate future features. |\n| `modelscope` | `[providers.modelscope]` | `MODELSCOPE_API_KEY` | `MODELSCOPE_BASE_URL`; default `https://api-inference.modelscope.cn/v1` | `Qwen/Qwen3.5-397B-A17B` (default), `Qwen/Qwen3.5-122B-A10B`, `Qwen/Qwen3.5-27B`, `Qwen/Qwen3.5-35B-A3B`, `Qwen/Qwen3.8-27B`, `Qwen/Qwen3.8-Flash-Next`, `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Pro-0813`, `deepseek-ai/DeepSeek-V4.1-Flash`, `ZhipuAI/GLM-4.7-Flash`, `ZhipuAI/GLM-5.2` | ModelScope OpenAI-compatible inference route. Org-prefixed model IDs pass through. `MODELSCOPE_MODEL` is accepted. |\n| `deepinfra` | `[providers.deepinfra]` | `DEEPINFRA_API_KEY`, `DEEPINFRA_TOKEN` | `DEEPINFRA_BASE_URL`; default `https://api.deepinfra.com/v1/openai` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | DeepInfra OpenAI-compatible route. Drop-in replacement for OpenAI SDK. |\n| `together` | `[providers.together]` | `TOGETHER_API_KEY` | `TOGETHER_BASE_URL`; default `https://api.together.xyz/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash`, `thinkingmachines/inkling` | Together AI OpenAI-compatible route. `TOGETHER_MODEL` is accepted. Model aliases `deepseek-v4-pro` and `deepseek-v4-flash` normalize to Together's org-prefixed IDs; `inkling` and `together-inkling` normalize to Together's published lowercase Inkling wire ID. Inkling uses the exact `none`/`minimal`/`low`/`medium`/`high`/`max` reasoning vocabulary from Thinking Machines' [official model repository](https://huggingface.co/thinkingmachines/Inkling). Together's [launch post](https://www.together.ai/blog/together-ai-brings-thinking-machines-labs-new-model-inkling-on-day-0) currently says Inkling is live with 1M context, while its [model detail page](https://www.together.ai/models/inkling) says coming soon with 256K context and publishes no price. Until Together's active `/models` endpoint and the Models.dev catalog resolve that conflict, Inkling is not seeded into Codewhale's offline picker and no route-specific context or cost is inferred. |\n| `qianfan` | `[providers.qianfan]` | `QIANFAN_API_KEY`, `BAIDU_QIANFAN_API_KEY` | `QIANFAN_BASE_URL`, `BAIDU_QIANFAN_BASE_URL`; default `https://api.baiduqianfan.ai/v1` | `ernie-4.0-turbo-8k`; provider-scoped custom Qianfan service/model IDs pass through | Baidu Qianfan OpenAI-compatible route. Requests use Bearer auth and Chat Completions payloads. `QIANFAN_MODEL` and `BAIDU_QIANFAN_MODEL` are accepted; aliases `baidu-qianfan`, `baidu_qianfan`, and `baidu` resolve to this provider. Tool/function calling is model-scoped in Qianfan docs, so Codewhale preserves the selected wire model and leaves live capability proof to follow-up route/capability work. |\n| `openai-codex` | `[providers.openai_codex]` | Official Sign in with ChatGPT (`codewhale auth chatgpt` / `/provider setup openai-codex`) | Official `https://api.openai.com/v1` | Selected account catalog; configured model IDs remain explicit selections | **Experimental.** Public Responses endpoint (`/v1/responses`) with a validated `chatgpt.tokens.use.direct` grant. Dynamic OSS registration issues a client ID for the selected account/workspace; Codewhale protects and renews its own tokens. The account catalog supplies selectable models; public model lists and legacy Codex tokens do not establish plan permission. No silent billing fallback. See [Sign in with ChatGPT](#sign-in-with-chatgpt) for setup, usage limits, and preview boundaries. |\n| `anthropic` | `[providers.anthropic]` | `ANTHROPIC_API_KEY` | `ANTHROPIC_BASE_URL`; default `https://api.anthropic.com` | `claude-opus-4-8`, `claude-sonnet-4-6` (default), `claude-haiku-4-5` | Native Anthropic Messages API route (`/v1/messages`, `x-api-key` + `anthropic-version: 2023-06-01`) — not OpenAI-compatible. Prompt caching via `cache_control` breakpoints, adaptive thinking + `output_config.effort`, signed thinking blocks replayed verbatim, cache telemetry normalized per #2961. `ANTHROPIC_MODEL` is accepted. |\n| `openmodel` | `[providers.openmodel]` | `OPENMODEL_API_KEY` | `OPENMODEL_BASE_URL`; default `https://api.openmodel.ai` | `deepseek-v4-flash`; provider-scoped custom model IDs pass through | OpenModel Anthropic-compatible Messages route. Uses `/v1/messages`, Bearer auth, and `anthropic-version: 2023-06-01`; OpenModel selects DeepSeek, DashScope, Xiaomi, Claude, and other routes by model id. `OPENMODEL_MODEL` is accepted. |\n| `sakana` | `[providers.sakana]` | `FUGU_API_KEY`, `SAKANA_API_KEY` | `SAKANA_BASE_URL`; default `https://api.sakana.ai/v1` | `fugu` (default), `fugu-ultra-20260615` | Sakana AI Fugu OpenAI-compatible route. Standard Chat Completions wire protocol; streaming supported. `fugu-ultra-20260615` is the heavy/reasoning variant. Env var aliases: `FUGU_API_KEY` (primary), `SAKANA_API_KEY`; provider aliases: `sakana-ai`, `sakana_ai`, `fugu`. |\n| `longcat` | `[providers.longcat]` | `LONGCAT_API_KEY` | `LONGCAT_BASE_URL`; default `https://api.longcat.chat/openai/v1` | `LongCat-2.0` (default) | Meituan LongCat curated model gateway. OpenAI-compatible Chat Completions wire protocol. Sign up at https://longcat.chat/platform for an API key. Provider aliases: `long-cat`, `meituan-longcat`, `meituan`. |\n| `opencode-go` | `[providers.opencode_go]` | `OPENCODE_GO_API_KEY` | `OPENCODE_GO_BASE_URL`; default `https://opencode.ai/zen/go/v1` | `deepseek-v4-pro` (default), `grok-4.5`, `glm-5.2`, `glm-5.1`, `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.6`, `deepseek-v4-flash`, `mimo-v2.5`, `mimo-v2.5-pro` | [OpenCode Go](https://opencode.ai/docs/go/) subscription route using OpenAI-compatible Chat Completions. `OPENCODE_GO_MODEL` is accepted. Codewhale uses bare wire IDs; familiar `opencode-go/` input aliases normalize to the bare ID. Go models documented only on the Anthropic `/messages` endpoint are deliberately not advertised by this route until Codewhale supports per-model wire selection. Billing surfaces show the Go allowance instead of token-price estimates. |\n| `opencode-zen` | `[providers.opencode_zen]` | `OPENCODE_ZEN_API_KEY`, fallback `OPENCODE_API_KEY` | `OPENCODE_ZEN_BASE_URL`; default `https://opencode.ai/zen/v1` | `gpt-5.6` (default); current GPT, Claude, Qwen, DeepSeek, MiniMax, GLM, Kimi, Grok, Muse Spark, and free-model IDs | [OpenCode Zen](https://opencode.ai/docs/zen/) model-aware gateway. `OPENCODE_ZEN_MODEL` is accepted, and official `opencode/` selectors normalize to bare wire IDs. Each model's wire comes from the curated snapshot, then from the AI SDK package its [Models.dev](https://models.dev) row names: GPT, Grok, and Muse Spark rows use `/responses`; Claude and most Qwen rows use `/messages`; DeepSeek, MiniMax, GLM, Kimi, `qwen3.8-max`, and the free rows use `/chat/completions`. Responses and Chat Completions authenticate with Bearer `Authorization`, while Anthropic Messages uses `x-api-key`; none of these routes use ChatGPT/Codex OAuth guidance or headers. Gemini fails closed because its model-specific Google wire protocol is not implemented; Models.dev rows marked `deprecated` and models no loaded catalog lists also fail closed. See [OpenCode Zen protocol catalog](#opencode-zen-protocol-catalog). |\n| `meta` | `[providers.meta]` | `META_MODEL_API_KEY`, `MODEL_API_KEY` | `META_MODEL_API_BASE_URL`, `MODEL_API_BASE_URL`; default `https://api.meta.ai/v1` | `muse-spark-1.2` (default) | [Meta Model API](https://developer.meta.com/ai/resources/blog/build-with-muse-spark/) public-preview route using OpenAI-compatible Chat Completions. Muse Spark 1.2 keeps its wire ID, tool support, 1M context, 32K output metadata, and `none` through `xhigh` reasoning effort. `META_MODEL_API_MODEL` and `MODEL_API_MODEL` are accepted. Provider aliases: `meta-ai`, `meta_model_api`, `muse`, `muse-spark`. |\n| `telecomjs` | `[providers.telecomjs]` | `TELECOMJS_API_KEY` | `TELECOMJS_BASE_URL`; default `https://aigw.telecomjs.com/v1` | `deepseek-v4-pro` conservative fallback; authenticated `/models` rows when a key is configured | TelecomJS TokenHub OpenAI-compatible Chat Completions route. Live catalogs are isolated by provider and key fingerprint, stale rows survive transient refresh failures, and unsupported reasoning request fields are omitted. `TELECOMJS_MODEL` is accepted. Provider aliases: `telecom-js`, `telecom_js`, `telecomjs-cn`, `tokenhub`. |\n| `mistral` | `[providers.mistral]` | `MISTRAL_API_KEY` | `MISTRAL_BASE_URL`; default `https://api.mistral.ai/v1` | `mistral-code-latest` (default; `codestral-latest` accepted as alias), `mistral-medium-latest` (aliases: `mistral-medium-3-5`), `mistral-small-latest` (aliases: `mistral-small-2603`), `mistral-large-latest` | Mistral AI (la Plateforme) OpenAI-compatible Chat route. On the documented first-party HTTPS `/v1` hosts, Medium and Small send adjustable `reasoning_effort` (`none` or `high` only), parse Mistral's polymorphic thinking/text blocks, and replay stored thinking in that same wire shape. Deprecated native Magistral IDs remain explicit-configuration compatibility routes: they are always-reasoning and never receive the adjustable effort field. Code and Large are non-reasoning. A custom `MISTRAL_BASE_URL` keeps generic Chat semantics unless it is one of the documented first-party hosts. `MISTRAL_MODEL` is accepted. Provider aliases: `mistral-ai`, `mistralai`, `la-plateforme`. |\n| `edenai` | `[providers.edenai]` | `EDENAI_API_KEY` | `EDENAI_BASE_URL`; default `https://api.edenai.run/v3`; EU `https://api.eu.edenai.run/v3` | `deepseek/deepseek-v4-pro` (default); live `/models` catalog of `provider/model` ids | Eden AI OpenAI-compatible aggregation gateway. Catalog rows remain provider-scoped; generic reasoning controls are omitted because supported fields depend on the selected upstream family. `EDENAI_MODEL` is accepted. The default `deepseek/deepseek-v4-pro` is listed on the global catalog only; on the EU endpoint set `EDENAI_MODEL` (or `model`) to a row from the EU `/models` list, for example `qwen/deepseek-v4-pro`. Provider aliases: `eden-ai`, `eden_ai`. |\n| `zenmux` | `[providers.zenmux]` | `ZENMUX_API_KEY` | `ZENMUX_BASE_URL`; default `https://zenmux.ai/api/v1` | `deepseek/deepseek-v4.1-flash` (default); live `/models` catalog of `provider/model` ids (keyless-readable) | ZenMux OpenAI-compatible aggregation gateway (~190 models). Catalog rows remain provider-scoped; generic reasoning controls are omitted because supported fields depend on the selected upstream family. `ZENMUX_MODEL` is accepted. Provider aliases: `zen-mux`, `zen_mux`. |\n| `csdn` | `[providers.csdn]` | `CSDN_API_KEY` | `CSDN_BASE_URL`; default `https://ai.csdn.net/api/model/v1` | `glm_for_coding` (default; the Coding Plan's dedicated model id); other marketplace model ids pass through | CSDN 星图 (Starmap) OpenAI-compatible hosted platform. Coding Plan keys and general marketplace keys share the one endpoint, so billing follows the credential product, never the URL alone: routing `glm_for_coding` — the shipped default — or setting `mode = \"coding_plan\"`/`\"plan\"`/`\"subscription\"` bills as CSDN Coding Plan quota with no dollar estimates; any other model, or an explicit `pay-as-you-go`/`metered` mode, bills metered; an unrecognized mode or an endpoint off `ai.csdn.net/api/model/v1` reports `cost: unknown`. `CSDN_MODEL` is accepted. Provider aliases: `csdn-ai`, `csdn_ai`, `csdn-coding-plan`, `csdn_coding_plan`, `starmap`. |\n| `concentrate` | `[providers.concentrate]` | `CONCENTRATE_API_KEY` | `CONCENTRATE_BASE_URL`; default `https://api.concentrate.ai/v1` | `deepseek-v4-pro` (default; a plain catalog id lets the gateway pick the upstream provider); `provider/model` ids such as `openai/gpt-5.6-sol` pin one upstream; `concentrate/auto` is the gateway's own router; unauthenticated live `/v1/models` catalog | Concentrate OpenAI Responses-compatible gateway (`POST /v1/responses`, bearer Universal API key). Opt-in and BYOK only: your key, your Concentrate bill, zero Codewhale fee, no managed default. Requests carry only documented fields (the system prompt rides as a `system` input item). Contract: [API introduction](https://concentrate.ai/docs/api-reference/introduction), [request parameters](https://concentrate.ai/docs/api-reference/endpoint/request-parameters), [streaming](https://concentrate.ai/docs/api-reference/endpoint/streaming), [errors](https://concentrate.ai/docs/api-reference/endpoint/errors). See [Concentrate Notes](#concentrate-notes). |\n| `codewhale` | `[providers.codewhale]` | `CODEWHALE_API_KEY` | `CODEWHALE_API_BASE`; default `https://api.codewhale.net/v1` | `deepseek/deepseek-v4-pro` (default), `anthropic/claude-sonnet-5`, `openai/gpt-5.6` are offline bootstrap rows only; the authenticated `GET /v1/models` listing of the account's connected providers is the catalog authority | Codewhale API: account-backed model access over the provider keys the customer connected to their Codewhale account. One `cwc_key_…` account API key with the `models:infer` scope authenticates every model. Model ids are `provider/model` exactly as the account catalog returns them, and each row states its protocol (`chat-completions` → `/v1/chat/completions`, `anthropic-messages` → `/v1/messages`, `responses` → `/v1/responses`); every protocol uses `Authorization: Bearer`, never `x-api-key`. `CODEWHALE_API_BASE` must be HTTPS except on loopback. Connect provider keys with `codewhale account keys set `. Provider aliases: `codewhale-api`, `cw-api`, `codewhale-cloud`. |\n| `xai` | `[providers.xai]` | `XAI_API_KEY`, Codewhale-owned device OAuth, or explicit read-only Grok CLI consent | `XAI_BASE_URL`; default `https://api.x.ai/v1` | `grok-4.6` (default), `grok-4.7`, `grok-4.5`, `grok-4.3`, `grok-build`, `grok-composer-2.5-fast`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning` | xAI/Grok OpenAI-compatible Chat Completions route. Grok 4.6 has a 500K context window, text/image input, function calls, structured output, server-side web search, and `low`/`medium`/`high`/`xhigh` reasoning (default `high`). [Grok 4.7](https://docs.x.ai/docs/models/grok-4.7) has the same 500K window, text/image input, function calls, structured output, reasoning ladder and $2.00 / $0.50 cached / $6.00 rates; xAI documents no web-search support for it yet, so Codewhale does not claim it. Grok reasoning cannot be disabled ([reasoning guide](https://docs.x.ai/docs/guides/reasoning)). Its standard rates double when the prompt reaches 200K tokens; the same 2x long-context rule applies to `grok-4.5` (500K context, $2.00 / $0.30 cached / $6.00) and `grok-4.3` (1M context, $1.25 / $0.20 cached / $2.50) per their [model pages](https://docs.x.ai/docs/models/grok-4.5). There is no documented `latest`/`fast` alias and no published numeric output limit. **API-key** (default): Bearer token from console.x.ai via `XAI_API_KEY` / keyring / `api_key`. **OAuth**: `codewhale auth xai-device` uses SSH-friendly device login and Codewhale-owned storage, which may refresh itself. Existing Grok CLI credentials require `codewhale auth external-consent --provider xai --mode read-only`; the granted external file is never refreshed or rewritten. OAuth may return HTTP 403 on some SuperGrok tiers — keep API-key as the reliable fallback. `XAI_MODEL` is accepted. Provider aliases: `x-ai`, `x_ai`, `grok`. |\n| `modelstudio-token-plan` | `[providers.modelstudio_token_plan]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | `MODELSTUDIO_TOKEN_PLAN_BASE_URL`; default `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` | `qwen3.8-max` (default), `qwen3.8-max-preview`, `qwen3.7-plus`, `qwen3.7-max`, `qwen3.6-flash`, `deepseek-v4-pro`, `deepseek-v4-flash-0731`, `glm-5.2` | Alibaba Cloud Model Studio Token Plan OpenAI-compatible Chat Completions route. Token Plan Personal and Team share this endpoint. `qwen3.8-max`, `qwen3.7-plus`, and `qwen3.7-max` can use provider-native web search through the Token Plan Responses Harness; the preview, Coding Plan, and Anthropic routes do not inherit that capability. All listed models are reasoning-capable text/coding models. DeepSeek and GLM entries are provider-scoped and do not collide with first-party routes. `MODELSTUDIO_TOKEN_PLAN_MODEL` is accepted. Provider aliases: `modelstudio-token-plan`, `alibaba-token-plan`, `dashscope-token-plan`. |\n| `modelstudio-token-plan-anthropic` | `[providers.modelstudio_token_plan_anthropic]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | default `https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic` | Same model catalog as `modelstudio-token-plan` | Token Plan Anthropic-compatible Messages route (`/apps/anthropic`). Same API key as the OpenAI dialect. Provider aliases: `modelstudio-token-plan-anthropic`, `alibaba-token-plan-anthropic`. |\n| `modelstudio-coding-plan` | `[providers.modelstudio_coding_plan]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | `MODELSTUDIO_CODING_PLAN_BASE_URL`; default `https://coding-intl.dashscope.aliyuncs.com/v1` | `qwen3.8-max` (default); same catalog as Token Plan | Alibaba Cloud Model Studio Coding Plan OpenAI-compatible Chat Completions route. `MODELSTUDIO_CODING_PLAN_MODEL` is accepted. Provider aliases: `modelstudio-coding-plan`, `alibaba-coding-plan`, `dashscope-coding-plan`. |\n| `modelstudio-coding-plan-anthropic` | `[providers.modelstudio_coding_plan_anthropic]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | default `https://coding-intl.dashscope.aliyuncs.com/apps/anthropic` | Same model catalog as `modelstudio-coding-plan` | Coding Plan Anthropic-compatible Messages route (`/apps/anthropic`). Provider aliases: `modelstudio-coding-plan-anthropic`, `alibaba-coding-plan-anthropic`. |\n\nStepFun's four coding models are available through both its standard API and\n[Step Plan](https://platform.stepfun.ai/docs/en/step-plan/integrations/reasoning-api).\nChoose the billing route in `/provider`, then the model in `/model`; existing\nStep 3.7 selections remain unchanged. [Step 5 Preview](https://platform.stepfun.ai/docs/en/guides/models/step-5-preview)\nhas a 1M context window. Step 5 and Step 3.7 expose low/medium/high reasoning;\nStep 3.5 Flash 2603 exposes low/high; base Step 3.5 uses provider-default reasoning.\n[Published API prices](https://platform.stepfun.ai/docs/en/guides/pricing/details)\napply only to verified PAYG routes. Step Plan displays subscription allowance.\nSpeech, music and image-generation models use separate interfaces and are not\npresented as coding models. Provider video capability metadata does not imply\nthat every Codewhale client can attach video.\n\n\n### OpenCode Zen protocol catalog\n\nZen Responses and Chat Completions requests authenticate with Bearer\n`Authorization`; Zen Anthropic Messages requests use `x-api-key`. None of these\nroutes add ChatGPT/Codex OAuth headers.\n\nEach Zen model's wire comes from two sources, and neither is guessed from a\nmodel-id family (`qwen3.8-flash` is Messages while `qwen3.8-max` is Chat):\n\n1. **The curated snapshot** compiled into Codewhale, verified against the\n [official endpoint table](https://opencode.ai/docs/zen/) and the `opencode`\n provider in [Models.dev](https://models.dev). It is the offline floor, and\n it wins when a catalog row names a different wire for the same id.\n2. **The Models.dev catalog.** Its `opencode` provider is Zen's published\n catalog: each model row names the AI SDK package OpenCode itself uses, which\n Codewhale maps exactly — `@ai-sdk/openai` to Responses, `@ai-sdk/anthropic`\n to Anthropic Messages, and `@ai-sdk/openai-compatible` (the provider\n default) to Chat Completions. A Zen model released after this build routes\n once the catalog lists it, without a Codewhale release. The interactive TUI\n and `codewhale exec` both load the persisted catalog; refresh it with\n `codewhale models --update`.\n\nThe curated snapshot:\n\n- Responses: `gpt-6-astra`, `gpt-6-sol`, `gpt-6-luna`, `gpt-5.6-sol`,\n `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5`, `gpt-5.5-pro`, `gpt-5.4`,\n `gpt-5.4-pro`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.3-codex`,\n `gpt-5.3-codex-spark`, `gpt-5.2`, `gpt-5.2-codex`, `gpt-5.1`,\n `gpt-5.1-codex`, `gpt-5.1-codex-max`, `gpt-5.1-codex-mini`, `gpt-5`,\n `gpt-5-codex`, `gpt-5-nano`, `grok-4.7`, `grok-4.6`, `grok-4.5`,\n `grok-build-0.1`, `muse-spark-1.3`, `muse-spark-1.3-contributor-free`,\n `muse-spark-1.2`, `muse-spark-1.2-contributor`,\n `muse-spark-1.2-contributor-free`.\n- Anthropic Messages: `claude-fable-5-1`, `claude-fable-5`,\n `claude-opus-5-5`, `claude-opus-5`, `claude-opus-4-8`, `claude-opus-4-7`,\n `claude-opus-4-6`, `claude-opus-4-5`, `claude-sonnet-5-5`,\n `claude-sonnet-5`, `claude-sonnet-4-6`, `claude-sonnet-4-5`,\n `claude-sonnet-4`, `claude-haiku-4-5`, `qwen3.8-flash`, `qwen3.7-max`,\n `qwen3.7-plus`, `qwen3.6-plus`, `qwen3.5-plus`.\n- Chat Completions: `deepseek-v4.1-flash`, `deepseek-v4-pro`,\n `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp`, `minimax-m3`,\n `minimax-m2.7`, `minimax-m2.5`, `glm-5.3-flash`, `glm-5.3`, `glm-5.2`,\n `glm-5.1`, `glm-5`, `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.6`, `kimi-k2.5`,\n `qwen3.8-max`, `big-pickle`, `space-bunny-free`,\n `longcat-2.5-preview-free`, `mimo-v2.6-flash-free`, `mimo-v2.5-free`,\n `ling-3.0-flash-fin-free`, `north-mini-code-free`,\n `nemotron-3-ultra-free`, `nemotron-3.5-lightning-free`,\n `deepseek-v4-flash-free`.\n\nThese fail closed locally, with the reason in the error, instead of reaching\nZen: Gemini (`@ai-sdk/google`, Google's model-specific protocol, which Codewhale\ndoes not speak); a catalog row naming any other package; a catalog row Models.dev\nmarks `deprecated`; and a model neither source lists. A miss never falls back to\nanother Zen wire shape, including when a custom Zen base URL is configured.\n\n### Concentrate Notes\n\nConcentrate (`concentrate`) is an OpenAI **Responses**-compatible gateway:\n`POST https://api.concentrate.ai/v1/responses` with `Authorization: Bearer\n`. It is opt-in and **BYOK only**.\n\n- **Model ids pass through.** A plain catalog id (`gpt-5.6-sol`,\n `deepseek-v4-pro`) lets the gateway choose the upstream provider;\n `provider/model` (`openai/gpt-5.6-sol`) pins the upstream provider inside the\n gateway; `concentrate/auto` sends the gateway's own `auto` router. Codewhale's\n bare `--model auto` remains the resolver sentinel (provider default), which is\n why the gateway router has its own explicit spelling. Once streaming begins\n the gateway commits to one provider ([auto routing](https://concentrate.ai/docs/api-reference/endpoint/auto-routing)).\n- **Wire.** Fixed Responses dialect. The request carries only documented\n fields — `model`, `input`, `stream`, `max_output_tokens`, `tools`,\n `tool_choice`, `parallel_tool_calls`, `reasoning.effort` — and the system\n prompt travels as a leading `system` input item because `instructions` is\n not in the gateway's [parameter reference](https://concentrate.ai/docs/api-reference/endpoint/request-parameters).\n Streaming follows the typed `response.*` SSE events and ends on\n `response.completed` without a `[DONE]` sentinel ([streaming](https://concentrate.ai/docs/api-reference/endpoint/streaming)).\n- **Errors.** `{ \"error\", \"message\", \"model\"?, \"retry_after\"? }` with 400\n (bad request / unknown model), 401 (invalid key), 402 (insufficient\n credits), 424 (upstream provider unavailable), 429 (rate limit) —\n surfaced verbatim and classified by message ([errors](https://concentrate.ai/docs/api-reference/endpoint/errors)).\n- **Catalog.** `GET /v1/models` needs no key\n ([list models](https://concentrate.ai/docs/api-reference/endpoint/list-models)); the live rows\n are provider-scoped and unclaimed (no pricing or capability claims).\n- **Commercial boundary.** Concentrate's [Terms of Service](https://concentrate.ai/legal/terms-of-service)\n forbid reselling, white-labeling, or providing the service on a\n service-bureau basis without written consent, and its\n [Acceptable Use Policy](https://concentrate.ai/legal/acceptable-use-policy)\n forbids sharing or sublicensing keys. Codewhale therefore ships **no**\n Codewhale-owned Concentrate key, no stored customer key, no default or\n managed routing, and no markup — activation of any hosted lane is gated on\n written consent, terms, and billing approval (tracked in the Codewhale ops\n evidence checklist `concentrate-gateway-20260829/CHECKLIST.md`). Public\n pricing states \"No platform markup on tokens\".\n- **Self-test without a key.** `scripts/concentrate-selftest.sh` boots a\n local stub that speaks the documented contract and drives the real\n `codewhale exec` path through it, asserting the URL, bearer header, model\n passthrough, streaming, and the completed-turn receipt. No network call\n leaves the machine and no account is required.\n\n### Hugging Face Provider vs MCP vs Hub\n\nCodewhale's `huggingface` provider ID is only the OpenAI-compatible chat\ninference route through Hugging Face Inference Providers. It is selected with\n`/provider huggingface`, `CODEWHALE_PROVIDER=huggingface`, or\n`provider = \"huggingface\"`.\n\nHugging Face MCP is a separate external-tool route. Configure it through the\nMCP config described in `docs/MCP.md`, preferably using the settings-generated\nsnippet from . In the TUI, `/hf mcp status`\nchecks whether the Hugging Face MCP server appears in the resolved MCP config,\n`/hf mcp setup` prints the settings workflow and a placeholder-only shape, and\n`/hf concepts` explains the provider/MCP/Hub distinction.\n\nHub publishing or repository management remains explicit user action through\nHub-native tooling such as `huggingface_hub` or git. The `/hf` helper does not\nupload to Hugging Face and does not perform direct Hugging Face Hub HTTP search.\n\n### Xiaomi MiMo Notes\n\n`xiaomi-mimo` defaults to `mimo-v2.5-pro` for long-context reasoning and coding\nwork. The chat picker also exposes `mimo-v2.5-pro-ultraspeed` and the latest\nOmni model `mimo-v2.5`. Xiaomi MiMo TTS is available through\n`codewhale --provider xiaomi-mimo speech \"text\" --model tts` (or the `tts`\nalias). In Act and Operate, the provider-specific `speech` / `tts` tools are\navailable through deferred discovery when the Xiaomi MiMo route is configured.\n\n`/provider xiaomi-mimo ultraspeed` and `/provider xiaomi-mimo pro-ultraspeed`\nboth select `mimo-v2.5-pro-ultraspeed`. Speech aliases such as `tts`,\n`voice-design`, and `voice-clone` are separate from normal chat defaults.\n\nToken Plan keys default to the Singapore endpoint\n`https://token-plan-sgp.xiaomimimo.com/v1`. If your MiMo account is provisioned\nfor the China region, set `base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"`\nexplicitly in `[providers.xiaomi_mimo]` or set `mode = \"token-plan-cn\"`. Europe\nToken Plan accounts can set\n`base_url = \"https://token-plan-ams.xiaomimimo.com/v1\"` or use\n`mode = \"token-plan-ams\"`; `mode = \"pay-as-you-go\"`\nselects the standard API endpoint and standard MiMo key family. Xiaomi Token\nPlan docs and console expose credit/quota semantics, but Codewhale does not\ncurrently have a documented balance endpoint to poll, so cost display remains\nunknown rather than reusing token-price estimates from another provider.\n\nVoice-design and voice-clone shorthands map to `mimo-v2.5-tts-voicedesign` and\n`mimo-v2.5-tts-voiceclone`. Xiaomi's current\n[image-understanding guide](https://platform.xiaomimimo.com/docs/en-US/usage-guide/multimodal-understanding/image-understanding)\nincludes `mimo-v2.5` for image input. Codewhale exposes image analysis through the\nseparate `[vision_model]` / `image_analyze` path; set that model to\n`mimo-v2.5` when using MiMo for vision.\n\n### OpenRouter-Compatible Base URLs\n\nOpenRouter-compatible gateways should usually stay on the `openrouter`\nprovider with a provider-scoped `base_url` override instead of moving through\nthe generic `openai` route. That keeps OpenRouter-style reasoning, streaming,\ncache usage, and namespaced wire model parsing attached to the selected route:\n\n```toml\nprovider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"sk-...\"\nbase_url = \"https://openrouter-compatible.example/v1\"\nmodel = \"deepseek/deepseek-v4-pro\"\n```\n\nCodewhale preserves the `deepseek/` wire-model prefix under the OpenRouter\nprovider scope; it does not infer a switch to the direct DeepSeek provider from\nthat model string. Cache fields such as `prompt_cache_hit_tokens`,\n`prompt_cache_miss_tokens`, and `prompt_tokens_details.cached_tokens` are\nparsed when the upstream gateway sends them. If a key/account type omits those\nfields, Codewhale treats them as absent for that response rather than as a\ndifferent provider route.\n\nOrcaRouter (`https://api.orcarouter.ai/v1`) is a dedicated named route\n([OrcaRouter](https://www.orcarouter.ai)) that speaks the same OpenAI\nChat Completions wire protocol and serves the same namespaced\n`vendor/model` catalog. It does not need an OpenRouter-compatible\n`base_url` override: select `provider = \"orcarouter\"` and its namespaced\nwire models (for example `deepseek/deepseek-v4-pro` or its own\n`orcarouter/auto` router) pass through verbatim, exactly as they do on the\nOpenRouter provider scope.\n\n#### OrcaRouter credentials: API key or OAuth 2.0 + PKCE\n\nOrcaRouter accepts two credential sources, and both write the same durable\n`sk-orca-…` key to the same `orcarouter` secret-store slot with\n`auth_mode = \"api_key\"`:\n\n- **API key** — `codewhale auth set --provider orcarouter`, `/provider`, or\n `ORCAROUTER_API_KEY`. Use this when you already have a key.\n- **Connect with OrcaRouter** — `codewhale auth orcarouter` (CLI) or\n `/auth orcarouter` (in-session). This runs an OAuth 2.0 authorization-code\n flow with PKCE (S256) on a loopback redirect. The browser is sent to\n `GET https://www.orcarouter.ai/auth` with `callback_url`, `code_challenge`,\n `code_challenge_method=S256`, `state`, `app_name`, and `scope`; the code is\n exchanged at `POST https://www.orcarouter.ai/api/v1/auth/keys` for a\n durable API key. There is no client secret and no pre-registered redirect\n URI; state is compared in constant time before the code is used.\n\nAuthentication and inference use different origins: the consent and exchange\nlive on `https://www.orcarouter.ai`; models and chat live on\n`https://api.orcarouter.ai/v1`. Neither is derived from the other, and\n`/v1/auth/keys` on the API origin is not the exchange route. Self-hosted\ndeployments can override each origin explicitly with `ORCA_AUTH_BASE_URL`\n(auth) and `ORCA_API_BASE_URL`/`ORCAROUTER_BASE_URL` (inference); the explicit\nvalue wins. Remote origins must be HTTPS; plain HTTP is accepted only on\nloopback.\n\nA PKCE-issued key is a durable API key, **not** a refresh token: Codewhale\nstores it, reuses it across restarts, and never sends a refresh grant. Revoke\nthe key on the OrcaRouter console (`/auth orcarouter-revoke` clears the local\ncopy) and sign in again to get a new one. A 401 from the relay marks that\ncredential generation as needing re-authentication rather than silently\nretrying.\n\nThe chat model list is discovered live from `GET https://api.orcarouter.ai/v1/models`\nwith the configured key. OrcaRouter publishes `supported_endpoint_types` on\nevery row, so the chat selector keeps only rows advertising `openai`,\n`anthropic`, `gemini`, or `openai-response`, and drops image-generation,\nvideo, and rerank rows instead of guessing from a model name. A row that\nstates `architecture.input_modalities` with `image` is image-input capable;\nrows that state no architecture are treated as unknown, not as text-only.\n\n### Recent OpenRouter Large Models\n\nOpenRouter completions and static registry rows include the April 2026 onward\nlarge models verified through OpenRouter's model metadata:\n`arcee-ai/trinity-large-thinking`, `qwen/qwen3.6-flash`,\n`qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`,\n`qwen/qwen3.6-plus`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`,\n`xiaomi/mimo-v2.5`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`,\n`z-ai/glm-5.1`, `z-ai/glm-5.2`, `z-ai/glm-5-turbo`, `tencent/hy3-preview`,\n`google/gemma-4-31b-it`, `google/gemma-4-26b-a4b-it`, and\n`nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`.\n`minimax/minimax-m3` was added from OpenRouter's May 31, 2026 listing as a 1M\ncontext multimodal model for coding, tool use, and long-horizon agentic work.\n`GLM-5.3` is now the default direct Z.AI Coding Plan model; `GLM-5.2` /\n`z-ai/glm-5.2` remain available (explicit selections keep their own id),\n`GLM-5.1` / `z-ai/glm-5.1` remain available as the smaller model,\n`GLM-5.3-Flash` / `z-ai/glm-5.3-flash` is the faster/explore sibling of\n`GLM-5.3`, and `GLM-5-Turbo` / `z-ai/glm-5-turbo` remains the faster sibling\nof `GLM-5.2`.\n`GLM-5.3` / `z-ai/glm-5.3` and `GLM-5.3-Flash` / `z-ai/glm-5.3-flash` are\nfirst-class picker ids on the Z.ai and OpenRouter routes (`/model` after\n`/provider zai`, or `model = \"GLM-5.3-Flash\"`).\nLimits and reasoning options for 5.3 are inherited from\n`GLM-5.2` until Z.ai publishes distinct 5.3 metadata, and 5.3 carries no\nprice. Flash ships the published $0.15/$0.50 list. A live call can still\n429 with entitlement code 1311 on accounts that are not provisioned for 5.3.\n\n## Static Model Registry\n\nThis table records legacy selector compatibility. Current `codewhale model\nresolve`, `codewhale model list`, the picker and Runtime API use the shared\nprovider lake and route resolver. `models --update` refreshes the existing\ncatalog cache; later routes see new metadata, while dispatched requests retain\ntheir admitted limits and price receipt.\n\nThe reviewed `selections` in `crates/config/assets/catalog_corrections.json`\nremain compatibility metadata for legacy callers. They do not establish account\navailability or executable route permission. Public discovery can contain\nmetadata-only models; an authenticated provider roster establishes a different\nfact. A same-named custom endpoint keeps its own declared limits and prices.\n\n`codewhale model resolve MODEL --provider PROVIDER` reports the exact wire ID,\ncanonical ID when declared, limits and implemented capabilities. Runtime\n`GET /v1/providers/PROVIDER/models` adds source, fetch/expiry metadata, published\nprice structure, reasoning options, configured effective limits and the price\nstatus. `account_access: unverified` means these facts have not established the\nselected account's access. Context responses distinguish an active engine\nwindow from the static route's resolution source, including any fallback.\nSee [Catalog refresh](CATALOG_REFRESH.md) for freshness and estimate boundaries.\n\n| Provider | Static registry entries | Tool calls | Registry reasoning flag |\n| --- | --- | --- | --- |\n| `deepseek` | `deepseek-v4-pro`, `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp` | yes | yes |\n| `nvidia-nim` | `deepseek-ai/deepseek-v4-pro`, `deepseek-ai/deepseek-v4-flash` | yes | yes |\n| `openai` | `deepseek-v4-pro`, `deepseek-v4-flash`, `gpt-5.6`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` | yes | yes |\n| `atlascloud` | `deepseek-ai/deepseek-v4-flash`, `deepseek-ai/deepseek-v4-pro` | yes | yes |\n| `wanjie-ark` | `deepseek-reasoner` | yes | yes |\n| `volcengine` | `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash` | yes | yes |\n| `openrouter` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`, `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `minimax/minimax-m2.7`, `xiaomi/mimo-v2.5-pro`, `xiaomi/mimo-v2.5`, `qwen/qwen3.6-flash`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`, `qwen/qwen3.6-plus`, `qwen/qwen3.7-max`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`, `z-ai/glm-5.1`, `z-ai/glm-5.2`, `z-ai/glm-5.3`, `z-ai/glm-5.3-flash`, `z-ai/glm-5-turbo`, `tencent/hy3-preview`, `google/gemma-4-31b-it`, `google/gemma-4-26b-a4b-it`, `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`, `nvidia/nemotron-3-ultra-550b-a55b` | yes | yes |\n| `orcarouter` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`, `orcarouter/auto` | yes | yes |\n| `xiaomi-mimo` | `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, `mimo-v2.5`; speech/TTS IDs are selected through `codewhale speech` / `tts` | yes | yes for chat models; no for speech/TTS models |\n| `novita` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | yes | yes |\n| `fireworks` | `accounts/fireworks/models/deepseek-v4-pro` | yes | yes |\n| `siliconflow` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes |\n| `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` |\n| `moonshot` | `kimi-k2.7-code`, `kimi-k2.6` | yes | yes |\n| `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 |\n| `stepfun` | `step-3.7-flash` (default), `step-5-preview`, `step-3.5-flash`, `step-3.5-flash-2603` | yes | yes |\n| `minimax` | `MiniMax-M3`, `MiniMax-M2.7`, `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `MiniMax-M2.1`, `MiniMax-M2.1-highspeed`, `MiniMax-M2` | yes | yes |\n| `minimax-anthropic` | `MiniMax-M3`, `MiniMax-M2.7` | yes | yes |\n| `sglang` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes |\n| `vllm` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes |\n| `ollama` | live local tag; custom tags pass through when provider hint is `ollama` | yes | no |\n| `ollama-cloud` | `gpt-oss:120b`; arbitrary provider-owned model IDs pass through | yes | yes |\n| `huggingface` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | no |\n| `modelscope` | `Qwen/Qwen3.5-397B-A17B`, `Qwen/Qwen3.5-122B-A10B`, `Qwen/Qwen3.5-27B`, `Qwen/Qwen3.5-35B-A3B`, `Qwen/Qwen3.8-27B`, `Qwen/Qwen3.8-Flash-Next`, `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Pro-0813`, `deepseek-ai/DeepSeek-V4.1-Flash`, `ZhipuAI/GLM-4.7-Flash`, `ZhipuAI/GLM-5.2` | yes | no |\n| `deepinfra` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes |\n| `together` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash`, `thinkingmachines/inkling` | yes | yes |\n| `openai-codex` | `gpt-5.5` | yes | yes |\n| `anthropic` | `claude-opus-5`, `claude-opus-4-8`, `claude-sonnet-5`, `claude-sonnet-4-6`, `claude-fable-5`, `claude-haiku-4-5` | yes | yes except `claude-haiku-4-5` |\n| `openmodel` | `deepseek-v4-flash`; provider-scoped custom model IDs pass through | yes | model-dependent |\n| `sakana` | `fugu`, `fugu-ultra-20260615` | yes | yes for `fugu-ultra-20260615` |\n| `longcat` | `LongCat-2.0` | yes | yes |\n| `opencode-go` | `deepseek-v4-pro`, `grok-4.5`, `glm-5.2`, `glm-5.1`, `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.6`, `deepseek-v4-flash`, `mimo-v2.5`, `mimo-v2.5-pro` | yes | yes |\n| `meta` | `muse-spark-1.2` | yes | yes |\n| `xai` | `grok-4.6`, `grok-4.7`, `grok-4.5`, `grok-4.3`, `grok-build`, `grok-composer-2.5-fast`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning` | yes | yes for `grok-4.6`, `grok-4.7`, `grok-4.5`, `grok-4.3`, `grok-build`, and `grok-4.20-0309-reasoning` |\n| `google` | `gemini-3.1-pro-preview`, `gemini-3-pro-preview`, `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-2.5-pro`, `gemini-2.5-flash` | yes | yes except `gemini-3.5-flash-lite` |\n| `mistral` | `mistral-code-latest`, `mistral-medium-latest`, `mistral-small-latest`, `mistral-large-latest` | yes | yes for Medium and Small (`reasoning_effort` `none` or `high` on exact first-party routes); deprecated native Magistral remains an always-on explicit compatibility ID; no for Code and Large |\n| `modelstudio-token-plan`, `modelstudio-coding-plan` | `qwen3.8-max`, `qwen3.8-max-preview`, `qwen3.7-plus`, `qwen3.7-max`, `qwen3.6-flash`, `deepseek-v4-pro`, `deepseek-v4-flash-0731`, `glm-5.2` | yes | yes |\n| `csdn` | `glm_for_coding`; other marketplace model IDs pass through | yes | yes |\n\nAtlasCloud keeps the same default model as the config layer and adds\nprovider-scoped aliases for the Pro and Flash rows. Other AtlasCloud model IDs\nshould still be selected through `ATLASCLOUD_MODEL`, config, or live model\nlisting when available.\n\n## Capability Metadata\n\n`codewhale doctor --json` exposes the `capability` object. It is static\nmetadata, not a live API probe. Current fields are:\n\n`resolved_provider`, `resolved_model`, `context_window`, `max_output`,\n`thinking_supported`, `cache_telemetry_supported`, and `request_payload_mode`.\n\nWhen configuration cannot be loaded or validated, `doctor --json` exits\nnonzero and prints a bounded, secret-redacted JSON error envelope with\n`status = \"error\"` and `error.kind = \"config_validation\"` instead of emitting\nmisleading route or capability metadata.\n\nMost shipped providers use the Chat Completions request payload mode. Native\nMessages routes, including `minimax-anthropic`, use `/v1/messages`, and\n`openai-codex` uses Responses.\n\nFor OpenAI-compatible gateways or self-hosted runtimes whose real window\ndiffers from the static table, set `[providers.] context_window = N`.\nThe configured value becomes the route-effective context window for prompts,\ncontext-pressure checks, compaction, and output-cap budgeting.\n\n`max_output` is optional and truthful: it is `null` (and omitted from the\ncapability struct on the wire) when the route publishes no output maximum we\ncan stand behind — the Kimi Code membership `kimi-for-coding` family is the\ncanonical example, since the membership catalog owns their limits. An unknown\noutput ceiling is never backfilled with a placeholder, and it applies **no**\ncompatibility clamp to a turn's requested `max_tokens`; only a concrete\nroute/offering maximum narrows the request. A model the catalogue simply has no\nrow for is a different fact — absence is not permission, so an uncatalogued id\nkeeps a conservative ceiling. The \"Max output metadata\" column below reads\n`unknown` wherever no documented maximum exists.\n\nThe exact Kimi Code membership roster contains `k3`, `k3-256k`,\n`kimi-for-coding`, and `kimi-for-coding-highspeed`. The two K3 ids share the\nsame reasoning and fixed-sampling contract; `k3-256k` stays at 262,144 tokens,\nwhile bare `k3` can use an entitled 1M override.\n\n| Provider/model class | Context window | Max output metadata | Thinking support | Cache telemetry | FIM endpoint |\n| --- | --- | --- | --- | --- | --- |\n| DeepSeek V4 (`deepseek-v4-pro`, `deepseek-v4-flash`) | 1,000,000 | 384,000 | yes | yes | DeepSeek beta only |\n| DeepSeek V4 Flash Vision experimental (`deepseek-v4-flash-vision-exp`) | 1,000,000 inherited from Flash | 384,000 inherited from Flash | yes, inherited | yes, inherited | not claimed; Chat Completions route only |\n| DeepSeek compatibility aliases (`deepseek-chat`, `deepseek-reasoner`) | 1,000,000 | 384,000 | yes | yes | DeepSeek beta only |\n| NVIDIA NIM V4 registry models | 1,000,000 | 384,000 | yes | yes | not documented in code |\n| Volcengine Ark V4 model IDs | 1,000,000 | 384,000 | yes | yes | not documented in code |\n| OpenRouter, Novita, Fireworks, SiliconFlow, SGLang, and vLLM V4 model IDs | 1,000,000 | 384,000 | yes | no | not documented in code |\n| Xiaomi MiMo `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, `mimo-v2.5` | 1,000,000 | 131,072 | yes | no | not documented in code |\n| OpenRouter Qwen 3.6 Flash / Plus | 1,000,000 | 65,536 | yes | no | not documented in code |\n| OpenRouter Qwen 3.6 35B / 27B | 262,144 | 262,140 | yes | no | not documented in code |\n| OpenRouter Qwen 3.6 Max Preview | 262,144 | 65,536 | yes | no | not documented in code |\n| OpenAI API `gpt-5.5` | 1,050,000 | 128,000 | yes | no | not documented in code |\n| OpenAI API `gpt-5.6`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` | 1,050,000 | 128,000 | yes | no | not documented in code |\n| Anthropic API `claude-opus-5`, `claude-opus-4-8`, `claude-sonnet-5`, `claude-sonnet-4-6`, `claude-fable-5` | 1,000,000 | 128,000 | yes | yes | not documented in code |\n| Google Gemini API `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-3.1-pro-preview`, `gemini-2.5-pro`, `gemini-2.5-flash` | 1,048,576 | 65,536 | model-dependent | no | not documented in code |\n| Meta Model API `muse-spark-1.2` | 1,000,000 | 32,000 | yes | no | not documented in code |\n| ChatGPT plan route (`openai-codex`) | conservatively budgeted; account listing does not state a window | no output cap sent in preview | model dependent | no | public `/v1/responses`; no context limit is inferred from account eligibility |\n| OpenModel default/custom model IDs | 200,000 fallback unless model metadata or config overrides it | 64,000 fallback | model-dependent | no | route uses Messages payload at `/v1/messages` |\n| Wanjie Ark `reasoner` / `r1` model IDs | 128,000 | unknown (no documented maximum) | yes | no | not documented in code |\n| Direct Arcee API `trinity-large-thinking` | 262,144 | 262,144 | yes | no | not documented in code |\n| Direct Arcee API `trinity-large-preview` | 262,144 | unknown (no documented maximum) | no in doctor capability metadata | no | not documented in code |\n| Direct Moonshot `kimi-k3` | 1,048,576 | 1,048,576 documented maximum; 131,072 provider default | yes | no | exact route uses `max_completion_tokens` and omits fixed sampling fields ([K3 quickstart](https://platform.kimi.ai/docs/guide/kimi-k3-quickstart)) |\n| Kimi Code membership `k3` | 262,144 safe baseline; 1,048,576 with an explicit entitled-plan override | 131,072 conservative default ceiling; membership maximum is not published | yes | no | exact `https://api.kimi.com/coding/v1` route |\n| Kimi Code membership `k3-256k` | 262,144 fixed | 131,072 conservative default ceiling; membership maximum is not published | yes | no | exact `https://api.kimi.com/coding/v1` route |\n| Direct Moonshot/Kimi K2.7/K2.6 (`kimi-k2.7-code`, `kimi-k2.7-code-highspeed`, `kimi-k2.6`) | 262,144 | 32,768 | yes | no | provider-reported bundled catalog |\n| Kimi Code membership `kimi-for-coding`, `kimi-for-coding-highspeed` | 262,144 | unknown — the membership catalog owns these limits and no client-side ceiling is claimed | yes | no | exact `https://api.kimi.com/coding/v1` route |\n| Direct Z.AI `GLM-5.3` (default) | 1,000,000 | 131,072 | yes | no | live on the GLM Coding Plan; limits inherited from `GLM-5.2` until Z.ai publishes distinct 5.3 numbers; no USD price |\n| Direct Z.AI `GLM-5.3-Flash` | 1,000,000 | 131,072 | yes | no | natively multimodal; $0.15/$0.50 list (2026-08-26); faster/explore sibling of `GLM-5.3` |\n| Direct Z.AI `GLM-5.2` | 1,000,000 | 131,072 | yes | no | not documented in code |\n| Direct Z.AI `GLM-5.1` | 202,752 | 131,072 | yes | no | not documented in code |\n| Direct Z.AI `GLM-5-Turbo` | 202,752 | 131,072 | yes | no | faster/explore sub-agent sibling |\n| Direct MiniMax `MiniMax-M3` | 1,000,000 | 524,288 | yes | no | not documented in code |\n| Direct MiniMax M2.x models | 204,800 | unknown until MiniMax output metadata is promoted | yes | no | not documented in code |\n| MiniMax Messages route (`MiniMax-M3`, `MiniMax-M2.7`) | model-specific values above | model-specific values above | yes | no | route uses `/anthropic/v1/messages` |\n| Generic `openai` and AtlasCloud | 128,000 | unknown (no documented maximum) | no in doctor capability metadata | no | not documented in code |\n| Ollama | 8,192 | unknown (no documented maximum) | no | no | not documented in code |\n| Hugging Face Inference Providers V4 model IDs | 131,072 | unknown (no documented maximum) | yes | no | not documented in code |\n| Other recognized DeepSeek model IDs | 128,000 unless the model name carries an explicit `Nk` hint | unknown (no documented maximum) | no unless V4/reasoner logic matches | DeepSeek/NIM only | DeepSeek beta only |\n\nMiniMax M3 uses input-length and service tiers. Codewhale omits\n`service_tier`, so requests use the standard tier and cost estimates select the\ncorrect standard rate from total input usage. Priority rates are listed to keep\nthe official tier structure visible. Prices are USD per million tokens.\n\n| Model / service tier | Input length | Input | Output | Cache read | Cache write |\n| --- | --- | ---: | ---: | ---: | ---: |\n| `MiniMax-M3` standard | up to 512,000 input tokens | $0.30 | $1.20 | $0.06 | not published |\n| `MiniMax-M3` standard | over 512,000 input tokens | $0.60 | $2.40 | $0.12 | not published |\n| `MiniMax-M3` priority | up to 512,000 input tokens | $0.45 | $1.80 | $0.09 | not published |\n| `MiniMax-M3` priority | over 512,000 input tokens | $0.90 | $3.60 | $0.18 | not published |\n| `MiniMax-M2.7` standard | all supported inputs | $0.30 | $1.20 | $0.06 | $0.375 |\n\nThese values come from the [MiniMax pay-as-you-go pricing\nguide](https://platform.minimax.io/docs/guides/pricing-paygo). M3 thinking is\nadaptive or disabled; the OpenAI-compatible API defaults to adaptive and the\nAnthropic-compatible API defaults to disabled. M2.7 thinking cannot be\ndisabled. Codewhale sends explicit controls when the user selects a reasoning\nmode.\n\nTool-call support is tracked separately by the static `ModelRegistry` and by\nthe endpoint's ability to accept OpenAI-compatible `tools` payloads. A custom\nOpenAI-compatible or local endpoint can still reject tool calls even if\nCodewhale can send the schema.\n\n### Hugging Face Inference Providers Notes\n\nThe shipped Hugging Face route targets the OpenAI-compatible Inference Providers\nrouter at `https://router.huggingface.co/v1`. Configure auth with\n`HUGGINGFACE_API_KEY` first, or `HF_TOKEN` as a fallback. Configure the endpoint\nwith `HUGGINGFACE_BASE_URL` first, or `HF_BASE_URL` as a fallback; configure the\nmodel with `HUGGINGFACE_MODEL` first, or `HF_MODEL` as a fallback.\n\nThis route does not imply Hub browsing, model-card metadata, dataset access,\nJobs, uploads, or export. Those remain explicit Model Lab work items so\nprovider auth and artifact movement stay separate.\n\n### When a Local Model Prints Tool JSON\n\nCodewhale only executes tools when the provider returns Chat Completions\n`tool_calls` or streamed `delta.tool_calls`. If a local model prints text such\nas `{\"name\":\"File\",\"arguments\":{\"action\":\"search_content\",...}}` in the\nassistant message, that is ordinary model output, not an executable tool\nrequest.\n\nFor OpenAI-compatible or local runtimes, check:\n\n- The endpoint accepts the `tools` array in `/v1/chat/completions` requests.\n- The selected model or chat template is configured for function/tool calls.\n- The server returns `tool_calls` in the response rather than plain JSON text.\n- The compatibility layer does not strip tools before forwarding the request.\n- If in doubt, test a small `File` `read` or `search_content` action against a\n known tool-calling model before debugging Codewhale's tool registry.\n\nChanging `provider`, `base_url`, or `model` can select a route that supports the\nOpenAI-compatible payload shape, but Codewhale cannot convert arbitrary JSON\ntext into a trusted tool call after the model has emitted it as prose.\n\nDeepSeek will retire `deepseek-chat` and `deepseek-reasoner` on 2026-07-24 at\n15:59 UTC. Codewhale migrates either name to `deepseek-v4-flash` before a\nrequest reaches DeepSeek's first-party OpenAI or Anthropic endpoint. If no\nreasoning tier was configured, `deepseek-chat` also migrates to `off` and\n`deepseek-reasoner` to `high`, preserving their former non-thinking / thinking\nintent; an explicit `reasoning_effort` remains authoritative. The mapping is\ndeliberately not global: Wanjie Ark, aggregators, self-hosted runtimes, and\ncustom endpoints continue to own their model ids.\n\n## Reasoning Effort\n\n`/reasoning ` (and the `reasoning_effort` config key) is translated to\neach provider's wire dialect by the client before the request is sent. `off`\ndisables thinking where the route supports it. Both exact K3 routes map `off`\nto their lowest supported tier, `low`, and the model is never switched to\nsatisfy `off` — but they do so for different reasons:\n\n- **Kimi Code membership K3** (exact `https://api.kimi.com/coding/v1` with\n `model = \"k3\"` or `model = \"k3-256k\"`) — the membership roster declares K3 always-thinking, so `off`\n cannot be honored without changing what the model is. The clamp preserves the\n fixed K3 identity.\n- **Direct Moonshot K3** (exact `https://api.moonshot.ai/v1` with\n `model = \"kimi-k3\"`) — this clamp is *defensive*, not a documented contract.\n The direct platform publishes no `off` state for K3, and Codewhale will not\n assert a fixed-thinking guarantee it cannot verify for a given key's\n entitlement, so the requested `off` is normalized to the lowest tier with the\n live entitlement left unknown.\n\nNormal dispatched\n`auto` uses Codewhale's auto-reasoning selector and sends a concrete tier;\nonly an omitted reasoning setting leaves the provider default in control.\nProviders marked \"omitted\" receive no reasoning fields at all for that tier.\n\n| Provider | `off` | `low`/`medium`/`high` | `max`/`xhigh` |\n| --- | --- | --- | --- |\n| `deepseek`, `deepseek-cn`, `siliconflow`, `siliconflow-CN`, `sglang`, `volcengine`, `atlascloud` | `thinking: {type: disabled}` | `reasoning_effort: \"high\"` + `thinking: {type: enabled}` | `reasoning_effort: \"max\"` + `thinking: {type: enabled}` |\n| `openrouter`, `novita`, other `together` models | `thinking: {type: disabled}` | `reasoning_effort` pass-through + `thinking: {type: enabled}` | `reasoning_effort: \"xhigh\"` + `thinking: {type: enabled}` |\n| `together` + `thinkingmachines/inkling` | `reasoning_effort: \"none\"` | exact `minimal`/`low`/`medium`/`high` `reasoning_effort` | `reasoning_effort: \"max\"` |\n| Direct Moonshot `kimi-k3` at exact `https://api.moonshot.ai/v1` | top-level `reasoning_effort: \"low\"` (effective normalization) | top-level `reasoning_effort: \"low\"` / `\"high\"` (`medium` becomes `high`) | top-level `reasoning_effort: \"max\"` |\n| Kimi Code membership `k3`, `k3-256k` at exact `https://api.kimi.com/coding/v1` | `thinking: {type: enabled, effort: \"low\"}` (effective normalization) | `thinking: {type: enabled, effort: \"low\" | \"high\"}` | `thinking: {type: enabled, effort: \"max\"}` |\n| Other `moonshot` routes | `thinking: {type: disabled}` | `thinking: {type: enabled}` | `thinking: {type: enabled}` |\n| `ollama` | `think: false` | `think: true` | `think: true` |\n| `ollama-cloud` | `reasoning_effort: \"none\"` | exact `low`/`medium`/`high` `reasoning_effort` | `reasoning_effort: \"max\"` |\n| `xiaomi-mimo` | `thinking: {type: disabled}` | `thinking: {type: enabled}` | `thinking: {type: enabled}` |\n| First-party `minimax` `MiniMax-M3` | `reasoning_split: true` + `thinking: {type: disabled}` | `reasoning_split: true` + `thinking: {type: adaptive}`; effective tier granularity unavailable | `reasoning_split: true` + `thinking: {type: adaptive}`; effective tier granularity unavailable |\n| First-party Z.ai `GLM-5.2` | `thinking: {type: disabled}`; no `reasoning_effort` | enabled thinking; only effective `high` adds `reasoning_effort: \"high\"` | enabled thinking + `reasoning_effort: \"max\"` |\n| First-party Z.ai `GLM-5.3` | `thinking: {type: disabled}`; no `reasoning_effort` | enabled thinking; only effective `high` adds `reasoning_effort: \"high\"` | enabled thinking + `reasoning_effort: \"max\"` |\n| First-party Z.ai `GLM-5.3-Flash` | `thinking: {type: disabled}`; no `reasoning_effort` | enabled thinking; only effective `high` adds `reasoning_effort: \"high\"` | enabled thinking + `reasoning_effort: \"max\"` |\n| First-party Z.ai `GLM-5-Turbo` | `thinking: {type: disabled}` | enabled thinking; effort granularity unavailable | enabled thinking; effort granularity unavailable |\n| Compatible gateways configured as `zai` | omitted; effective unavailable | omitted; effective unavailable | omitted; effective unavailable |\n| `nvidia-nim` | `chat_template_kwargs.thinking: false` | `chat_template_kwargs`: `thinking: true` + `reasoning_effort: \"high\"` | `chat_template_kwargs`: `thinking: true` + `reasoning_effort: \"max\"` |\n| `vllm` | `chat_template_kwargs.enable_thinking: false` | `chat_template_kwargs.enable_thinking: true` + `reasoning_effort` low/medium/high | `chat_template_kwargs.enable_thinking: true` + `reasoning_effort: \"high\"` (vLLM has no max tier) |\n| `arcee`, `huggingface` | omitted | `reasoning_effort` pass-through | `reasoning_effort: \"high\"` |\n| `fireworks` | omitted | `reasoning_effort: \"high\"` | `reasoning_effort: \"max\"` |\n| `openai`, `wanjie-ark`, `telecomjs` | omitted | omitted | omitted |\n| `openmodel` | Anthropic Messages adapter handles thinking/output configuration | Anthropic Messages adapter handles thinking/output configuration | Anthropic Messages adapter handles thinking/output configuration |\n| `openai-codex` | Responses API `reasoning` field (handled by the Responses bridge) | Responses API `reasoning` field | Responses API `reasoning` field |\n\nAtlasCloud serves DeepSeek models, so it speaks the DeepSeek reasoning dialect,\nincluding the `max` tier (#3024).\n\nOn the exact MiniMax OpenAI-compatible Chat endpoints, `MiniMax-M3` uses\n`max_completion_tokens`. Other MiniMax models and compatible gateways retain\n`max_tokens`; the MiniMax Anthropic endpoints use the separate Messages\nadapter.\n\n## Drift Check\n\nRun this before changing provider IDs, provider TOML tables, static model\nregistry rows, or provider default strings:\n\n```bash\npython3 scripts/check-provider-registry.py\n```\n\nThe check fails when:\n\n- `docs/PROVIDERS.md` omits a canonical `ProviderKind::as_str()` ID.\n- Descriptor presentation IDs or released wire tags are duplicated or drift\n from intrinsic kinds, or a second provider enum/ordinal bridge is introduced.\n- The shipped-provider table omits or adds a `[providers.*]` TOML table.\n- The static model registry table drifts from providers used by\n `crates/agent/src/lib.rs`.\n- A provider default model or base URL constant in `crates/tui/src/config.rs`\n is no longer mentioned here.\n\n## Provider Gotchas\n\nThree failures that look like something else (measured by a provider operator\non live traffic):\n\n- **A cold model looks broken.** The first call to a model the provider has not\n loaded can take 90 to 150 seconds, then drop to a second or less once warm.\n Raise `[stream].open_timeout_secs` (default 45 seconds, at most 300) before reading a timeout as \"model unavailable\".\n- **Listed is not callable.** A catalog entry can be visible and still fail\n every call. The same error text on every attempt (for example a \"resources\n busy\" 502) is not transient: a genuinely busy upstream returns successes in\n between. Probe a model before trusting it, and stop retrying one that fails\n identically.\n- **A truncated answer can report `stop`.** A reasoning model can spend the\n whole `max_tokens` allowance on reasoning. When the provider reports\n `completion_tokens` exactly equal to the `max_tokens` the request asked for\n and the finish reason is `stop`, Codewhale treats the turn as cut at the\n ceiling rather than finished (`stop_contradicts_output_ceiling` in\n `codewhale-models`); retrying the same prompt stops at the same count, so\n raise the ceiling or shorten the task instead. No inference is made when the\n provider reports no usage.\n\n## Planned, Not Shipped Yet\n\nThese items belong to the v0.8.48+ provider-abstraction milestone or related\nprovider docs work, but they are not native shipped behavior in this checkout:\n\n- A unified `Provider` trait in `codewhale-agent` that owns env precedence,\n secret resolution, base URL normalization, auth-header construction, and\n provider metadata. Those responsibilities are still split across\n `crates/config`, `crates/secrets`, and `crates/tui/src/client.rs`.\n- Hugging Face model passport metadata in the picker, including license, base\n model, context length, chat template, tool-call support, reasoning support,\n and gated/private status.\n\n## Sign in with Claude\n\nRun `codewhale auth claude` (or `/auth claude` in a running session), open the\nprinted Claude sign-in URL, and paste the complete `code#state` callback.\n`/provider setup anthropic` also offers API-key and Claude Pro / Max choices.\nThen select `anthropic` or its `claude` alias. API keys continue to use separate\nAnthropic API billing; selecting subscription sign-in never falls back to one.\n\nCodewhale stores the grant in its private credentials directory, commits the\nprovider's credential pointer atomically, and refreshes it before requests on a\nworker. Subscription tokens go only to `https://api.anthropic.com`; a custom\nendpoint is rejected. Each request keeps the existing Messages adapter, tool\nprotocol, cache prefix, and Codewhale client identity. No Claude Code credential\nfile or keychain entry is read or modified.\n\n`codewhale auth claude-revoke` (or `/auth claude-revoke`) removes the owned local\ngrant. End remote access separately in your Claude account settings. The\nsubscription route then needs another login; select API-key billing explicitly\nto use an API key. Login and inference follow Anthropic's provider-side\navailability; local checks do not\nestablish that an account is eligible or that live subscription inference works.\n\nThe callback and JSON token exchange follow the interoperable flow used by\n[pi's Anthropic OAuth adapter](https://github.com/fivewillow/badlogic-pi-mono/blob/main/packages/ai/src/utils/oauth/anthropic.ts).\n" + "sha256": "c9dfba513b12ac26aaa87d84f74a3ae8e2ccbcf51fd49fa99feff0b168aaaca9", + "content": "# Provider Registry\n\n> 阅读简体中文版:[zh_hans/PROVIDERS.md](zh_hans/PROVIDERS.md)\n\nSpeech recognition, synthesis, translation and video have separate execution\npaths. See [media models](MEDIA_MODELS.md) for current support and reviewed\nupstream candidates; a chat catalogue row alone does not add a media backend.\n\nThis registry describes provider behavior that is wired into the current\nCodewhale codebase. It is intentionally conservative: shipped entries are\nlimited to provider IDs, config keys, auth paths, base URLs, model resolution,\nand capability metadata that the code already knows about.\n\nDeepSeek remains the default provider, but every entry in `ProviderKind::ALL`\nis a first-class selectable provider route. `ALL` is the catalog/picker\nsurface — one identity per vendor. Dual-wire dialect kinds (`*Anthropic`, e.g.\n`deepseek-anthropic`) and the Model Studio plan variants stay on the enum for\nserde and `provider_for_kind` but are deliberately **not** catalog rows:\na plan is `mode`/`base_url` and a dialect is `wire = openai|anthropic` on the\nprimary provider config (`crates/config/src/provider_kind.rs:221-226`). Hosted\nroutes, generic OpenAI-compatible endpoints, the OpenAI Codex/ChatGPT route,\nnative Anthropic, and local runtimes all run the same terminal harness against\nthe selected provider/model/base URL.\n\nA host reached over plain Chat Completions is an ordinary named provider,\nnot a `ProviderKind`: enum variants are reserved for distinct *wires*\n(Anthropic Messages, Codex Responses, Google thought signatures). Any such\nhost is a `[providers.]` table with a base URL, a model, and a key env\n(`docs/CONFIGURATION.md`); `/provider` and `/setup` keep a \"paste a Base URL\nand a key\" path for exactly this. Offerings come from live `GET /v1/models`\nplus the Codewhale catalog rather than a compiled roster (#5350, #6289).\n\nKnown-good hosts. These ship as bundled descriptor rows in\n`crates/config/assets/provider_descriptors.json` (compiled in by\n`crates/config/src/descriptors.rs`): each row says how to reach the host — wire,\nbase URL, key env, aliases — while model ids stay live from `GET /v1/models`\nand the Codewhale catalog; the example model is only a bootstrap hint. Verify\nagainst the vendor's own docs before trusting any value here:\n\n| Host | Base URL | Example models | API key env |\n| --- | --- | --- | --- |\n| SenseNova | `https://token.sensenova.cn/v1` | `deepseek-v4-flash` | `SENSENOVA_API_KEY` |\n| Baseten | `https://inference.baseten.co/v1` | `deepseek-ai/DeepSeek-V4-Pro` | `BASETEN_API_KEY` |\n| Groq | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | `GROQ_API_KEY` |\n| Cerebras | `https://api.cerebras.ai/v1` | `llama-3.3-70b` | `CEREBRAS_API_KEY` |\n| Command Code | `https://api.commandcode.ai/provider/v1` | `deepseek/deepseek-v4-flash` | `COMMAND_CODE_API_KEY` |\n| Alibaba Model Studio (DashScope) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `qwen3.8-flash` | `DASHSCOPE_API_KEY` |\n| AICraft | `https://aicraftapi.com/v1` | `claude-4.6-sonnet`; DeepSeek / Claude / Gemini / Qwen / GLM / MiniMax / Doubao families | `AICRAFT_API_KEY` |\n| Tsubasa | `https://api.tsubasa.sh/v1` | `tsubasa-pro`, `tsubasa-fast` (32,768-token context) | `TSUBASA_API_KEY` |\n| Cheaper Inference | `https://api.cheaperinference.com/v1` | `gpt-5.4-mini`, `claude-sonnet-5`, `gemini-3.1-pro` | `CHEAPER_INFERENCE_API_KEY` |\n| Yolo-Auto | `https://yolo-auto.com/v1` | `qwen3.8-flash`; `yolo` / `yolo-small` | `YOLO_AUTO_API_KEY` |\n\nAICraft's roster spans DeepSeek, Anthropic Claude, Google Gemini, Qwen, GLM,\nMiniMax and Doubao ids on its OpenAI-compatible endpoint. The authority is\n`GET https://aicraftapi.com/v1/models` with your key — pick a model from that\nlist, not from this table.\nTsubasa implements only `GET /v1/models` and Chat Completions. Its two public\nmodel ids share a 32,768-token context, smaller than the 128,000 tokens Codewhale assumes\nfor an unknown model, so set it on the route after saving:\n`codewhale config set providers.tsubasa.context_window 32768`.\n\nCheaper Inference is an OpenAI-compatible gateway with one key for models from\nseveral labs. Model ids are bare, such as `gpt-5.4-mini` or `claude-sonnet-5`.\nPricing varies by model and route; consult the provider’s current catalog.\nThe authority is `GET https://api.cheaperinference.com/v1/models` with your key.\nDocs: .\n\nOpenCode Zen and OpenCode Go are first-class provider routes, configured like\nany other provider below; they are not part of this table. In `/provider`,\ntype to filter the list (letters not bound to a row action); `Ctrl+T` probes the\nselected row's `/models` and records reachability only (a 2xx is not\nmodel-ready).\n\nSources to keep in sync:\n\n- `crates/config/assets/provider_descriptors.json` - built-in and compatible-host\n labels, defaults, aliases, key-env lists, config/secret slots, and credential\n guidance. `crates/config/build.rs` generates immutable typed views and the\n existing constant projections from this one data owner.\n- `crates/config/src/provider_kind.rs` and `src/lib.rs` - legacy identity serde,\n config schema, environment precedence and Rust route/auth behavior.\n- `crates/tui/src/config.rs` - captures and verifies exact configured provider\n identities and keeps provider-specific credential and route policy in Rust.\n- `crates/config/assets/catalog_corrections.json` `reviewed` - intrinsic facts,\n scoped selector aliases, completion references and pure transport metadata.\n The existing seed renderer embeds this reviewed supplement in Models.dev.\n- `crates/tui/src/provider_lake.rs` and `route_runtime.rs` - resolve current\n model facts for both CLI model commands, the picker and Runtime API. Public\n catalog facts, provider/account rosters and explicit configuration retain\n their separate ownership. `crates/agent/src/lib.rs` keeps the legacy selector\n compatibility projection; it does not decide the CLI's current route.\n- `config.example.toml` and `docs/CONFIGURATION.md` - user-facing config\n examples and environment variable reference.\n- `scripts/check-provider-registry.py` - drift check for canonical provider\n IDs, live TUI provider IDs, TOML table names, static registry rows, and\n documented defaults.\n\n## Captured Provider Identity\n\nPresentation names, labels, aliases, and historical wire tags come from\n`provider_descriptors.json`. `ProviderKind` remains the Rust-owned intrinsic\ncredential, protocol, and region distinction. A configured route captures both\nits kind and exact table key; a custom table named `openai` stays custom and\ndoes not acquire OpenAI credentials or protocol rules from its name. Case is\nsignificant for custom keys.\n\nDeepSeek China has three preserved representations: `deepseek_c_n` in released\nTUI serde wrappers, `deepseek-cn` as its captured route ID, and `deepseek_cn` as\nits config leaf. Its intrinsic kind is DeepSeek, while its table and endpoint\nstay distinct.\n\nAn old custom route without an additive provider ID can resume only with a\nverified root `base_url` migration into the active `providers.custom` table.\nThat private receipt is bound to the parsed table generation. A table-only,\nprofile-only, conflicting, or later replaced table cannot establish the\nmissing identity; an explicit empty ID is refused. Writes reuse the existing\nlocked config mutation and undo comparison, obtain the fresh migration receipt\nunder that lock, and verify the captured table before changing a leaf.\n\nIn-flight requests retain their captured identity, endpoint, and credential\ngeneration. Health lookup cannot treat an opaque credential reference as ready\nmerely because an earlier request used the same authentication class.\n\n## Provider Selection\n\nWith no saved model, no `default_text_model`, and no `CODEWHALE_MODEL` or\nprovider-specific model variable, a fresh install runs `deepseek-flash` on the\nDeepSeek provider. Precedence is the active provider's configured default, then\nits catalog default — so an explicit `default_text_model` is not silently\noverridden by whichever model the shipped catalog lists first.\n\nRefresh model catalogs without installing a new Codewhale release:\n\n```sh\ncodewhale models --update\ncodewhale models --update --provider openai\ncodewhale models --provider openai --json\n```\n\n`models --update` (also `--refresh`) updates the shared Models.dev metadata\nand calls the existing `/models` endpoint for each configured provider with\nits own credentials. `--provider ID` restricts the refresh to that exact\nprovider, including named custom endpoints. It makes no inference requests\nand never changes the saved provider or model. A command-line API key is\nconfined to the active provider; other routes are reported as skipped for\nthat invocation.\n\nPlain `models` lists the active provider's saved catalog without provider requests.\nFor ChatGPT, it validates the selected registration locally before reading\nthat account's saved roster. Successful refreshes are saved under Codewhale's\ncatalog directory and used by the model/provider pickers. Cache files are\nscoped to provider identity and endpoint; a failed refresh preserves prior\nrows. Text output reports source, last successful fetch time (Unix seconds),\nand freshness. `--update --json` adds per-source receipts and aggregate counts;\npartial failures return a nonzero exit code after writing those receipts.\nOrdinary `models --json` keeps its model-array format. Bundled/configured\nfallbacks are not proof that an account can use every listed model.\n\n### Sign in with ChatGPT\n\nThe `openai-codex` provider ID now uses OpenAI's official open-source\n[Sign in with ChatGPT flow](https://developers.openai.com/siwc/token-sharing-open-source).\nSign in and refresh the selected account's catalog:\n\n```sh\ncodewhale auth chatgpt\ncodewhale models --update --provider openai-codex\ncodewhale --provider openai-codex\n```\n\nSign-in opens your system browser, uses a loopback callback and PKCE, validates\nthe returned identity, and stores the issued registration and renewable tokens\nin Codewhale's protected credential storage. Permission to use your ChatGPT\nplan is separate from identity sign-in. A declined or missing plan grant stops\ninference; choose another provider explicitly if you want another billing path.\n`codewhale auth chatgpt-revoke` signs out of the Codewhale-owned session.\nThis experimental implementation retains one active registration. A picker\nfor multiple saved ChatGPT accounts is not implemented yet.\n\nModel discovery uses your granted bearer token at\n`GET https://api.openai.com/v1/models`; inference uses the public\n`POST https://api.openai.com/v1/responses` endpoint. The model picker keeps\nOpenAI's display labels and order and lists only entries with `visibility: list`.\nThe catalog contains no credentials and is bound to the verified issuer,\nissued client ID, and account subject. Another account or workspace never\ninherits those model choices. Missing, invalid, or older-than-one-day rosters\nare reported explicitly and provide no account entitlement evidence. Run the\nrefresh command after signing in. Neither model discovery nor ordinary listing\nstarts an inference request. Imported Codex CLI tokens and legacy process-token\nvariables cannot authorize this official plan route.\n\nEligible requests consume your ChatGPT plan or credits. ChatGPT Plus's\nfive-hour allowance is shared across apps; each app receives no separate\nallowance. The documented five-hour limit does not apply to Pro. App-specific\nlimits can also apply. Review limits and access in\n[ChatGPT usage settings](https://chatgpt.com/settings/usage). Codewhale does\nnot silently switch to an API key or another provider when a limit is reached.\n\nThis is an OpenAI preview for open-source/local apps. It does not grant access\nto ChatGPT conversation history. Requests stream with `store: false` and\n`stream: true`, while Codewhale retains its own session history and tools.\nOpenAI-hosted image generation, file search, Code Interpreter, native computer\nuse, hosted MCP/connectors, and Responses `tool_search` are unavailable on this\nroute; Codewhale's own tools use supported function/custom tool calls.\nThe ChatGPT preview requests one function call at a time.\nSee [preview limitations](https://developers.openai.com/siwc/token-sharing-open-source/preview-limitations).\nPaid or remotely hosted applications require the\n[commercial partner interest process](https://openai.com/form/sign-in-with-chatgpt-interest/);\nthis local integration is not commercial approval.\n\nThe canonical provider IDs are the entries of `ProviderKind::ALL`\n(`crates/config/src/provider_kind.rs`), in that order:\n\n`deepseek`, `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`,\n`openrouter`, `orcarouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `arcee`,\n`siliconflow-CN`, `moonshot`, `sglang`, `vllm`, `ollama`, `ollama-cloud`, `huggingface`,\n`together`, `qianfan`, `openai-codex`, `anthropic`, `openmodel`, `zai`,\n`stepfun`, `minimax`, `deepinfra`, `sakana`, `longcat`, `opencode-go`,\n`opencode-zen`, `meta`, `xai`, `mistral`, `telecomjs`, `modelstudio-token-plan`, `modelscope`,\n`google`, `edenai`, `zenmux`, `csdn`, `concentrate`, `codewhale`, and `custom`.\n\n`deepseek-anthropic` is *not* on this list — it is a wire dialect of\n`deepseek`, reached with `wire = \"anthropic\"`, not a separate route to select.\n\nUse any of these surfaces to select a provider:\n\n- CLI: `codewhale --provider `\n- TUI: `/provider ` or the provider picker\n- Env: `CODEWHALE_PROVIDER=`; `DEEPSEEK_PROVIDER=` is the legacy alias\n- Config: `provider = \"\"`\n\n`deepseek-cn`, `deepseek_china`, `deepseekcn`, and `deepseek-china` are accepted\nas legacy aliases for `deepseek`. They do not select a different official host;\nDeepSeek uses the same official API host worldwide.\n\n`deepseek_anthropic`, `deepseek-claude`, and `deepseek_claude` select\n`deepseek-anthropic`, the opt-in DeepSeek route that speaks the Anthropic\nMessages API at `https://api.deepseek.com/anthropic`. It keeps the normal\nDeepSeek API key path but uses `x-api-key` plus `anthropic-version: 2023-06-01`\ninstead of Bearer auth. If the key already lives in official DeepSeek Harness\n(`dsh`) at `$DSH_HOME/.credentials.yaml`, grant read-only access with\n`codewhale auth external-consent --provider deepseek --mode read-only`.\nCodewhale never writes that file and only reads `DEEPSEEK_API_KEY`.\n\n`huggingface`, `hugging-face`, `hugging_face`, and `hf` all select the\nHugging Face Inference Providers route. This is the OpenAI-compatible router\npath for chat/inference, not Hub browsing, model-card inspection, uploads, or\nartifact export.\n\n`telecomjs`, `telecom-js`, `telecom_js`, `telecomjs-cn`, and `tokenhub` all\nselect the TelecomJS TokenHub route. Its authenticated `/models` catalog is\nkey-scoped and remains isolated from every other provider's live snapshot.\n\nFresh shared config writes to `~/.codewhale/config.toml`. Existing\n`~/.deepseek/config.toml` files are still read for compatibility.\n\n### Legacy Antigravity tombstone\n\nAntigravity is not a Codewhale provider and cannot be selected or run. Existing\nlegacy Antigravity provider state is recognized only as a non-runnable migration\ntombstone. Run `codewhale auth clear --provider antigravity` to forget only\nCodewhale-owned legacy configuration and consent metadata. This does not sign\nout of, revoke, read, or otherwise alter any official Google or Antigravity\nsession. For Gemini, select the supported `google` provider and supply\n`GEMINI_API_KEY`.\n\n### Wire Protocol Compatibility\n\nProvider selection is explicit. A model string prefix such as\n`deepseek-ai/...`, `deepseek/...`, `qwen/...`, or `arcee-ai/...` is a\nprovider-owned wire ID or catalog namespace hint under the selected provider.\nIt is not a provider switch and must not be treated as proof that the route is\nDeepSeek, OpenRouter, or any other provider.\n\nSet the route with `provider = \"\"`, `CODEWHALE_PROVIDER=`, or\n`codewhale --provider `. Set the request model with `CODEWHALE_MODEL`, a\nprovider-specific model env var, top-level `default_text_model`, or\n`[providers.
].model`. Set the endpoint with `CODEWHALE_BASE_URL`, a\nprovider-specific base URL env var, or `[providers.
].base_url`. Set auth\nwith `codewhale auth set --provider `, `[providers.
].api_key`, or\nthe listed provider env vars.\n\n| Provider ID | TOML table | Wire protocol | Auth env vars |\n| --- | --- | --- | --- |\n| `deepseek` | `[providers.deepseek]` | Model-aware: Responses default (`deepseek-flash`); Chat Completions (`deepseek-v4-pro`) | `DEEPSEEK_API_KEY` |\n| `deepseek-anthropic` | `[providers.deepseek_anthropic]` | Anthropic Messages | `DEEPSEEK_API_KEY` |\n| `nvidia-nim` | `[providers.nvidia_nim]` | OpenAI Chat Completions | `NVIDIA_API_KEY`, `NVIDIA_NIM_API_KEY` |\n| `openai` | `[providers.openai]` | OpenAI Chat Completions | `OPENAI_API_KEY` |\n| `atlascloud` | `[providers.atlascloud]` | OpenAI Chat Completions | `ATLASCLOUD_API_KEY` |\n| `wanjie-ark` | `[providers.wanjie_ark]` | OpenAI Chat Completions | `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, `WANJIE_MAAS_API_KEY` |\n| `volcengine` | `[providers.volcengine]` | OpenAI Chat Completions | `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, `ARK_API_KEY` |\n| `openrouter` | `[providers.openrouter]` | OpenAI Chat Completions | `OPENROUTER_API_KEY` |\n| `xiaomi-mimo` | `[providers.xiaomi_mimo]` | OpenAI Chat Completions | `XIAOMI_MIMO_TOKEN_PLAN_API_KEY`, `MIMO_TOKEN_PLAN_API_KEY`, `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, `MIMO_API_KEY` |\n| `novita` | `[providers.novita]` | OpenAI Chat Completions | `NOVITA_API_KEY` |\n| `fireworks` | `[providers.fireworks]` | OpenAI Chat Completions | `FIREWORKS_API_KEY` |\n| `siliconflow` | `[providers.siliconflow]` | OpenAI Chat Completions | `SILICONFLOW_API_KEY` |\n| `arcee` | `[providers.arcee]` | OpenAI Chat Completions | `ARCEE_API_KEY` |\n| `siliconflow-CN` | `[providers.siliconflow_cn]` | OpenAI Chat Completions | `SILICONFLOW_API_KEY` |\n| `moonshot` | `[providers.moonshot]` | OpenAI Chat Completions | `MOONSHOT_API_KEY`, `KIMI_API_KEY` |\n| `sglang` | `[providers.sglang]` | OpenAI Chat Completions | `SGLANG_API_KEY` |\n| `vllm` | `[providers.vllm]` | OpenAI Chat Completions | `VLLM_API_KEY` |\n| `ollama` | `[providers.ollama]` | Local OpenAI-compatible Chat Completions | `OLLAMA_API_KEY` (optional; only for an authenticated local route) |\n| `ollama-cloud` | `[providers.ollama_cloud]` | Hosted OpenAI-compatible Chat Completions | `OLLAMA_CLOUD_API_KEY`, `OLLAMA_API_KEY` |\n| `huggingface` | `[providers.huggingface]` | OpenAI Chat Completions | `HUGGINGFACE_API_KEY`, `HF_TOKEN` |\n| `modelscope` | `[providers.modelscope]` | OpenAI Chat Completions | `MODELSCOPE_API_KEY` |\n| `together` | `[providers.together]` | OpenAI Chat Completions | `TOGETHER_API_KEY` |\n| `qianfan` | `[providers.qianfan]` | OpenAI Chat Completions | `QIANFAN_API_KEY`, `BAIDU_QIANFAN_API_KEY` |\n| `openai-codex` | `[providers.openai_codex]` | OpenAI Responses | Official Sign in with ChatGPT (`codewhale auth chatgpt`) with a validated Codewhale-owned plan grant |\n| `anthropic` | `[providers.anthropic]` | Anthropic Messages | `ANTHROPIC_API_KEY` or `codewhale auth claude` |\n| `openmodel` | `[providers.openmodel]` | Anthropic Messages | `OPENMODEL_API_KEY` |\n| `zai` | `[providers.zai]` | OpenAI Chat Completions | `ZAI_API_KEY`, `Z_AI_API_KEY` |\n| `stepfun` | `[providers.stepfun]` | OpenAI Chat Completions | `STEPFUN_API_KEY`, `STEP_API_KEY` |\n| `minimax` | `[providers.minimax]` | OpenAI Chat Completions | `MINIMAX_API_KEY` |\n| `deepinfra` | `[providers.deepinfra]` | OpenAI Chat Completions | `DEEPINFRA_API_KEY`, `DEEPINFRA_TOKEN` |\n| `sakana` | `[providers.sakana]` | OpenAI Chat Completions | `FUGU_API_KEY`, `SAKANA_API_KEY` |\n| `longcat` | `[providers.longcat]` | OpenAI Chat Completions | `LONGCAT_API_KEY` |\n| `opencode-go` | `[providers.opencode_go]` | OpenAI Chat Completions | `OPENCODE_GO_API_KEY` |\n| `opencode-zen` | `[providers.opencode_zen]` | Model-aware: OpenAI Responses, Anthropic Messages, or OpenAI Chat Completions | `OPENCODE_ZEN_API_KEY`, `OPENCODE_API_KEY` |\n| `meta` | `[providers.meta]` | OpenAI Chat Completions | `META_MODEL_API_KEY`, `MODEL_API_KEY` |\n| `telecomjs` | `[providers.telecomjs]` | OpenAI Chat Completions | `TELECOMJS_API_KEY` |\n| `xai` | `[providers.xai]` | OpenAI Chat Completions | `XAI_API_KEY` |\n| `mistral` | `[providers.mistral]` | OpenAI Chat Completions | `MISTRAL_API_KEY` |\n| `google` | `[providers.google]` | OpenAI Chat Completions (official Gemini OpenAI-compat route; captures and replays thought signatures on tool calls) | `GOOGLE_API_KEY`, `GEMINI_API_KEY` |\n| `edenai` | `[providers.edenai]` | OpenAI Chat Completions | `EDENAI_API_KEY` |\n| `zenmux` | `[providers.zenmux]` | OpenAI Chat Completions | `ZENMUX_API_KEY` |\n| `csdn` | `[providers.csdn]` | OpenAI Chat Completions | `CSDN_API_KEY` |\n| `concentrate` | `[providers.concentrate]` | OpenAI Responses (`/v1/responses`) | `CONCENTRATE_API_KEY` |\n| `codewhale` | `[providers.codewhale]` | Model-aware: OpenAI Chat Completions (`/v1/chat/completions`) or Anthropic Messages (`/v1/messages`), chosen per model by the account catalog | `CODEWHALE_API_KEY` |\n| `modelstudio-token-plan` | `[providers.modelstudio_token_plan]` | OpenAI Chat Completions | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` |\n| `modelstudio-token-plan-anthropic` | `[providers.modelstudio_token_plan_anthropic]` | Anthropic Messages | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` |\n| `modelstudio-coding-plan` | `[providers.modelstudio_coding_plan]` | OpenAI Chat Completions | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` |\n| `modelstudio-coding-plan-anthropic` | `[providers.modelstudio_coding_plan_anthropic]` | Anthropic Messages | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` |\n\nDefault base URLs and models for each route are listed in the shipped provider\ntable below. The wire protocol values above are derived from\n`crates/config/src/provider.rs`: `ChatCompletions` is the default,\n`openai-codex` overrides to `Responses`; `deepseek-anthropic`, `anthropic`, and\n`openmodel` override to `AnthropicMessages`; `opencode-zen` resolves the\nprotocol from the selected model's curated offering; and `deepseek` is\nmodel-aware — the shipped default `deepseek-flash` (and legacy\n`deepseek-v4-flash`) rides the Responses endpoint while `deepseek-v4-pro`\nstays on Chat Completions.\n\n## Auth And Env Rules\n\nFor hosted providers, `codewhale auth set --provider ` saves an API key for\nthat provider. API-key environment variables are fallback inputs after saved\nconfig and keyring credentials; an explicit process-level `--api-key` still\nwins for that launch.\n\nFor base URL and model selection, prefer:\n\n- `CODEWHALE_BASE_URL` / `CODEWHALE_MODEL` for the active provider.\n- Provider-specific base URL/model env vars when listed below.\n- `DEEPSEEK_BASE_URL`, `DEEPSEEK_MODEL`, and `DEEPSEEK_DEFAULT_TEXT_MODEL` as\n legacy aliases.\n\nNon-local `http://` base URLs are rejected unless\n`DEEPSEEK_ALLOW_INSECURE_HTTP=1` is set. Loopback HTTP URLs are allowed for\nself-hosted runtimes.\n\n## Custom DeepSeek-Compatible Endpoints\n\nMost custom DeepSeek-compatible deployments can use an existing provider ID.\nDo not create `[providers.deepseek_custom]`; the provider table names are fixed.\nInstead, choose the closest shipped route and override its endpoint/model:\n\n- DeepSeek-compatible hosted API: keep `provider = \"deepseek\"` and set\n `[providers.deepseek].base_url` plus `[providers.deepseek].model`, or launch\n with `DEEPSEEK_BASE_URL` and `DEEPSEEK_MODEL`.\n- Generic OpenAI-compatible gateway: use `provider = \"openai\"` with\n `[providers.openai].base_url` plus `[providers.openai].model`, or launch with\n `OPENAI_BASE_URL` and `OPENAI_MODEL`.\n- Multiple named OpenAI-compatible gateways, or local routes you want to pin\n from an AgentProfile, can use a custom table such as\n `[providers.lm-studio] kind = \"openai-compatible\"` and select it with\n `provider = \"lm-studio\"` or a profile `provider = \"lm-studio\"`.\n- Local OpenAI-compatible runtimes: use `provider = \"vllm\"`, `\"sglang\"`, or\n `\"ollama\"` with the matching provider-specific base URL/model values.\n\nExample user config for a DeepSeek-compatible host:\n\n```toml\nprovider = \"deepseek\"\n\n[providers.deepseek]\napi_key = \"YOUR_API_KEY\"\nbase_url = \"https://your-provider.example/v1\"\nmodel = \"deepseek-ai/DeepSeek-V4-Pro\"\n```\n\nExample user config for a generic gateway:\n\n```toml\nprovider = \"openai\"\n\n[providers.openai]\napi_key = \"YOUR_GATEWAY_API_KEY\"\nbase_url = \"https://gateway.example/v1\"\nmodel = \"your-deepseek-compatible-model\"\n```\n\nAlibaba Cloud Model Studio (Bailian / DashScope) is a first-class provider as\nof v0.9.4 with two plan profiles: Token Plan (Personal / Team) and Coding Plan.\nBoth plans expose an OpenAI-compatible Chat Completions endpoint and an\nAnthropic-compatible Messages endpoint.\n\n**Token Plan** (Personal and Team share the same AP-Southeast endpoint):\n\n```toml\nprovider = \"modelstudio-token-plan\"\n\n[providers.modelstudio_token_plan]\napi_key = \"YOUR_MODELSTUDIO_API_KEY\"\n# base_url defaults to https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1\nmodel = \"qwen3.8-max\" # or qwen3.8-max-preview | qwen3.7-plus | qwen3.7-max |\n # qwen3.6-flash | deepseek-v4-pro | deepseek-v4-flash-0731 |\n # glm-5.2\n```\n\n**Coding Plan** (separate international endpoint):\n\n```toml\nprovider = \"modelstudio-coding-plan\"\n\n[providers.modelstudio_coding_plan]\napi_key = \"YOUR_MODELSTUDIO_API_KEY\"\n# base_url defaults to https://coding-intl.dashscope.aliyuncs.com/v1\nmodel = \"qwen3.8-max\"\n```\n\n**Anthropic-compatible dialect** — both plans also expose a native Anthropic\nMessages path. Select it with the `-anthropic` provider suffix:\n\n```toml\nprovider = \"modelstudio-token-plan-anthropic\"\n\n[providers.modelstudio_token_plan_anthropic]\napi_key = \"YOUR_MODELSTUDIO_API_KEY\"\n# base_url defaults to https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic\nmodel = \"qwen3.8-max\"\n```\n\nCreate or copy a Model Studio API key from the\n[Bailian console](https://bailian.console.aliyun.com/). The API key is shared\nacross all four provider IDs above; only the base URL and wire protocol differ.\n\n**Thinking / reasoning.** Reasoning surfaces in the TUI's Thinking view on both\ndialects, per Model Studio's\n[deep-thinking docs](https://www.alibabacloud.com/help/en/model-studio/deep-thinking).\n\nOn the OpenAI-compatible routes the top-level controls are **route- and\nmodel-specific**, and Codewhale fails closed: they are sent only when the\nconfigured `base_url` is an official Alibaba Chat Completions host\n(`*.maas.aliyuncs.com/compatible-mode/v1`, including workspace-scoped hosts, or\n`coding-intl.dashscope.aliyuncs.com/v1`). A custom `base_url` on the same\nprovider ID gets `thinking`, `enable_thinking`, `preserve_thinking`, and\n`reasoning_effort` stripped, so an arbitrary OpenAI-compatible gateway is never\nhanded Alibaba's dialect. On a verified host:\n\n- **Hybrid models** (`qwen3.7-*`, `qwen3.6-*`, `deepseek-v4*`, `glm-*`,\n `kimi-k2.6*`) get `enable_thinking`: `false` for `off`, `true` otherwise.\n- **Thinking-only models** — `qwen3.8-max` (catalogued `thinking: always_on`),\n `qwen3.8-max-preview` (effort/budget options, no toggle), and\n `kimi-k2.7-code` — get **no** enable/disable switch at all. Sending one is at\n best ignored.\n- `preserve_thinking` is sent for the models documented to accept it\n (`qwen3.7-max`/`-plus`, `qwen3.6-max-preview`/`-plus`/`-flash`, `kimi-k2.6*`,\n `kimi-k2.7-code`), so the next turn keeps the assistant's trace.\n- `reasoning_effort` is sent only for the two families with a documented ladder\n — `deepseek-v4*` and `glm-5`/`5.1`/`5.2` — mapped to `high` or `max`.\n\nReasoning streams back as `delta.reasoning_content`. It is replayed to the\nprovider on later turns only for the `preserve_thinking` models above and the\nthinking-only models; `deepseek-v3.1`, `deepseek-v3.2`, and `glm-*` history\nstays stripped pending live confirmation that DashScope accepts\n`reasoning_content` in input messages. (`deepseek-v4*` replays regardless — the\nDeepSeek thinking-mode contract requires it on every provider.)\n\nOn the Anthropic-compatible routes, thinking uses the documented\n`{\"type\":\"enabled\",\"budget_tokens\":N}` / `{\"type\":\"disabled\"}` shapes from the\n[Anthropic-compatible Messages API](https://www.alibabacloud.com/help/en/model-studio/anthropic-api-messages),\nwith `budget_tokens` derived from the effort level.\n\nDeepSeek (`deepseek-v4-pro`, `deepseek-v4-flash-0731`) and GLM (`glm-5.2`)\nmodels served by Model Studio are provider-scoped and do not collide with the\nfirst-party DeepSeek or Zhipu/Z.ai routes. Model Studio publishes no `glm-5.3`\nentry, so Codewhale does not offer one on this route.\nPay-as-you-go workspace-id templating is not yet in the built-in provider; use\na custom provider entry for that plan until a follow-up adds it.\n\nPrivate gateways with broken or intercepted certificates should use\n`SSL_CERT_FILE` with a trusted CA bundle. The legacy\n`insecure_skip_tls_verify = true` key is still parsed so `codewhale doctor` can\nreport stale configs, but provider clients reject it instead of skipping TLS\ncertificate verification.\n\nKeep `provider`, `api_key`, and `base_url` in user config or process\nenvironment. Project-local config overlays intentionally cannot set those keys,\nso a repository cannot silently redirect prompts or credentials to another\nendpoint.\n\n## Local Models (DS4, Ollama, vLLM, SGLang)\n\nSelf-hosted OpenAI-compatible runtimes are first-class routes and are keyless\nby default — set an API key only when your server requires one. Start your\nrunner, then point Codewhale at it with `--provider` / `/provider` or a config\ntable.\n\n| Runner | Default base URL | Default model | Base URL override |\n| --- | --- | --- | --- |\n| `ollama` | `http://localhost:11434/v1` | live tag from `GET /v1/models` (pre-refresh: `unknown`) | `OLLAMA_BASE_URL` |\n| `vllm` | `http://localhost:8000/v1` | `deepseek-ai/DeepSeek-V4-Pro` | `VLLM_BASE_URL` |\n| `sglang` | `http://localhost:30000/v1` | `deepseek-ai/DeepSeek-V4-Pro` | `SGLANG_BASE_URL` |\n\n### DS4 (DwarfStar)\n\n[DS4](https://github.com/antirez/ds4/tree/84cc882352757baf628a1776badf7cc54d584e28)\nserves DeepSeek V4 Flash and Pro locally\nthrough an OpenAI-compatible API. Start DS4, then open Codewhale's prefilled,\nkeyless setup form:\n\n```bash\n./ds4-server --ctx 100000 --kv-disk-dir /tmp/ds4-kv --kv-disk-space-mb 8192\ncodewhale\n# In Codewhale: /setup provider ds4\n```\n\nReview the prefilled route and press Enter to save it. The preset budgets a\n100,000-token context to match that starter command and defaults to the Flash\ncompatibility alias. Check the local route explicitly with\n`codewhale doctor --probe-local`.\n\nDS4 loads the actual GGUF when the server starts. Its `deepseek-v4-flash` and\n`deepseek-v4-pro` API ids are compatibility aliases; changing `/model` does\nnot swap the resident model. To run Pro, download the supported Pro weights\nand start `ds4-server -m ...` as described by DS4. Update\n`context_window` whenever the server's `--ctx` value changes.\n\nThe equivalent config is:\n\n```toml\nprovider = \"ds4\"\n\n[providers.ds4]\nkind = \"openai-compatible\"\nbase_url = \"http://127.0.0.1:8000/v1\"\nmodel = \"deepseek-v4-flash\"\nauth_mode = \"none\"\ncontext_window = 100000\n```\n\nCodewhale reuses its existing OpenAI-compatible transport and DeepSeek\nreasoning/tool-call shaping for DS4. It does not invent an API key, confuse an\nAPI alias with the loaded GGUF, or silently switch to a hosted DeepSeek route.\nThe pinned DS4 [agent-client contract](https://github.com/antirez/ds4/blob/84cc882352757baf628a1776badf7cc54d584e28/README.md#agent-client-usage)\ndocuments Chat Completions at `/v1`, DeepSeek thinking replay, streamed usage,\n`max_tokens`, and no strict-tool mode; Codewhale follows those exact route\nfacts instead of inheriting unsupported capabilities from a generic gateway.\nThe primary sources for model-facing behavior are DeepSeek's official\n[thinking-mode](https://api-docs.deepseek.com/guides/thinking_mode),\n[tool-call](https://api-docs.deepseek.com/guides/tool_calls), and\n[Chat Completion](https://api-docs.deepseek.com/api/create-chat-completion)\ncontracts. The pinned\n[DeepSeek Harness adapter](https://github.com/deepseek-ai/deepseek-harness/blob/47f943859bef60e4160492346772ded9b24f765a/packages/llm/llm-deepseek/README.md)\nis only a secondary implementation cross-check; it is not the API contract.\n\n### Ollama\n\n```bash\nollama serve # if not already running\nollama pull # e.g. deepseek-v4-flash, or any tag you prefer\ncodewhale --provider ollama --model \n```\n\nProvider-hinted model names are sent as-is, so `--model qwen3:8b` works with\nany tag Ollama has pulled.\n\n### Ollama Cloud\n\nOllama Cloud is a separate hosted provider. It uses the authenticated\nOpenAI-compatible `/v1/chat/completions` route and defaults to `gpt-oss:120b`:\n\n```toml\nprovider = \"ollama-cloud\"\n\n[providers.ollama_cloud]\nbase_url = \"https://ollama.com/v1\"\nmodel = \"gpt-oss:120b\"\n```\n\nCreate a key in [Ollama account settings](https://ollama.com/settings/keys),\nthen run `codewhale auth set --provider ollama-cloud`. For ambient auth,\n`OLLAMA_CLOUD_API_KEY` wins over Ollama's official `OLLAMA_API_KEY`.\n`OLLAMA_CLOUD_BASE_URL` and `OLLAMA_CLOUD_MODEL` override the Cloud defaults;\narbitrary provider-owned model IDs pass through unchanged. Local `ollama`\nremains a separate, keyless-by-default provider.\n\nCompatibility is read-only and in memory: a released config that selected\n`provider = \"ollama\"` with the exact normalized\n`[providers.ollama] base_url = \"https://ollama.com/v1\"` tuple is treated as\n`ollama-cloud` at runtime. Only that exact tuple may fall back to the legacy\n`ollama` secret slot. Codewhale does not rewrite the config, copy or delete a\nsecret, migrate neighboring paths, or make an explicit `ollama-cloud` route\nconsume the legacy slot.\n\n### vLLM\n\n```bash\nvllm serve --port 8000\n# or: python -m vllm.entrypoints.openai.api_server --model --port 8000\ncodewhale --provider vllm --model \n```\n\nvLLM's OpenAI-compatible server listens on port 8000 by default, matching\nCodewhale's `VLLM_BASE_URL`.\n\n### SGLang\n\n```bash\npython -m sglang.launch_server --model-path --port 30000\ncodewhale --provider sglang --model \n```\n\nSGLang's default port 30000 matches Codewhale's `SGLANG_BASE_URL`.\n\n### Pinning a local route in config\n\n```toml\nprovider = \"ollama\" # or \"vllm\" / \"sglang\"\n\n[providers.ollama]\nmodel = \"qwen3:8b\" # default is deepseek-v4-flash\n# base_url defaults to http://localhost:11434/v1\n```\n\nLocal models that print tool-call JSON without the wire markers: see\n[When a Local Model Prints Tool JSON](#when-a-local-model-prints-tool-json).\n\n## Credential Links\n\nProvider setup surfaces use the same typed credential metadata as onboarding,\n`/provider`, `/links`, setup receipts, and doctor output. A missing URL is\nintentional: local, OAuth-only, and user-defined routes show their supported\nconfiguration path instead of guessing a vendor page.\n\n| Provider ID | Credential or console link |\n| --- | --- |\n| `deepseek`, `deepseek-anthropic` | [DeepSeek API keys](https://platform.deepseek.com/api_keys) |\n| `nvidia-nim` | [NVIDIA NIM API keys](https://build.nvidia.com/settings/api-keys) |\n| `openai` | [OpenAI API keys](https://platform.openai.com/api-keys) |\n| `atlascloud` | [Atlas Cloud API keys](https://atlascloud.ai/docs/en/api-keys) |\n| `wanjie-ark` | [Wanjie MaaS APIKEY docs](https://docs.wanjiedata.com/maas/maas-openapi-v1.html) |\n| `volcengine` | [Volcengine Ark API keys](https://console.volcengine.com/ark/apiKey) |\n| `openrouter` | [OpenRouter keys](https://openrouter.ai/settings/keys) |\n| `xiaomi-mimo` | [Xiaomi MiMo Token Plan](https://platform.xiaomimimo.com/token-plan) |\n| `novita` | [Novita key management](https://novita.ai/en/settings/key-management) |\n| `fireworks` | [Fireworks API keys](https://fireworks.ai/api-keys) |\n| `siliconflow` | [SiliconFlow global API keys](https://cloud.siliconflow.com/account/ak) |\n| `siliconflow-CN` | [SiliconFlow China API keys](https://cloud.siliconflow.cn/account/ak) |\n| `arcee` | [Arcee API key guide](https://docs.arcee.ai/other/create-your-first-api-key) |\n| `moonshot` | [Kimi API platform keys](https://platform.kimi.ai/console/api-keys) or [Kimi Code membership console](https://www.kimi.com/code/console) |\n| `zai` | [Z.ai model API](https://z.ai/model-api) |\n| `stepfun` | [StepFun Open Platform](https://platform.stepfun.ai/) |\n| `minimax`, `minimax-anthropic` | [MiniMax interface keys](https://platform.minimax.io/user-center/basic-information/interface-key) |\n| `huggingface` | [Hugging Face tokens](https://huggingface.co/settings/tokens) |\n| `modelscope` | [ModelScope API Keys](https://modelscope.cn/my/settings/token) |\n| `deepinfra` | [DeepInfra API keys](https://deepinfra.com/dash/api_keys) |\n| `together` | [Together API keys](https://api.together.ai/settings/api-keys) |\n| `qianfan` | [Baidu Cloud access keys](https://console.bce.baidu.com/iam/#/iam/accesslist) |\n| `anthropic` | [Anthropic API keys](https://console.anthropic.com/settings/keys) |\n| `openmodel` | [OpenModel console](https://console.openmodel.ai/) ([authentication guide](https://docs.openmodel.ai/en/docs/getting-started/authentication)) |\n| `openai-codex` | Official Sign in with ChatGPT via `codewhale auth chatgpt` (eligible ChatGPT plan or credits, Codewhale-owned tokens). The `openai` API-key route has separate billing. Legacy imported Codex credentials do not authorize this route. |\n| `sglang`, `vllm` | Local OpenAI-compatible endpoints are keyless by default; configure a key only when the server requires one. |\n| `ollama` | Local Ollama is keyless by default; configure a key only when the local server requires one. |\n| `ollama-cloud` | Create an [Ollama API key](https://ollama.com/settings/keys), save it with `codewhale auth set --provider ollama-cloud`, or set `OLLAMA_CLOUD_API_KEY` / `OLLAMA_API_KEY` in that precedence order. |\n| `sakana` | [Sakana AI API keys](https://console.sakana.ai/api-keys) ([get started](https://console.sakana.ai/get-started)) |\n| `longcat` | [Meituan LongCat platform](https://longcat.chat/platform) |\n| `opencode-go` | [OpenCode Go](https://opencode.ai/docs/go/) |\n| `opencode-zen` | [OpenCode Zen](https://opencode.ai/docs/zen/) |\n| `meta` | [Meta Model API](https://developer.meta.com/ai/) |\n| `telecomjs` | [TelecomJS TokenHub](https://aigw.telecomjs.com/) |\n| `xai` | [xAI Console](https://console.x.ai/) for an API key, Codewhale-owned device login, or explicitly consented read-only Grok CLI credentials. |\n| `mistral` | [Mistral Console (la Plateforme)](https://console.mistral.ai/api-keys) |\n| `google` | [Google AI Studio](https://aistudio.google.com/apikey) — Codewhale uses the official Gemini OpenAI-compatible endpoint and never reads Google OAuth files. |\n| `edenai` | [Eden AI API keys](https://app.edenai.run/settings/api-keys) |\n| `zenmux` | [ZenMux API keys](https://zenmux.ai/platform/pay-as-you-go) |\n| `csdn` | [CSDN 星图 console](https://ai.csdn.net/workbench/api-key) — choose the Coding Plan key type for the `glm_for_coding` plan route; a general key bills metered. Docs: [Coding Plan](https://ai.csdn.net/coding-plan). |\n| `concentrate` | [Concentrate dashboard](https://concentrate.ai/) → API Keys → Create API Key (a Universal API key); docs: [API introduction](https://concentrate.ai/docs/api-reference/introduction). BYOK only — the key stays in the local secret store. |\n| `modelstudio-token-plan`, `modelstudio-token-plan-anthropic`, `modelstudio-coding-plan`, `modelstudio-coding-plan-anthropic` | [Alibaba Cloud Model Studio (Bailian console)](https://bailian.console.aliyun.com/) — create or copy a Model Studio API key. |\n| `codewhale` | [Codewhale account settings](https://app.codewhale.net/settings?section=api) — create an API key with the `models:infer` scope, or run `codewhale account api-keys create --name --use`. |\n| `custom` | Set the named provider's `base_url` and `api_key_env` or `api_key`; no canonical vendor credential page exists. |\n\nFor Kimi, the official [quickstart](https://platform.kimi.ai/docs/overview)\ndirects users to sign in, open **API Keys**, create and copy a key, and keep it\nsecret. Codewhale links straight to that console and accepts the copied key.\nIt never probes or impersonates `kimi_cli`/`kimi_code_cli`; first-class Kimi\nOAuth remains blocked on a vendor-registered Codewhale identity.\n\n### Subscription sign-in accounts (ChatGPT, xAI)\n\nCodewhale keeps one sign-in of its own per subscription provider. It is\nseparate from the browser, the ChatGPT or Grok apps, and the Codex or Grok\nCLIs, so those can be signed in to a different account. A successful login\nreplaces the previous owned grant; there is no picker for multiple saved\nChatGPT accounts.\n\n- **See which account is signed in.** `codewhale auth status --provider\n openai-codex` (or `--provider xai`) and `codewhale auth list` show an account\n label when the ID token contains an email. ChatGPT plan and workspace\n details appear only when those claims are present; a workspace label uses\n the first characters of its account ID. The label is display metadata, not\n proof of plan entitlement. A valid sign-in may have no email label. Status\n reads local credential state without contacting the issuer: an expired\n access token with a stored refresh token remains structurally usable, while\n missing or invalid storage, or an expired token without a refresh token,\n is reported unusable. The provider picker shows the label of the sign-in\n the route would use (for xAI this includes a consented Grok CLI import).\n Successful login reports the account when known and whether it replaced\n an earlier sign-in, in the TUI's selected language. No token is printed.\n- **Reauthorize ChatGPT.** Run `/auth chatgpt` inside Codewhale to update the\n running session, or `codewhale auth chatgpt` from a shell and restart open\n Codewhale sessions. Ordinary login reuses the selected account/workspace's\n issued client ID and checks that the returned account is the same. The\n browser opens with `prompt=login`; `CODEWHALE_CHATGPT_OAUTH_NO_PROMPT=1`\n omits that parameter if the issuer refuses it.\n- **Replace the ChatGPT account or workspace.** From a shell, run\n `CODEWHALE_CHATGPT_NEW_ACCOUNT=1 codewhale auth chatgpt`, then refresh its\n model catalog and restart open Codewhale sessions. If the browser holds the\n wrong account, sign out there or open the printed URL in a private window.\n Only a fully validated new grant replaces the active owned sign-in.\n- **Switch xAI accounts.** Run `/auth xai-device` inside Codewhale, or\n `codewhale auth xai-device` from a shell and restart open sessions. The\n device page approves with whichever account the browser holds; sign out\n there or use a private window to approve another account. The login names\n the account it replaced, or says it is the same account as before.\n- **Usage limits.** ChatGPT plan requests can fail with\n `subscription_sharing_usage_limit_exceeded` or\n `subscription_sharing_usage_unavailable`. These errors, and subscription\n quota errors from other providers, name the account that made the request\n when its label is known and offer recovery guidance. Codewhale does not\n retry these terminal errors or silently choose another billing route. An\n xAI route set to OAuth that fell back to an API key gets no sign-in\n guidance, since no sign-in was used.\n- **ChatGPT credential authority.** The official ChatGPT plan route uses\n only its selected, verified Codewhale-owned grant.\n `OPENAI_CODEX_ACCESS_TOKEN`, `CODEX_ACCESS_TOKEN`, and consented Codex CLI\n tokens cannot override or authorize this route.\n\n### External CLI credential consent\n\nCredential files owned by another CLI are disabled by default. Without an\nexplicit grant, provider discovery, setup, routing, `auth status`, and doctor\ndo not stat, read, refresh, contact an identity provider for, or rewrite Codex,\nGrok, Kimi, or future external credential files.\n\nCodewhale currently supports exact-path, provider-scoped **read-only** grants\nfor the Codex CLI and Grok CLI. Codex grants remain available for legacy\ncredential inspection only; they cannot authorize the official ChatGPT plan\nroute. Use `codewhale auth chatgpt` for that route.\n\n```bash\n# Legacy Codex credential inspection; does not authorize ChatGPT plan requests.\ncodex login\ncodewhale auth external-consent --provider openai-codex --mode read-only\n\ngrok login\ncodewhale auth external-consent --provider xai --mode read-only\n\ncodewhale auth status --provider openai-codex\ncodewhale auth external-revoke --provider openai-codex\n```\n\nPass `--path /absolute/path/to/auth.json` when the external CLI uses a custom\nlocation. Consent persists the provider, external owner, exact absolute path,\nand consent schema version. Later environment-variable changes do not redirect\nthat authority to a different file. Read-only grants never refresh, contact an\nidentity/discovery service, or rewrite the external file. Supported routes such\nas xAI may use the external token after explicit selection. The official ChatGPT\nplan route ignores imported Codex tokens and requires its own verified grant.\nAn expired external token fails with login guidance. Doctor reports structural\nconsent/config state without\nopening credential files and is always non-mutating.\n\n`managed` is reserved for a future provider-specific preservation adapter.\nv0.9.1 rejects it before file or network I/O because no reviewed adapter can\nyet preserve every unknown external schema field safely. Codewhale-started xAI\ndevice login instead atomically activates a Codewhale-owned generation named\n`$CODEWHALE_HOME/credentials/xai-auth-.json`, stores only that\nvalidated basename in config, and revokes any Grok-file grant. Superseded\ngenerations are cleaned only after the new config pointer commits.\nKimi remains API-key-only; external consent for Kimi is rejected.\n\nThe official DeepSeek Harness (`dsh`) is a third read-only credential owner:\n`codewhale auth external-consent --provider deepseek --mode read-only` grants\nexact-path read access to `DEEPSEEK_API_KEY` in `$DSH_HOME/.credentials.yaml`\n(or `~/.dsh/.credentials.yaml`), which Codewhale never writes, refreshes, or\nloads into the process environment. This is separate from the DSH *harness*\nintegration (`codewhale integrations dsh …`, see\n[INTEGRATIONS_DSH.md](INTEGRATIONS_DSH.md)), which never touches credentials\nin either direction: it pins Codewhale's route identity into a `--patch`\noverlay and lets DSH resolve its own keys.\n\n## Shipped Providers\n\n| Provider ID | TOML table | Auth env | Base URL env and default | Default or static models | Notes |\n| --- | --- | --- | --- | --- | --- |\n| `deepseek` | `[providers.deepseek]` | `DEEPSEEK_API_KEY` | `CODEWHALE_BASE_URL` / `DEEPSEEK_BASE_URL`; default `https://api.deepseek.com/beta` | `deepseek-flash` (shipped default; V4.1 Flash, unversioned id), `deepseek-v4-pro`, `deepseek-v4-flash`, experimental `deepseek-v4-flash-vision-exp`; vision aliases `flash-vision`, `deepseek-v4flashvisionexp`; compatibility aliases `deepseek-chat`, `deepseek-reasoner` | First-class default. The live Pro backend is labeled `DeepSeek-V4-Pro-0813`; the callable API ID remains `deepseek-v4-pro`. Beta URL enables strict tool mode, chat prefix completion, and FIM completion. The documented V4 routes can use provider-native web search through a separate bounded Responses request; compatible custom endpoints do not inherit that capability. Set `https://api.deepseek.com` or `/v1` explicitly to opt out of beta-only features. The shipped default `deepseek-flash` speaks the Responses API (DeepSeek's documented path for Codex-style integration, since the 2026-07-31 Flash production update); the Chat-only controls (the `thinking` toggle and strict-tool `/beta` routing) apply to Chat Completions routes, and `deepseek-v4-pro` stays on Chat Completions until its announced Responses rollout. Reasoning effort follows DeepSeek's documented requested-to-actual mapping: `minimal`/`low` land on `low`, `medium`/`xhigh` on `high`, and `max`/`ultra` on `max`; `off` disables thinking (`thinking: {\"type\":\"disabled\"}` on Chat, `reasoning.effort: \"none\"` on Responses). The experimental vision ID was observed in the authenticated `/models` roster on 2026-08-21 and is advertised as image-input capable on the direct Chat Completions route only. Its limits, reasoning, and tool-call flags provisionally inherit Flash; pricing remains unknown, and no funded image round trip was made during this release work. |\n| `deepseek-anthropic` | `[providers.deepseek_anthropic]` | `DEEPSEEK_API_KEY` | `DEEPSEEK_ANTHROPIC_BASE_URL`; default `https://api.deepseek.com/anthropic` | `deepseek-v4-pro`, `deepseek-v4-flash`; compatibility aliases `deepseek-chat`, `deepseek-reasoner` | Opt-in DeepSeek route for the Anthropic Messages wire protocol. Uses `/v1/messages`, `x-api-key`, and `anthropic-version: 2023-06-01`. Keep `provider = \"deepseek\"` for the default Chat Completions path. |\n| `nvidia-nim` | `[providers.nvidia_nim]` | `NVIDIA_API_KEY`, `NVIDIA_NIM_API_KEY` | `NVIDIA_NIM_BASE_URL`, `NIM_BASE_URL`, `NVIDIA_BASE_URL`; default `https://integrate.api.nvidia.com/v1` | `deepseek-ai/deepseek-v4-pro`, `deepseek-ai/deepseek-v4-flash` | Hosted DeepSeek V4 through NVIDIA NIM. `NVIDIA_NIM_MODEL` is accepted by the TUI config path. |\n| `openai` | `[providers.openai]` | `OPENAI_API_KEY` | `OPENAI_BASE_URL`; default `https://api.openai.com/v1` | `gpt-5.6` (default), `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` | Generic OpenAI-compatible route whose built-in endpoint and fallback catalog are native to OpenAI. The [GPT-5.6 family](https://developers.openai.com/api/docs/models/gpt-5.6-sol) uses OpenAI's documented 1.05M context, 128K max output, and reasoning levels. Custom gateways remain free to select an explicit gateway-owned model. `OPENAI_MODEL` is accepted. |\n| `atlascloud` | `[providers.atlascloud]` | `ATLASCLOUD_API_KEY` | `ATLASCLOUD_BASE_URL`; default `https://api.atlascloud.ai/v1` | Default `deepseek-ai/deepseek-v4-flash`; explicit `vendor/model-id` values pass through when AtlasCloud is selected | OpenAI-compatible hosted route. `ATLASCLOUD_MODEL` is accepted by the TUI config path, the static `ModelRegistry` keeps DeepSeek V4 fallback rows, and provider-hinted CLI model IDs are sent to AtlasCloud exactly as requested. Use Atlas Cloud's own catalog or Coding Plan page for the current provider-owned model list and pricing. |\n| `wanjie-ark` | `[providers.wanjie_ark]` | `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, `WANJIE_MAAS_API_KEY` | `WANJIE_ARK_BASE_URL`, `WANJIE_BASE_URL`, `WANJIE_MAAS_BASE_URL`; default `https://maas-openapi.wanjiedata.com/api/v1` | `deepseek-reasoner` | OpenAI-compatible hosted route. `WANJIE_ARK_MODEL`, `WANJIE_MODEL`, and `WANJIE_MAAS_MODEL` are accepted. |\n| `volcengine` | `[providers.volcengine]` | `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, `ARK_API_KEY` | `VOLCENGINE_BASE_URL`, `VOLCENGINE_ARK_BASE_URL`, `ARK_BASE_URL`; default `https://ark.cn-beijing.volces.com/api/coding/v3` | `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash` | Volcengine/Volcano Engine Ark OpenAI-compatible coding endpoint. `VOLCENGINE_MODEL` and `VOLCENGINE_ARK_MODEL` are accepted. |\n| `openrouter` | `[providers.openrouter]` | `OPENROUTER_API_KEY` | `OPENROUTER_BASE_URL`; default `https://openrouter.ai/api/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`; recent large IDs include `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`, `qwen/qwen3.6-flash`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`, `qwen/qwen3.6-plus`, `google/gemma-4-31b-it`, `z-ai/glm-5.1`, `z-ai/glm-5.2`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6` | Additive open-model routing layer. It does not replace DeepSeek; it lets users route supported model IDs through OpenRouter when they choose it. |\n| `orcarouter` | `[providers.orcarouter]` | `ORCAROUTER_API_KEY` | `ORCAROUTER_BASE_URL`; default `https://api.orcarouter.ai/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`; router alias `orcarouter/auto`; recent large IDs mirror the OpenRouter namespaced catalog | [OrcaRouter](https://www.orcarouter.ai) OpenAI-compatible aggregation gateway. Shares the namespaced `vendor/model` wire-model format and DeepSeek model set with OpenRouter, so the OpenRouter base-URL and model-normalization rules apply. `ORCAROUTER_MODEL` is accepted. Provider aliases: `orcarouter`, `orca_router`, `orca`. |\n| `xiaomi-mimo` | `[providers.xiaomi_mimo]` | `XIAOMI_MIMO_TOKEN_PLAN_API_KEY`, `MIMO_TOKEN_PLAN_API_KEY`, `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, `MIMO_API_KEY` | `XIAOMI_MIMO_BASE_URL`, `MIMO_BASE_URL`, `XIAOMI_MIMO_MODE`, `MIMO_MODE`; default `https://token-plan-sgp.xiaomimimo.com/v1` | Chat: `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, `mimo-v2.5`; speech/TTS: `mimo-v2.5-tts`, `mimo-v2.5-tts-voicedesign`, `mimo-v2.5-tts-voiceclone`, `mimo-v2-tts` | Xiaomi MiMo OpenAI-compatible chat completions route. `mimo-v2.5-pro` and `mimo-v2.5` can use the documented provider-native web-search plugin; ultraspeed, speech/TTS, and custom-compatible routes do not inherit it. Token Plan keys (`tp-...`) use `api-key` auth and the token-plan endpoint by default; pay-as-you-go mode uses standard API keys (`sk-...`) and `https://api.xiaomimimo.com/v1`. It sends `max_completion_tokens` and uses MiMo's `thinking` field for reasoning control. Token Plan cost/usage is credit/quota based; Codewhale shows it as unknown until Xiaomi exposes a reliable balance API. `codewhale speech` / `tts` uses the TTS models. |\n| `novita` | `[providers.novita]` | `NOVITA_API_KEY` | `NOVITA_BASE_URL`; default `https://api.novita.ai/openai/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | OpenAI-compatible hosted route for DeepSeek model IDs. Use config or `CODEWHALE_MODEL` / `DEEPSEEK_MODEL` for model overrides. |\n| `fireworks` | `[providers.fireworks]` | `FIREWORKS_API_KEY` | `FIREWORKS_BASE_URL`; default `https://api.fireworks.ai/inference/v1` | `accounts/fireworks/models/deepseek-v4-pro` | OpenAI-compatible hosted route. Use config or `CODEWHALE_MODEL` / `DEEPSEEK_MODEL` for model overrides. |\n| `siliconflow` | `[providers.siliconflow]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.com/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | OpenAI-compatible hosted route. Official docs use the `.com` endpoint. `SILICONFLOW_MODEL` is accepted. Reasoning aliases `deepseek-reasoner` and `deepseek-r1` map to Pro; `deepseek-chat` and `deepseek-v3` map to Flash. |\n| `siliconflow-CN` | `[providers.siliconflow_cn]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.cn/v1` | Uses the SiliconFlow model set | China regional SiliconFlow route. Falls back to `[providers.siliconflow]` for api_key / base_url / model when unset. Select it with `provider = \"siliconflow-CN\"` or `CODEWHALE_PROVIDER=siliconflow-CN`. |\n| `arcee` | `[providers.arcee]` | `ARCEE_API_KEY` | `ARCEE_BASE_URL`; default `https://api.arcee.ai/api/v1` | `trinity-large-thinking`, `trinity-large-preview` | Arcee AI direct OpenAI-compatible route, tracked as 256K-context BF16 serving. `ARCEE_MODEL` is accepted. OpenRouter's `arcee-ai/trinity-large-thinking` remains the OpenRouter namespaced model ID; direct Arcee uses the bare `trinity-large-thinking` ID. |\n| `moonshot` | `[providers.moonshot]` | `MOONSHOT_API_KEY`, `KIMI_API_KEY` | `MOONSHOT_BASE_URL`, `KIMI_BASE_URL`; default `https://api.moonshot.ai/v1` | Direct Moonshot: `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.7-code-highspeed`, `kimi-k2.6`; Kimi Code membership: `k3`, `kimi-for-coding`, `kimi-for-coding-highspeed` at `https://api.kimi.com/coding/v1` | Moonshot/Kimi route. Exact direct `kimi-k3` routes use the documented Formula web-search tool/fiber loop; direct `kimi-k2.6` retains the built-in `$web_search` contract, and exact Kimi Code membership routes use their structured `/search` service. Adjacent paths, K2.7 direct models, and cross-product model IDs do not inherit native search. `kimi` and `kimi-k2` aliases select `kimi-k2.7-code`; `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, and `KIMI_MODEL` are accepted. Kimi thinking streams through `reasoning_content`; Codewhale keeps it in Thinking cells and replays it for thinking/tool-call continuity. For direct K3, use exact `base_url = \"https://api.moonshot.ai/v1\"` and `model = \"kimi-k3\"`; it is always-thinking and receives top-level `reasoning_effort = \"low\" | \"high\" | \"max\"` (`off` normalizes to `low`), uses only `max_completion_tokens`, and omits `temperature`/`top_p` per the [K3 quickstart](https://platform.kimi.ai/docs/guide/kimi-k3-quickstart). For Kimi Code K3, use a key from the [Kimi Code console](https://www.kimi.com/code/console), exact `base_url = \"https://api.kimi.com/coding/v1\"`, and bare `model = \"k3\"`; `off` becomes enabled `low`, while normal dispatched `auto` selects and sends a concrete Codewhale tier. Only an omitted reasoning setting leaves the provider default in control. That membership route defaults safely to 262,144 context tokens; the [Kimi Code model-tier table](https://www.kimi.com/code/docs/en/kimi-code/models.html) grants Allegretto and higher plans up to 1M, which those plans may express as `context_window = 1048576`. `k3[1m]` is Claude Code-only and Codewhale rejects it. `kimi-for-coding` remains the valid K2.7 membership route, and `kimi-for-coding-highspeed` is its own high-speed roster entry (262,144 context); membership ids are rejected on the direct platform endpoint, and `kimi-k3` stays rejected on the membership endpoint. Billing is decided by the endpoint the route resolves to, judged once against the two exact product endpoints: direct Moonshot (`https://api.moonshot.ai/v1` or the default) bills metered with dollar estimates, the exact Kimi Code membership endpoint bills as Kimi Code quota and never shows dollar estimates, and anything else — a gateway host, a neighboring Kimi-hosted path — reports `cost: unknown` rather than borrowing either product. An imported Kimi Code token with no `base_url` in its table still resolves to the membership endpoint, so it bills as Kimi Code quota and never accrues dollars. A completed turn, parent or sub-agent, is billed from the immutable endpoint receipt its own client was built with, never from a later config re-read: `MOONSHOT_BASE_URL`/`KIMI_BASE_URL` are merged into the *active* provider's table only, and an in-turn provider switch can move the ambient config off the route that actually ran. Legacy `auth_mode = \"kimi_oauth\"` fails to API-key guidance without probing Kimi CLI files. Codewhale does not impersonate `kimi_cli` or `kimi_code_cli`. **China-region keys:** contributor field evidence (@vFONGv, PR #5229, verified on Windows 10) reports that a China-region Moonshot key must be paired with `base_url = \"https://api.moonshot.cn/v1\"`; left on the default international host (`https://api.moonshot.ai/v1`) it fails authentication. We have no China-region key to verify this ourselves, so it is recorded as a user report rather than a tested route. Note also that editing `base_url` alone does not take effect until `codewhale auth set` is re-run for that provider. |\n| `google` | `[providers.google]` | `GOOGLE_API_KEY`, `GEMINI_API_KEY` | `GOOGLE_BASE_URL`, `GEMINI_BASE_URL`; default `https://generativelanguage.googleapis.com/v1beta/openai/` | `gemini-3.1-pro-preview` (default); `/model` also lists `gemini-3-pro-preview`, `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-2.5-pro`, `gemini-2.5-flash` | Google Gemini as its own backend on the official OpenAI-compatible Chat Completions route. Thinking models capture `extra_content.google.thought_signature` on tool calls and replay it with the assistant tool-call messages; replaying a tool call whose signature was not captured fails closed with an actionable error instead of letting the tool loop break. `gemini-2.5-flash-lite` ships thinking off and degrades with a warning instead. Reasoning effort maps onto the documented `google.thinking_config.thinking_level` (`low`/`high`). The dialect binds to the exact official base URL: a `google` row pointed at another gateway gets plain OpenAI semantics and no signature requirements. Codewhale never reads Google OAuth files; only an AI Studio API key is used. Not live-tested against the real endpoint in this environment. |\n| `zai` | `[providers.zai]` | `ZAI_API_KEY`, `Z_AI_API_KEY`, `ZHIPU_API_KEY`, `GLM_API_KEY` | `ZAI_BASE_URL`, `Z_AI_BASE_URL`; default `https://api.z.ai/api/coding/paas/v4`; general APIs `https://api.z.ai/api/paas/v4` and `https://open.bigmodel.cn/api/paas/v4` | `GLM-5.3` default; `/model` also lists `GLM-5.3-Flash`, `GLM-5.2`, `GLM-5.1`, and `GLM-5-Turbo` | Z.AI GLM Coding Plan route. All three first-party Chat routes (both api.z.ai products and BigModel's general platform endpoint) share one request dialect, so reasoning controls — the GLM-5.2 thinking toggle, tiered effort, and the forced-thinking GLM-5.3 rewrite that sends `off` as `enabled` + `reasoning_effort: \"low\"` — apply on BigModel too; neighboring paths such as `/preview` stay fail-closed. The two general API products expose structured provider-native web search (`search-prime` globally, `search_std` in China); Coding Plan and compatible custom endpoints do not inherit it. `GLM-5.3` is the default and a first-class picker row (`model = \"GLM-5.3\"` or `ZAI_MODEL=GLM-5.3`); `GLM-5.3-Flash` is the 1M multimodal fast sibling (`model = \"GLM-5.3-Flash\"`). An explicit `GLM-5.2` selection keeps its own id. Limits and reasoning options for 5.3 are inherited from `GLM-5.2` until Z.ai publishes distinct 5.3 metadata; 5.3 carries no price. Flash ships the published $0.15/$0.50 list. A live call can still 429 with entitlement code 1311 on accounts that are not provisioned for 5.3. |\n| `stepfun` | `[providers.stepfun]` | `STEPFUN_API_KEY`, `STEP_API_KEY` | `STEPFUN_BASE_URL`, `STEP_BASE_URL`; default `https://api.stepfun.ai/v1`; Coding Plan endpoint `https://api.stepfun.ai/step_plan/v1` | `step-3.7-flash` | StepFun / StepFlash direct OpenAI-compatible route. `/provider` setup asks which billing route the key belongs to — pay-as-you-go or Step Plan — validates the key against the chosen endpoint, and writes the answer to `[providers.stepfun].base_url` only. A base URL that is neither recognized route is left alone and the question is skipped. You can also set `[providers.stepfun].base_url` or `STEP_BASE_URL` to the Coding Plan URL by hand. Offline accounting labels recognized routes as `stepfun-payg` or `stepfun-plan` without persisting the raw endpoint, and only the standard PAYG route receives token pricing. `STEPFUN_MODEL` and `STEP_MODEL` are accepted. |\n| `minimax` | `[providers.minimax]` | `MINIMAX_API_KEY` | `MINIMAX_BASE_URL`; default `https://api.minimax.io/v1`; China `https://api.minimaxi.com/v1` | `MiniMax-M3`, `MiniMax-M2.7`, `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `MiniMax-M2.1`, `MiniMax-M2.1-highspeed`, `MiniMax-M2` | MiniMax direct OpenAI-compatible route. Codewhale sends `reasoning_split = true` so MiniMax thinking arrives separately from answer text. Both MiniMax dialects sell pay-as-you-go and Token Plan over the same endpoints and the same key, so billing is classified from the credential *product*, never from the endpoint or from a default. `mode = \"token-plan\"` in `[providers.minimax]`/`[providers.minimax_anthropic]`, or a Token Plan key shaped `sk-cp…`, bills as MiniMax Token Plan quota with no dollar estimates; an explicit pay-as-you-go mode (`pay-as-you-go`/`payg`/`metered`) wins over key shape. The key's product prefix is only visible when the key is in config, bound by `api_key_env`, or exported as `MINIMAX_API_KEY` on an official endpoint — a key saved through `codewhale auth set` (secret store / OS keyring) is deliberately not read to classify billing. With no explicit mode and no visible product marker the route reports `cost: unknown` rather than assuming pay-as-you-go, so a Token Plan account is never charged invented dollars. Custom/gateway endpoints also fail closed with `cost: unknown`. Official M3 input modalities are text, image, and video; M2.7 is text-only. |\n| `minimax-anthropic` | `[providers.minimax_anthropic]` | `MINIMAX_API_KEY` | `MINIMAX_ANTHROPIC_BASE_URL`; default `https://api.minimax.io/anthropic`; China `https://api.minimaxi.com/anthropic` | `MiniMax-M3`, `MiniMax-M2.7` | MiniMax direct Anthropic-compatible Messages route. Keep the `/anthropic` suffix because Codewhale appends `/v1/messages`; the route uses `x-api-key`. M3 supports adaptive or disabled thinking. M2.7 always keeps thinking enabled. |\n| `sglang` | `[providers.sglang]` | Optional `SGLANG_API_KEY` | `SGLANG_BASE_URL`; default `http://localhost:30000/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Self-hosted OpenAI-compatible route. Localhost deployments commonly omit auth. `SGLANG_MODEL` is accepted. |\n| `vllm` | `[providers.vllm]` | Optional `VLLM_API_KEY` | `VLLM_BASE_URL`; default `http://localhost:8000/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Self-hosted vLLM OpenAI-compatible route. Localhost deployments commonly omit auth. `VLLM_MODEL` is accepted. |\n| `ollama` | `[providers.ollama]` | Local optional `OLLAMA_API_KEY` | `OLLAMA_BASE_URL`; default `http://localhost:11434/v1` | live tag from the local catalog; pre-refresh placeholder `unknown`; provider-hinted custom tags pass through | Local Ollama is keyless by default. `OLLAMA_MODEL` is accepted. The header must not paint a hosted id the local daemon did not list. |\n| `ollama-cloud` | `[providers.ollama_cloud]` | `OLLAMA_CLOUD_API_KEY`, then `OLLAMA_API_KEY` | `OLLAMA_CLOUD_BASE_URL`; default `https://ollama.com/v1` | `gpt-oss:120b`; arbitrary provider-owned IDs pass through | Hosted OpenAI-compatible `/v1/chat/completions` route. Save credentials under `ollama-cloud`; the exact released `ollama` + Cloud URL tuple has bounded read-only in-memory compatibility with its legacy table and secret slot. `OLLAMA_CLOUD_MODEL` is accepted. |\n| `huggingface` | `[providers.huggingface]` | `HUGGINGFACE_API_KEY`, `HF_TOKEN` | `HUGGINGFACE_BASE_URL`, `HF_BASE_URL`; default `https://router.huggingface.co/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Hugging Face Inference Providers OpenAI-compatible router route. Accepted aliases: `huggingface`, `hugging-face`, `hugging_face`, `hf`. Org-prefixed model IDs pass through. `HUGGINGFACE_MODEL` and `HF_MODEL` are accepted. Hub browsing/export are separate future features. |\n| `modelscope` | `[providers.modelscope]` | `MODELSCOPE_API_KEY` | `MODELSCOPE_BASE_URL`; default `https://api-inference.modelscope.cn/v1` | `Qwen/Qwen3.5-397B-A17B` (default), `Qwen/Qwen3.5-122B-A10B`, `Qwen/Qwen3.5-27B`, `Qwen/Qwen3.5-35B-A3B`, `Qwen/Qwen3.8-27B`, `Qwen/Qwen3.8-Flash-Next`, `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Pro-0813`, `deepseek-ai/DeepSeek-V4.1-Flash`, `ZhipuAI/GLM-4.7-Flash`, `ZhipuAI/GLM-5.2` | ModelScope OpenAI-compatible inference route. Org-prefixed model IDs pass through. `MODELSCOPE_MODEL` is accepted. |\n| `deepinfra` | `[providers.deepinfra]` | `DEEPINFRA_API_KEY`, `DEEPINFRA_TOKEN` | `DEEPINFRA_BASE_URL`; default `https://api.deepinfra.com/v1/openai` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | DeepInfra OpenAI-compatible route. Drop-in replacement for OpenAI SDK. |\n| `together` | `[providers.together]` | `TOGETHER_API_KEY` | `TOGETHER_BASE_URL`; default `https://api.together.xyz/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash`, `thinkingmachines/inkling` | Together AI OpenAI-compatible route. `TOGETHER_MODEL` is accepted. Model aliases `deepseek-v4-pro` and `deepseek-v4-flash` normalize to Together's org-prefixed IDs; `inkling` and `together-inkling` normalize to Together's published lowercase Inkling wire ID. Inkling uses the exact `none`/`minimal`/`low`/`medium`/`high`/`max` reasoning vocabulary from Thinking Machines' [official model repository](https://huggingface.co/thinkingmachines/Inkling). Together's [launch post](https://www.together.ai/blog/together-ai-brings-thinking-machines-labs-new-model-inkling-on-day-0) currently says Inkling is live with 1M context, while its [model detail page](https://www.together.ai/models/inkling) says coming soon with 256K context and publishes no price. Until Together's active `/models` endpoint and the Models.dev catalog resolve that conflict, Inkling is not seeded into Codewhale's offline picker and no route-specific context or cost is inferred. |\n| `qianfan` | `[providers.qianfan]` | `QIANFAN_API_KEY`, `BAIDU_QIANFAN_API_KEY` | `QIANFAN_BASE_URL`, `BAIDU_QIANFAN_BASE_URL`; default `https://api.baiduqianfan.ai/v1` | `ernie-4.0-turbo-8k`; provider-scoped custom Qianfan service/model IDs pass through | Baidu Qianfan OpenAI-compatible route. Requests use Bearer auth and Chat Completions payloads. `QIANFAN_MODEL` and `BAIDU_QIANFAN_MODEL` are accepted; aliases `baidu-qianfan`, `baidu_qianfan`, and `baidu` resolve to this provider. Tool/function calling is model-scoped in Qianfan docs, so Codewhale preserves the selected wire model and leaves live capability proof to follow-up route/capability work. |\n| `openai-codex` | `[providers.openai_codex]` | Official Sign in with ChatGPT (`codewhale auth chatgpt` / `/provider setup openai-codex`) | Official `https://api.openai.com/v1` | Selected account catalog; configured model IDs remain explicit selections | **Experimental.** Public Responses endpoint (`/v1/responses`) with a validated `chatgpt.tokens.use.direct` grant. Dynamic OSS registration issues a client ID for the selected account/workspace; Codewhale protects and renews its own tokens. The account catalog supplies selectable models; public model lists and legacy Codex tokens do not establish plan permission. No silent billing fallback. See [Sign in with ChatGPT](#sign-in-with-chatgpt) for setup, usage limits, and preview boundaries. |\n| `anthropic` | `[providers.anthropic]` | `ANTHROPIC_API_KEY` | `ANTHROPIC_BASE_URL`; default `https://api.anthropic.com` | `claude-opus-4-8`, `claude-sonnet-4-6` (default), `claude-haiku-4-5` | Native Anthropic Messages API route (`/v1/messages`, `x-api-key` + `anthropic-version: 2023-06-01`) — not OpenAI-compatible. Prompt caching via `cache_control` breakpoints, adaptive thinking + `output_config.effort`, signed thinking blocks replayed verbatim, cache telemetry normalized per #2961. `ANTHROPIC_MODEL` is accepted. |\n| `openmodel` | `[providers.openmodel]` | `OPENMODEL_API_KEY` | `OPENMODEL_BASE_URL`; default `https://api.openmodel.ai` | `deepseek-v4-flash`; provider-scoped custom model IDs pass through | OpenModel Anthropic-compatible Messages route. Uses `/v1/messages`, Bearer auth, and `anthropic-version: 2023-06-01`; OpenModel selects DeepSeek, DashScope, Xiaomi, Claude, and other routes by model id. `OPENMODEL_MODEL` is accepted. |\n| `sakana` | `[providers.sakana]` | `FUGU_API_KEY`, `SAKANA_API_KEY` | `SAKANA_BASE_URL`; default `https://api.sakana.ai/v1` | `fugu` (default), `fugu-ultra-20260615` | Sakana AI Fugu OpenAI-compatible route. Standard Chat Completions wire protocol; streaming supported. `fugu-ultra-20260615` is the heavy/reasoning variant. Env var aliases: `FUGU_API_KEY` (primary), `SAKANA_API_KEY`; provider aliases: `sakana-ai`, `sakana_ai`, `fugu`. |\n| `longcat` | `[providers.longcat]` | `LONGCAT_API_KEY` | `LONGCAT_BASE_URL`; default `https://api.longcat.chat/openai/v1` | `LongCat-2.0` (default) | Meituan LongCat curated model gateway. OpenAI-compatible Chat Completions wire protocol. Sign up at https://longcat.chat/platform for an API key. Provider aliases: `long-cat`, `meituan-longcat`, `meituan`. |\n| `opencode-go` | `[providers.opencode_go]` | `OPENCODE_GO_API_KEY` | `OPENCODE_GO_BASE_URL`; default `https://opencode.ai/zen/go/v1` | `deepseek-v4-pro` (default), `grok-4.5`, `glm-5.2`, `glm-5.1`, `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.6`, `deepseek-v4-flash`, `mimo-v2.5`, `mimo-v2.5-pro` | [OpenCode Go](https://opencode.ai/docs/go/) subscription route using OpenAI-compatible Chat Completions. `OPENCODE_GO_MODEL` is accepted. Codewhale uses bare wire IDs; familiar `opencode-go/` input aliases normalize to the bare ID. Go models documented only on the Anthropic `/messages` endpoint are deliberately not advertised by this route until Codewhale supports per-model wire selection. Billing surfaces show the Go allowance instead of token-price estimates. |\n| `opencode-zen` | `[providers.opencode_zen]` | `OPENCODE_ZEN_API_KEY`, fallback `OPENCODE_API_KEY` | `OPENCODE_ZEN_BASE_URL`; default `https://opencode.ai/zen/v1` | `gpt-5.6` (default); current GPT, Claude, Qwen, DeepSeek, MiniMax, GLM, Kimi, Grok, Muse Spark, and free-model IDs | [OpenCode Zen](https://opencode.ai/docs/zen/) model-aware gateway. `OPENCODE_ZEN_MODEL` is accepted, and official `opencode/` selectors normalize to bare wire IDs. Each model's wire comes from the curated snapshot, then from the AI SDK package its [Models.dev](https://models.dev) row names: GPT, Grok, and Muse Spark rows use `/responses`; Claude and most Qwen rows use `/messages`; DeepSeek, MiniMax, GLM, Kimi, `qwen3.8-max`, and the free rows use `/chat/completions`. Responses and Chat Completions authenticate with Bearer `Authorization`, while Anthropic Messages uses `x-api-key`; none of these routes use ChatGPT/Codex OAuth guidance or headers. Gemini fails closed because its model-specific Google wire protocol is not implemented; Models.dev rows marked `deprecated` and models no loaded catalog lists also fail closed. See [OpenCode Zen protocol catalog](#opencode-zen-protocol-catalog). |\n| `meta` | `[providers.meta]` | `META_MODEL_API_KEY`, `MODEL_API_KEY` | `META_MODEL_API_BASE_URL`, `MODEL_API_BASE_URL`; default `https://api.meta.ai/v1` | `muse-spark-1.2` (default) | [Meta Model API](https://developer.meta.com/ai/resources/blog/build-with-muse-spark/) public-preview route using OpenAI-compatible Chat Completions. Muse Spark 1.2 keeps its wire ID, tool support, 1M context, 32K output metadata, and `none` through `xhigh` reasoning effort. `META_MODEL_API_MODEL` and `MODEL_API_MODEL` are accepted. Provider aliases: `meta-ai`, `meta_model_api`, `muse`, `muse-spark`. |\n| `telecomjs` | `[providers.telecomjs]` | `TELECOMJS_API_KEY` | `TELECOMJS_BASE_URL`; default `https://aigw.telecomjs.com/v1` | `deepseek-v4-pro` conservative fallback; authenticated `/models` rows when a key is configured | TelecomJS TokenHub OpenAI-compatible Chat Completions route. Live catalogs are isolated by provider and key fingerprint, stale rows survive transient refresh failures, and unsupported reasoning request fields are omitted. `TELECOMJS_MODEL` is accepted. Provider aliases: `telecom-js`, `telecom_js`, `telecomjs-cn`, `tokenhub`. |\n| `mistral` | `[providers.mistral]` | `MISTRAL_API_KEY` | `MISTRAL_BASE_URL`; default `https://api.mistral.ai/v1` | `mistral-code-latest` (default; `codestral-latest` accepted as alias), `mistral-medium-latest` (aliases: `mistral-medium-3-5`), `mistral-small-latest` (aliases: `mistral-small-2603`), `mistral-large-latest` | Mistral AI (la Plateforme) OpenAI-compatible Chat route. On the documented first-party HTTPS `/v1` hosts, Medium and Small send adjustable `reasoning_effort` (`none` or `high` only), parse Mistral's polymorphic thinking/text blocks, and replay stored thinking in that same wire shape. Deprecated native Magistral IDs remain explicit-configuration compatibility routes: they are always-reasoning and never receive the adjustable effort field. Code and Large are non-reasoning. A custom `MISTRAL_BASE_URL` keeps generic Chat semantics unless it is one of the documented first-party hosts. `MISTRAL_MODEL` is accepted. Provider aliases: `mistral-ai`, `mistralai`, `la-plateforme`. |\n| `edenai` | `[providers.edenai]` | `EDENAI_API_KEY` | `EDENAI_BASE_URL`; default `https://api.edenai.run/v3`; EU `https://api.eu.edenai.run/v3` | `deepseek/deepseek-v4-pro` (default); live `/models` catalog of `provider/model` ids | Eden AI OpenAI-compatible aggregation gateway. Catalog rows remain provider-scoped; generic reasoning controls are omitted because supported fields depend on the selected upstream family. `EDENAI_MODEL` is accepted. The default `deepseek/deepseek-v4-pro` is listed on the global catalog only; on the EU endpoint set `EDENAI_MODEL` (or `model`) to a row from the EU `/models` list, for example `qwen/deepseek-v4-pro`. Provider aliases: `eden-ai`, `eden_ai`. |\n| `zenmux` | `[providers.zenmux]` | `ZENMUX_API_KEY` | `ZENMUX_BASE_URL`; default `https://zenmux.ai/api/v1` | `deepseek/deepseek-v4.1-flash` (default); live `/models` catalog of `provider/model` ids (keyless-readable) | ZenMux OpenAI-compatible aggregation gateway (~190 models). Catalog rows remain provider-scoped; generic reasoning controls are omitted because supported fields depend on the selected upstream family. `ZENMUX_MODEL` is accepted. Provider aliases: `zen-mux`, `zen_mux`. |\n| `csdn` | `[providers.csdn]` | `CSDN_API_KEY` | `CSDN_BASE_URL`; default `https://ai.csdn.net/api/model/v1` | `glm_for_coding` (default; the Coding Plan's dedicated model id); other marketplace model ids pass through | CSDN 星图 (Starmap) OpenAI-compatible hosted platform. Coding Plan keys and general marketplace keys share the one endpoint, so billing follows the credential product, never the URL alone: routing `glm_for_coding` — the shipped default — or setting `mode = \"coding_plan\"`/`\"plan\"`/`\"subscription\"` bills as CSDN Coding Plan quota with no dollar estimates; any other model, or an explicit `pay-as-you-go`/`metered` mode, bills metered; an unrecognized mode or an endpoint off `ai.csdn.net/api/model/v1` reports `cost: unknown`. `CSDN_MODEL` is accepted. Provider aliases: `csdn-ai`, `csdn_ai`, `csdn-coding-plan`, `csdn_coding_plan`, `starmap`. |\n| `concentrate` | `[providers.concentrate]` | `CONCENTRATE_API_KEY` | `CONCENTRATE_BASE_URL`; default `https://api.concentrate.ai/v1` | `deepseek-v4-pro` (default; a plain catalog id lets the gateway pick the upstream provider); `provider/model` ids such as `openai/gpt-5.6-sol` pin one upstream; `concentrate/auto` is the gateway's own router; unauthenticated live `/v1/models` catalog | Concentrate OpenAI Responses-compatible gateway (`POST /v1/responses`, bearer Universal API key). Opt-in and BYOK only: your key, your Concentrate bill, zero Codewhale fee, no managed default. Requests carry only documented fields (the system prompt rides as a `system` input item). Contract: [API introduction](https://concentrate.ai/docs/api-reference/introduction), [request parameters](https://concentrate.ai/docs/api-reference/endpoint/request-parameters), [streaming](https://concentrate.ai/docs/api-reference/endpoint/streaming), [errors](https://concentrate.ai/docs/api-reference/endpoint/errors). See [Concentrate Notes](#concentrate-notes). |\n| `codewhale` | `[providers.codewhale]` | `CODEWHALE_API_KEY` | `CODEWHALE_API_BASE`; default `https://api.codewhale.net/v1` | `deepseek/deepseek-v4-pro` (default), `anthropic/claude-sonnet-5`, `openai/gpt-5.6` are offline bootstrap rows only; the authenticated `GET /v1/models` listing of the account's connected providers is the catalog authority | Codewhale API: account-backed model access over the provider keys the customer connected to their Codewhale account. One `cwc_key_…` account API key with the `models:infer` scope authenticates every model. Model ids are `provider/model` exactly as the account catalog returns them, and each row states its protocol (`chat-completions` → `/v1/chat/completions`, `anthropic-messages` → `/v1/messages`, `responses` → `/v1/responses`); every protocol uses `Authorization: Bearer`, never `x-api-key`. `CODEWHALE_API_BASE` must be HTTPS except on loopback. Connect provider keys with `codewhale account keys set `. Provider aliases: `codewhale-api`, `cw-api`, `codewhale-cloud`. |\n| `xai` | `[providers.xai]` | `XAI_API_KEY`, Codewhale-owned device OAuth, or explicit read-only Grok CLI consent | `XAI_BASE_URL`; default `https://api.x.ai/v1` | `grok-4.6` (default), `grok-4.7`, `grok-4.5`, `grok-4.3`, `grok-build`, `grok-composer-2.5-fast`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning` | xAI/Grok OpenAI-compatible Chat Completions route. Grok 4.6 has a 500K context window, text/image input, function calls, structured output, server-side web search, and `low`/`medium`/`high`/`xhigh` reasoning (default `high`). [Grok 4.7](https://docs.x.ai/docs/models/grok-4.7) has the same 500K window, text/image input, function calls, structured output, reasoning ladder and $2.00 / $0.50 cached / $6.00 rates; xAI documents no web-search support for it yet, so Codewhale does not claim it. Grok reasoning cannot be disabled ([reasoning guide](https://docs.x.ai/docs/guides/reasoning)). Its standard rates double when the prompt reaches 200K tokens; the same 2x long-context rule applies to `grok-4.5` (500K context, $2.00 / $0.30 cached / $6.00) and `grok-4.3` (1M context, $1.25 / $0.20 cached / $2.50) per their [model pages](https://docs.x.ai/docs/models/grok-4.5). There is no documented `latest`/`fast` alias and no published numeric output limit. **API-key** (default): Bearer token from console.x.ai via `XAI_API_KEY` / keyring / `api_key`. **OAuth**: `codewhale auth xai-device` uses SSH-friendly device login and Codewhale-owned storage, which may refresh itself. Existing Grok CLI credentials require `codewhale auth external-consent --provider xai --mode read-only`; the granted external file is never refreshed or rewritten. OAuth may return HTTP 403 on some SuperGrok tiers — keep API-key as the reliable fallback. `XAI_MODEL` is accepted. Provider aliases: `x-ai`, `x_ai`, `grok`. |\n| `modelstudio-token-plan` | `[providers.modelstudio_token_plan]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | `MODELSTUDIO_TOKEN_PLAN_BASE_URL`; default `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` | `qwen3.8-max` (default), `qwen3.8-max-preview`, `qwen3.7-plus`, `qwen3.7-max`, `qwen3.6-flash`, `deepseek-v4-pro`, `deepseek-v4-flash-0731`, `glm-5.2` | Alibaba Cloud Model Studio Token Plan OpenAI-compatible Chat Completions route. Token Plan Personal and Team share this endpoint. `qwen3.8-max`, `qwen3.7-plus`, and `qwen3.7-max` can use provider-native web search through the Token Plan Responses Harness; the preview, Coding Plan, and Anthropic routes do not inherit that capability. All listed models are reasoning-capable text/coding models. DeepSeek and GLM entries are provider-scoped and do not collide with first-party routes. `MODELSTUDIO_TOKEN_PLAN_MODEL` is accepted. Provider aliases: `modelstudio-token-plan`, `alibaba-token-plan`, `dashscope-token-plan`. |\n| `modelstudio-token-plan-anthropic` | `[providers.modelstudio_token_plan_anthropic]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | default `https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic` | Same model catalog as `modelstudio-token-plan` | Token Plan Anthropic-compatible Messages route (`/apps/anthropic`). Same API key as the OpenAI dialect. Provider aliases: `modelstudio-token-plan-anthropic`, `alibaba-token-plan-anthropic`. |\n| `modelstudio-coding-plan` | `[providers.modelstudio_coding_plan]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | `MODELSTUDIO_CODING_PLAN_BASE_URL`; default `https://coding-intl.dashscope.aliyuncs.com/v1` | `qwen3.8-max` (default); same catalog as Token Plan | Alibaba Cloud Model Studio Coding Plan OpenAI-compatible Chat Completions route. `MODELSTUDIO_CODING_PLAN_MODEL` is accepted. Provider aliases: `modelstudio-coding-plan`, `alibaba-coding-plan`, `dashscope-coding-plan`. |\n| `modelstudio-coding-plan-anthropic` | `[providers.modelstudio_coding_plan_anthropic]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | default `https://coding-intl.dashscope.aliyuncs.com/apps/anthropic` | Same model catalog as `modelstudio-coding-plan` | Coding Plan Anthropic-compatible Messages route (`/apps/anthropic`). Provider aliases: `modelstudio-coding-plan-anthropic`, `alibaba-coding-plan-anthropic`. |\n\nStepFun's four coding models are available through both its standard API and\n[Step Plan](https://platform.stepfun.ai/docs/en/step-plan/integrations/reasoning-api).\nChoose the billing route in `/provider`, then the model in `/model`; existing\nStep 3.7 selections remain unchanged. [Step 5 Preview](https://platform.stepfun.ai/docs/en/guides/models/step-5-preview)\nhas a 1M context window. Step 5 and Step 3.7 expose low/medium/high reasoning;\nStep 3.5 Flash 2603 exposes low/high; base Step 3.5 uses provider-default reasoning.\n[Published API prices](https://platform.stepfun.ai/docs/en/guides/pricing/details)\napply only to verified PAYG routes. Step Plan displays subscription allowance.\nSpeech, music and image-generation models use separate interfaces and are not\npresented as coding models. Provider video capability metadata does not imply\nthat every Codewhale client can attach video.\n\n\n### OpenCode Zen protocol catalog\n\nZen Responses and Chat Completions requests authenticate with Bearer\n`Authorization`; Zen Anthropic Messages requests use `x-api-key`. None of these\nroutes add ChatGPT/Codex OAuth headers.\n\nEach Zen model's wire comes from two sources, and neither is guessed from a\nmodel-id family (`qwen3.8-flash` is Messages while `qwen3.8-max` is Chat):\n\n1. **The curated snapshot** compiled into Codewhale, verified against the\n [official endpoint table](https://opencode.ai/docs/zen/) and the `opencode`\n provider in [Models.dev](https://models.dev). It is the offline floor, and\n it wins when a catalog row names a different wire for the same id.\n2. **The Models.dev catalog.** Its `opencode` provider is Zen's published\n catalog: each model row names the AI SDK package OpenCode itself uses, which\n Codewhale maps exactly — `@ai-sdk/openai` to Responses, `@ai-sdk/anthropic`\n to Anthropic Messages, and `@ai-sdk/openai-compatible` (the provider\n default) to Chat Completions. A Zen model released after this build routes\n once the catalog lists it, without a Codewhale release. The interactive TUI\n and `codewhale exec` both load the persisted catalog; refresh it with\n `codewhale models --update`.\n\nThe curated snapshot:\n\n- Responses: `gpt-6-astra`, `gpt-6-sol`, `gpt-6-luna`, `gpt-5.6-sol`,\n `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5`, `gpt-5.5-pro`, `gpt-5.4`,\n `gpt-5.4-pro`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.3-codex`,\n `gpt-5.3-codex-spark`, `gpt-5.2`, `gpt-5.2-codex`, `gpt-5.1`,\n `gpt-5.1-codex`, `gpt-5.1-codex-max`, `gpt-5.1-codex-mini`, `gpt-5`,\n `gpt-5-codex`, `gpt-5-nano`, `grok-4.7`, `grok-4.6`, `grok-4.5`,\n `grok-build-0.1`, `muse-spark-1.3`, `muse-spark-1.3-contributor-free`,\n `muse-spark-1.2`, `muse-spark-1.2-contributor`,\n `muse-spark-1.2-contributor-free`.\n- Anthropic Messages: `claude-fable-5-1`, `claude-fable-5`,\n `claude-opus-5-5`, `claude-opus-5`, `claude-opus-4-8`, `claude-opus-4-7`,\n `claude-opus-4-6`, `claude-opus-4-5`, `claude-sonnet-5-5`,\n `claude-sonnet-5`, `claude-sonnet-4-6`, `claude-sonnet-4-5`,\n `claude-sonnet-4`, `claude-haiku-4-5`, `qwen3.8-flash`, `qwen3.7-max`,\n `qwen3.7-plus`, `qwen3.6-plus`, `qwen3.5-plus`.\n- Chat Completions: `deepseek-v4.1-flash`, `deepseek-v4-pro`,\n `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp`, `minimax-m3`,\n `minimax-m2.7`, `minimax-m2.5`, `glm-5.3-flash`, `glm-5.3`, `glm-5.2`,\n `glm-5.1`, `glm-5`, `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.6`, `kimi-k2.5`,\n `qwen3.8-max`, `big-pickle`, `space-bunny-free`,\n `longcat-2.5-preview-free`, `mimo-v2.6-flash-free`, `mimo-v2.5-free`,\n `ling-3.0-flash-fin-free`, `north-mini-code-free`,\n `nemotron-3-ultra-free`, `nemotron-3.5-lightning-free`,\n `deepseek-v4-flash-free`.\n\nThese fail closed locally, with the reason in the error, instead of reaching\nZen: Gemini (`@ai-sdk/google`, Google's model-specific protocol, which Codewhale\ndoes not speak); a catalog row naming any other package; a catalog row Models.dev\nmarks `deprecated`; and a model neither source lists. A miss never falls back to\nanother Zen wire shape, including when a custom Zen base URL is configured.\n\n### Concentrate Notes\n\nConcentrate (`concentrate`) is an OpenAI **Responses**-compatible gateway:\n`POST https://api.concentrate.ai/v1/responses` with `Authorization: Bearer\n`. It is opt-in and **BYOK only**.\n\n- **Model ids pass through.** A plain catalog id (`gpt-5.6-sol`,\n `deepseek-v4-pro`) lets the gateway choose the upstream provider;\n `provider/model` (`openai/gpt-5.6-sol`) pins the upstream provider inside the\n gateway; `concentrate/auto` sends the gateway's own `auto` router. Codewhale's\n bare `--model auto` remains the resolver sentinel (provider default), which is\n why the gateway router has its own explicit spelling. Once streaming begins\n the gateway commits to one provider ([auto routing](https://concentrate.ai/docs/api-reference/endpoint/auto-routing)).\n- **Wire.** Fixed Responses dialect. The request carries only documented\n fields — `model`, `input`, `stream`, `max_output_tokens`, `tools`,\n `tool_choice`, `parallel_tool_calls`, `reasoning.effort` — and the system\n prompt travels as a leading `system` input item because `instructions` is\n not in the gateway's [parameter reference](https://concentrate.ai/docs/api-reference/endpoint/request-parameters).\n Streaming follows the typed `response.*` SSE events and ends on\n `response.completed` without a `[DONE]` sentinel ([streaming](https://concentrate.ai/docs/api-reference/endpoint/streaming)).\n- **Errors.** `{ \"error\", \"message\", \"model\"?, \"retry_after\"? }` with 400\n (bad request / unknown model), 401 (invalid key), 402 (insufficient\n credits), 424 (upstream provider unavailable), 429 (rate limit) —\n surfaced verbatim and classified by message ([errors](https://concentrate.ai/docs/api-reference/endpoint/errors)).\n- **Catalog.** `GET /v1/models` needs no key\n ([list models](https://concentrate.ai/docs/api-reference/endpoint/list-models)); the live rows\n are provider-scoped and unclaimed (no pricing or capability claims).\n- **Commercial boundary.** Concentrate's [Terms of Service](https://concentrate.ai/legal/terms-of-service)\n forbid reselling, white-labeling, or providing the service on a\n service-bureau basis without written consent, and its\n [Acceptable Use Policy](https://concentrate.ai/legal/acceptable-use-policy)\n forbids sharing or sublicensing keys. Codewhale therefore ships **no**\n Codewhale-owned Concentrate key, no stored customer key, no default or\n managed routing, and no markup — activation of any hosted lane is gated on\n written consent, terms, and billing approval (tracked in the Codewhale ops\n evidence checklist `concentrate-gateway-20260829/CHECKLIST.md`). Public\n pricing states \"No platform markup on tokens\".\n- **Self-test without a key.** `scripts/concentrate-selftest.sh` boots a\n local stub that speaks the documented contract and drives the real\n `codewhale exec` path through it, asserting the URL, bearer header, model\n passthrough, streaming, and the completed-turn receipt. No network call\n leaves the machine and no account is required.\n\n### Hugging Face Provider vs MCP vs Hub\n\nCodewhale's `huggingface` provider ID is only the OpenAI-compatible chat\ninference route through Hugging Face Inference Providers. It is selected with\n`/provider huggingface`, `CODEWHALE_PROVIDER=huggingface`, or\n`provider = \"huggingface\"`.\n\nHugging Face MCP is a separate external-tool route. Configure it through the\nMCP config described in `docs/MCP.md`, preferably using the settings-generated\nsnippet from . In the TUI, `/hf mcp status`\nchecks whether the Hugging Face MCP server appears in the resolved MCP config,\n`/hf mcp setup` prints the settings workflow and a placeholder-only shape, and\n`/hf concepts` explains the provider/MCP/Hub distinction.\n\nHub publishing or repository management remains explicit user action through\nHub-native tooling such as `huggingface_hub` or git. The `/hf` helper does not\nupload to Hugging Face and does not perform direct Hugging Face Hub HTTP search.\n\n### Xiaomi MiMo Notes\n\n`xiaomi-mimo` defaults to `mimo-v2.5-pro` for long-context reasoning and coding\nwork. The chat picker also exposes `mimo-v2.5-pro-ultraspeed` and the latest\nOmni model `mimo-v2.5`. Xiaomi MiMo TTS is available through\n`codewhale --provider xiaomi-mimo speech \"text\" --model tts` (or the `tts`\nalias). In Act and Operate, the provider-specific `speech` / `tts` tools are\navailable through deferred discovery when the Xiaomi MiMo route is configured.\n\n`/provider xiaomi-mimo ultraspeed` and `/provider xiaomi-mimo pro-ultraspeed`\nboth select `mimo-v2.5-pro-ultraspeed`. Speech aliases such as `tts`,\n`voice-design`, and `voice-clone` are separate from normal chat defaults.\n\nToken Plan keys default to the Singapore endpoint\n`https://token-plan-sgp.xiaomimimo.com/v1`. If your MiMo account is provisioned\nfor the China region, set `base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"`\nexplicitly in `[providers.xiaomi_mimo]` or set `mode = \"token-plan-cn\"`. Europe\nToken Plan accounts can set\n`base_url = \"https://token-plan-ams.xiaomimimo.com/v1\"` or use\n`mode = \"token-plan-ams\"`; `mode = \"pay-as-you-go\"`\nselects the standard API endpoint and standard MiMo key family. Xiaomi Token\nPlan docs and console expose credit/quota semantics, but Codewhale does not\ncurrently have a documented balance endpoint to poll, so cost display remains\nunknown rather than reusing token-price estimates from another provider.\n\nVoice-design and voice-clone shorthands map to `mimo-v2.5-tts-voicedesign` and\n`mimo-v2.5-tts-voiceclone`. Xiaomi's current\n[image-understanding guide](https://platform.xiaomimimo.com/docs/en-US/usage-guide/multimodal-understanding/image-understanding)\nincludes `mimo-v2.5` for image input. Codewhale exposes image analysis through the\nseparate `[vision_model]` / `image_analyze` path; set that model to\n`mimo-v2.5` when using MiMo for vision.\n\n### OpenRouter-Compatible Base URLs\n\nOpenRouter-compatible gateways should usually stay on the `openrouter`\nprovider with a provider-scoped `base_url` override instead of moving through\nthe generic `openai` route. That keeps OpenRouter-style reasoning, streaming,\ncache usage, and namespaced wire model parsing attached to the selected route:\n\n```toml\nprovider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"sk-...\"\nbase_url = \"https://openrouter-compatible.example/v1\"\nmodel = \"deepseek/deepseek-v4-pro\"\n```\n\nCodewhale preserves the `deepseek/` wire-model prefix under the OpenRouter\nprovider scope; it does not infer a switch to the direct DeepSeek provider from\nthat model string. Cache fields such as `prompt_cache_hit_tokens`,\n`prompt_cache_miss_tokens`, and `prompt_tokens_details.cached_tokens` are\nparsed when the upstream gateway sends them. If a key/account type omits those\nfields, Codewhale treats them as absent for that response rather than as a\ndifferent provider route.\n\nOrcaRouter (`https://api.orcarouter.ai/v1`) is a dedicated named route\n([OrcaRouter](https://www.orcarouter.ai)) that speaks the same OpenAI\nChat Completions wire protocol and serves the same namespaced\n`vendor/model` catalog. It does not need an OpenRouter-compatible\n`base_url` override: select `provider = \"orcarouter\"` and its namespaced\nwire models (for example `deepseek/deepseek-v4-pro` or its own\n`orcarouter/auto` router) pass through verbatim, exactly as they do on the\nOpenRouter provider scope.\n\n#### OrcaRouter credentials: API key or OAuth 2.0 + PKCE\n\nOrcaRouter accepts two credential sources, and both write the same durable\n`sk-orca-…` key to the same `orcarouter` secret-store slot with\n`auth_mode = \"api_key\"`:\n\n- **API key** — `codewhale auth set --provider orcarouter`, `/provider`, or\n `ORCAROUTER_API_KEY`. Use this when you already have a key.\n- **Connect with OrcaRouter** — `codewhale auth orcarouter` (CLI) or\n `/auth orcarouter` (in-session). This runs an OAuth 2.0 authorization-code\n flow with PKCE (S256) on a loopback redirect. The browser is sent to\n `GET https://www.orcarouter.ai/auth` with `callback_url`, `code_challenge`,\n `code_challenge_method=S256`, `state`, `app_name`, and `scope`; the code is\n exchanged at `POST https://www.orcarouter.ai/api/v1/auth/keys` for a\n durable API key. There is no client secret and no pre-registered redirect\n URI; state is compared in constant time before the code is used.\n\nAuthentication and inference use different origins: the consent and exchange\nlive on `https://www.orcarouter.ai`; models and chat live on\n`https://api.orcarouter.ai/v1`. Neither is derived from the other, and\n`/v1/auth/keys` on the API origin is not the exchange route. Self-hosted\ndeployments can override each origin explicitly with `ORCA_AUTH_BASE_URL`\n(auth) and `ORCA_API_BASE_URL`/`ORCAROUTER_BASE_URL` (inference); the explicit\nvalue wins. Remote origins must be HTTPS; plain HTTP is accepted only on\nloopback.\n\nA PKCE-issued key is a durable API key, **not** a refresh token: Codewhale\nstores it, reuses it across restarts, and never sends a refresh grant. Revoke\nthe key on the OrcaRouter console (`/auth orcarouter-revoke` clears the local\ncopy) and sign in again to get a new one. A 401 from the relay marks that\ncredential generation as needing re-authentication rather than silently\nretrying.\n\nThe chat model list is discovered live from `GET https://api.orcarouter.ai/v1/models`\nwith the configured key. OrcaRouter publishes `supported_endpoint_types` on\nevery row, so the chat selector keeps only rows advertising `openai`,\n`anthropic`, `gemini`, or `openai-response`, and drops image-generation,\nvideo, and rerank rows instead of guessing from a model name. A row that\nstates `architecture.input_modalities` with `image` is image-input capable;\nrows that state no architecture are treated as unknown, not as text-only.\n\n### Recent OpenRouter Large Models\n\nOpenRouter completions and static registry rows include the April 2026 onward\nlarge models verified through OpenRouter's model metadata:\n`arcee-ai/trinity-large-thinking`, `qwen/qwen3.6-flash`,\n`qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`,\n`qwen/qwen3.6-plus`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`,\n`xiaomi/mimo-v2.5`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`,\n`z-ai/glm-5.1`, `z-ai/glm-5.2`, `z-ai/glm-5-turbo`, `tencent/hy3-preview`,\n`google/gemma-4-31b-it`, `google/gemma-4-26b-a4b-it`, and\n`nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`.\n`minimax/minimax-m3` was added from OpenRouter's May 31, 2026 listing as a 1M\ncontext multimodal model for coding, tool use, and long-horizon agentic work.\n`GLM-5.3` is now the default direct Z.AI Coding Plan model; `GLM-5.2` /\n`z-ai/glm-5.2` remain available (explicit selections keep their own id),\n`GLM-5.1` / `z-ai/glm-5.1` remain available as the smaller model,\n`GLM-5.3-Flash` / `z-ai/glm-5.3-flash` is the faster/explore sibling of\n`GLM-5.3`, and `GLM-5-Turbo` / `z-ai/glm-5-turbo` remains the faster sibling\nof `GLM-5.2`.\n`GLM-5.3` / `z-ai/glm-5.3` and `GLM-5.3-Flash` / `z-ai/glm-5.3-flash` are\nfirst-class picker ids on the Z.ai and OpenRouter routes (`/model` after\n`/provider zai`, or `model = \"GLM-5.3-Flash\"`).\nLimits and reasoning options for 5.3 are inherited from\n`GLM-5.2` until Z.ai publishes distinct 5.3 metadata, and 5.3 carries no\nprice. Flash ships the published $0.15/$0.50 list. A live call can still\n429 with entitlement code 1311 on accounts that are not provisioned for 5.3.\n\n## Static Model Registry\n\nThis table records legacy selector compatibility. Current `codewhale model\nresolve`, `codewhale model list`, the picker and Runtime API use the shared\nprovider lake and route resolver. `models --update` refreshes the existing\ncatalog cache; later routes see new metadata, while dispatched requests retain\ntheir admitted limits and price receipt.\n\nThe reviewed `selections` in `crates/config/assets/catalog_corrections.json`\nremain compatibility metadata for legacy callers. They do not establish account\navailability or executable route permission. Public discovery can contain\nmetadata-only models; an authenticated provider roster establishes a different\nfact. A same-named custom endpoint keeps its own declared limits and prices.\n\n`codewhale model resolve MODEL --provider PROVIDER` reports the exact wire ID,\ncanonical ID when declared, limits and implemented capabilities. Runtime\n`GET /v1/providers/PROVIDER/models` adds source, fetch/expiry metadata, published\nprice structure, reasoning options, configured effective limits and the price\nstatus. `account_access: unverified` means these facts have not established the\nselected account's access. Context responses distinguish an active engine\nwindow from the static route's resolution source, including any fallback.\nSee [Catalog refresh](CATALOG_REFRESH.md) for freshness and estimate boundaries.\n\n| Provider | Static registry entries | Tool calls | Registry reasoning flag |\n| --- | --- | --- | --- |\n| `deepseek` | `deepseek-v4-pro`, `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp` | yes | yes |\n| `nvidia-nim` | `deepseek-ai/deepseek-v4-pro`, `deepseek-ai/deepseek-v4-flash` | yes | yes |\n| `openai` | `deepseek-v4-pro`, `deepseek-v4-flash`, `gpt-5.6`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` | yes | yes |\n| `atlascloud` | `deepseek-ai/deepseek-v4-flash`, `deepseek-ai/deepseek-v4-pro` | yes | yes |\n| `wanjie-ark` | `deepseek-reasoner` | yes | yes |\n| `volcengine` | `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash` | yes | yes |\n| `openrouter` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`, `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `minimax/minimax-m2.7`, `xiaomi/mimo-v2.5-pro`, `xiaomi/mimo-v2.5`, `qwen/qwen3.6-flash`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`, `qwen/qwen3.6-plus`, `qwen/qwen3.7-max`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`, `z-ai/glm-5.1`, `z-ai/glm-5.2`, `z-ai/glm-5.3`, `z-ai/glm-5.3-flash`, `z-ai/glm-5-turbo`, `tencent/hy3-preview`, `google/gemma-4-31b-it`, `google/gemma-4-26b-a4b-it`, `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`, `nvidia/nemotron-3-ultra-550b-a55b` | yes | yes |\n| `orcarouter` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`, `orcarouter/auto` | yes | yes |\n| `xiaomi-mimo` | `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, `mimo-v2.5`; speech/TTS IDs are selected through `codewhale speech` / `tts` | yes | yes for chat models; no for speech/TTS models |\n| `novita` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | yes | yes |\n| `fireworks` | `accounts/fireworks/models/deepseek-v4-pro` | yes | yes |\n| `siliconflow` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes |\n| `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` |\n| `moonshot` | `kimi-k2.7-code`, `kimi-k2.6` | yes | yes |\n| `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 |\n| `stepfun` | `step-3.7-flash` (default), `step-5-preview`, `step-3.5-flash`, `step-3.5-flash-2603` | yes | yes |\n| `minimax` | `MiniMax-M3`, `MiniMax-M2.7`, `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `MiniMax-M2.1`, `MiniMax-M2.1-highspeed`, `MiniMax-M2` | yes | yes |\n| `minimax-anthropic` | `MiniMax-M3`, `MiniMax-M2.7` | yes | yes |\n| `sglang` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes |\n| `vllm` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes |\n| `ollama` | live local tag; custom tags pass through when provider hint is `ollama` | yes | no |\n| `ollama-cloud` | `gpt-oss:120b`; arbitrary provider-owned model IDs pass through | yes | yes |\n| `huggingface` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | no |\n| `modelscope` | `Qwen/Qwen3.5-397B-A17B`, `Qwen/Qwen3.5-122B-A10B`, `Qwen/Qwen3.5-27B`, `Qwen/Qwen3.5-35B-A3B`, `Qwen/Qwen3.8-27B`, `Qwen/Qwen3.8-Flash-Next`, `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Pro-0813`, `deepseek-ai/DeepSeek-V4.1-Flash`, `ZhipuAI/GLM-4.7-Flash`, `ZhipuAI/GLM-5.2` | yes | no |\n| `deepinfra` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes |\n| `together` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash`, `thinkingmachines/inkling` | yes | yes |\n| `openai-codex` | `gpt-5.5` | yes | yes |\n| `anthropic` | `claude-opus-5`, `claude-opus-4-8`, `claude-sonnet-5`, `claude-sonnet-4-6`, `claude-fable-5`, `claude-haiku-4-5` | yes | yes except `claude-haiku-4-5` |\n| `openmodel` | `deepseek-v4-flash`; provider-scoped custom model IDs pass through | yes | model-dependent |\n| `sakana` | `fugu`, `fugu-ultra-20260615` | yes | yes for `fugu-ultra-20260615` |\n| `longcat` | `LongCat-2.0` | yes | yes |\n| `opencode-go` | `deepseek-v4-pro`, `grok-4.5`, `glm-5.2`, `glm-5.1`, `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.6`, `deepseek-v4-flash`, `mimo-v2.5`, `mimo-v2.5-pro` | yes | yes |\n| `meta` | `muse-spark-1.2` | yes | yes |\n| `xai` | `grok-4.6`, `grok-4.7`, `grok-4.5`, `grok-4.3`, `grok-build`, `grok-composer-2.5-fast`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning` | yes | yes for `grok-4.6`, `grok-4.7`, `grok-4.5`, `grok-4.3`, `grok-build`, and `grok-4.20-0309-reasoning` |\n| `google` | `gemini-3.1-pro-preview`, `gemini-3-pro-preview`, `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-2.5-pro`, `gemini-2.5-flash` | yes | yes except `gemini-3.5-flash-lite` |\n| `mistral` | `mistral-code-latest`, `mistral-medium-latest`, `mistral-small-latest`, `mistral-large-latest` | yes | yes for Medium and Small (`reasoning_effort` `none` or `high` on exact first-party routes); deprecated native Magistral remains an always-on explicit compatibility ID; no for Code and Large |\n| `modelstudio-token-plan`, `modelstudio-coding-plan` | `qwen3.8-max`, `qwen3.8-max-preview`, `qwen3.7-plus`, `qwen3.7-max`, `qwen3.6-flash`, `deepseek-v4-pro`, `deepseek-v4-flash-0731`, `glm-5.2` | yes | yes |\n| `csdn` | `glm_for_coding`; other marketplace model IDs pass through | yes | yes |\n\nAtlasCloud keeps the same default model as the config layer and adds\nprovider-scoped aliases for the Pro and Flash rows. Other AtlasCloud model IDs\nshould still be selected through `ATLASCLOUD_MODEL`, config, or live model\nlisting when available.\n\n## Capability Metadata\n\n`codewhale doctor --json` exposes the `capability` object. It is static\nmetadata, not a live API probe. Current fields are:\n\n`resolved_provider`, `resolved_model`, `context_window`, `max_output`,\n`thinking_supported`, `cache_telemetry_supported`, and `request_payload_mode`.\n\nWhen configuration cannot be loaded or validated, `doctor --json` exits\nnonzero and prints a bounded, secret-redacted JSON error envelope with\n`status = \"error\"` and `error.kind = \"config_validation\"` instead of emitting\nmisleading route or capability metadata.\n\nMost shipped providers use the Chat Completions request payload mode. Native\nMessages routes, including `minimax-anthropic`, use `/v1/messages`, and\n`openai-codex` uses Responses.\n\nFor OpenAI-compatible gateways or self-hosted runtimes whose real window\ndiffers from the static table, set `[providers.] context_window = N`.\nThe configured value becomes the route-effective context window for prompts,\ncontext-pressure checks, compaction, and output-cap budgeting.\n\n`max_output` is optional and truthful: it is `null` (and omitted from the\ncapability struct on the wire) when the route publishes no output maximum we\ncan stand behind — the Kimi Code membership `kimi-for-coding` family is the\ncanonical example, since the membership catalog owns their limits. An unknown\noutput ceiling is never backfilled with a placeholder, and it applies **no**\ncompatibility clamp to a turn's requested `max_tokens`; only a concrete\nroute/offering maximum narrows the request. A model the catalogue simply has no\nrow for is a different fact — absence is not permission, so an uncatalogued id\nkeeps a conservative ceiling. The \"Max output metadata\" column below reads\n`unknown` wherever no documented maximum exists.\n\nThe exact Kimi Code membership roster contains `k3`, `k3-256k`,\n`kimi-for-coding`, and `kimi-for-coding-highspeed`. The two K3 ids share the\nsame reasoning and fixed-sampling contract; `k3-256k` stays at 262,144 tokens,\nwhile bare `k3` can use an entitled 1M override.\n\n| Provider/model class | Context window | Max output metadata | Thinking support | Cache telemetry | FIM endpoint |\n| --- | --- | --- | --- | --- | --- |\n| DeepSeek V4 (`deepseek-v4-pro`, `deepseek-v4-flash`) | 1,000,000 | 384,000 | yes | yes | DeepSeek beta only, on Chat Completions routes; not on the default Flash route, which speaks Responses |\n| DeepSeek V4 Flash Vision experimental (`deepseek-v4-flash-vision-exp`) | 1,000,000 inherited from Flash | 384,000 inherited from Flash | yes, inherited | yes, inherited | not claimed; Chat Completions route only |\n| DeepSeek compatibility aliases (`deepseek-chat`, `deepseek-reasoner`) | 1,000,000 | 384,000 | yes | yes | DeepSeek beta only |\n| NVIDIA NIM V4 registry models | 1,000,000 | 384,000 | yes | yes | not documented in code |\n| Volcengine Ark V4 model IDs | 1,000,000 | 384,000 | yes | yes | not documented in code |\n| OpenRouter, Novita, Fireworks, SiliconFlow, SGLang, and vLLM V4 model IDs | 1,000,000 | 384,000 | yes | no | not documented in code |\n| Xiaomi MiMo `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, `mimo-v2.5` | 1,000,000 | 131,072 | yes | no | not documented in code |\n| OpenRouter Qwen 3.6 Flash / Plus | 1,000,000 | 65,536 | yes | no | not documented in code |\n| OpenRouter Qwen 3.6 35B / 27B | 262,144 | 262,140 | yes | no | not documented in code |\n| OpenRouter Qwen 3.6 Max Preview | 262,144 | 65,536 | yes | no | not documented in code |\n| OpenAI API `gpt-5.5` | 1,050,000 | 128,000 | yes | no | not documented in code |\n| OpenAI API `gpt-5.6`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` | 1,050,000 | 128,000 | yes | no | not documented in code |\n| Anthropic API `claude-opus-5`, `claude-opus-4-8`, `claude-sonnet-5`, `claude-sonnet-4-6`, `claude-fable-5` | 1,000,000 | 128,000 | yes | yes | not documented in code |\n| Google Gemini API `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-3.1-pro-preview`, `gemini-2.5-pro`, `gemini-2.5-flash` | 1,048,576 | 65,536 | model-dependent | no | not documented in code |\n| Meta Model API `muse-spark-1.2` | 1,000,000 | 32,000 | yes | no | not documented in code |\n| ChatGPT plan route (`openai-codex`) | conservatively budgeted; account listing does not state a window | no output cap sent in preview | model dependent | no | public `/v1/responses`; no context limit is inferred from account eligibility |\n| OpenModel default/custom model IDs | 200,000 fallback unless model metadata or config overrides it | 64,000 fallback | model-dependent | no | route uses Messages payload at `/v1/messages` |\n| Wanjie Ark `reasoner` / `r1` model IDs | 128,000 | unknown (no documented maximum) | yes | no | not documented in code |\n| Direct Arcee API `trinity-large-thinking` | 262,144 | 262,144 | yes | no | not documented in code |\n| Direct Arcee API `trinity-large-preview` | 262,144 | unknown (no documented maximum) | no in doctor capability metadata | no | not documented in code |\n| Direct Moonshot `kimi-k3` | 1,048,576 | 1,048,576 documented maximum; 131,072 provider default | yes | no | exact route uses `max_completion_tokens` and omits fixed sampling fields ([K3 quickstart](https://platform.kimi.ai/docs/guide/kimi-k3-quickstart)) |\n| Kimi Code membership `k3` | 262,144 safe baseline; 1,048,576 with an explicit entitled-plan override | 131,072 conservative default ceiling; membership maximum is not published | yes | no | exact `https://api.kimi.com/coding/v1` route |\n| Kimi Code membership `k3-256k` | 262,144 fixed | 131,072 conservative default ceiling; membership maximum is not published | yes | no | exact `https://api.kimi.com/coding/v1` route |\n| Direct Moonshot/Kimi K2.7/K2.6 (`kimi-k2.7-code`, `kimi-k2.7-code-highspeed`, `kimi-k2.6`) | 262,144 | 32,768 | yes | no | provider-reported bundled catalog |\n| Kimi Code membership `kimi-for-coding`, `kimi-for-coding-highspeed` | 262,144 | unknown — the membership catalog owns these limits and no client-side ceiling is claimed | yes | no | exact `https://api.kimi.com/coding/v1` route |\n| Direct Z.AI `GLM-5.3` (default) | 1,000,000 | 131,072 | yes | no | live on the GLM Coding Plan; limits inherited from `GLM-5.2` until Z.ai publishes distinct 5.3 numbers; no USD price |\n| Direct Z.AI `GLM-5.3-Flash` | 1,000,000 | 131,072 | yes | no | natively multimodal; $0.15/$0.50 list (2026-08-26); faster/explore sibling of `GLM-5.3` |\n| Direct Z.AI `GLM-5.2` | 1,000,000 | 131,072 | yes | no | not documented in code |\n| Direct Z.AI `GLM-5.1` | 202,752 | 131,072 | yes | no | not documented in code |\n| Direct Z.AI `GLM-5-Turbo` | 202,752 | 131,072 | yes | no | faster/explore sub-agent sibling |\n| Direct MiniMax `MiniMax-M3` | 1,000,000 | 524,288 | yes | no | not documented in code |\n| Direct MiniMax M2.x models | 204,800 | unknown until MiniMax output metadata is promoted | yes | no | not documented in code |\n| MiniMax Messages route (`MiniMax-M3`, `MiniMax-M2.7`) | model-specific values above | model-specific values above | yes | no | route uses `/anthropic/v1/messages` |\n| Generic `openai` and AtlasCloud | 128,000 | unknown (no documented maximum) | no in doctor capability metadata | no | not documented in code |\n| Ollama | 8,192 | unknown (no documented maximum) | no | no | not documented in code |\n| Hugging Face Inference Providers V4 model IDs | 131,072 | unknown (no documented maximum) | yes | no | not documented in code |\n| Other recognized DeepSeek model IDs | 128,000 unless the model name carries an explicit `Nk` hint | unknown (no documented maximum) | no unless V4/reasoner logic matches | DeepSeek/NIM only | DeepSeek beta only |\n\nMiniMax M3 uses input-length and service tiers. Codewhale omits\n`service_tier`, so requests use the standard tier and cost estimates select the\ncorrect standard rate from total input usage. Priority rates are listed to keep\nthe official tier structure visible. Prices are USD per million tokens.\n\n| Model / service tier | Input length | Input | Output | Cache read | Cache write |\n| --- | --- | ---: | ---: | ---: | ---: |\n| `MiniMax-M3` standard | up to 512,000 input tokens | $0.30 | $1.20 | $0.06 | not published |\n| `MiniMax-M3` standard | over 512,000 input tokens | $0.60 | $2.40 | $0.12 | not published |\n| `MiniMax-M3` priority | up to 512,000 input tokens | $0.45 | $1.80 | $0.09 | not published |\n| `MiniMax-M3` priority | over 512,000 input tokens | $0.90 | $3.60 | $0.18 | not published |\n| `MiniMax-M2.7` standard | all supported inputs | $0.30 | $1.20 | $0.06 | $0.375 |\n\nThese values come from the [MiniMax pay-as-you-go pricing\nguide](https://platform.minimax.io/docs/guides/pricing-paygo). M3 thinking is\nadaptive or disabled; the OpenAI-compatible API defaults to adaptive and the\nAnthropic-compatible API defaults to disabled. M2.7 thinking cannot be\ndisabled. Codewhale sends explicit controls when the user selects a reasoning\nmode.\n\nTool-call support is tracked separately by the static `ModelRegistry` and by\nthe endpoint's ability to accept OpenAI-compatible `tools` payloads. A custom\nOpenAI-compatible or local endpoint can still reject tool calls even if\nCodewhale can send the schema.\n\n### Hugging Face Inference Providers Notes\n\nThe shipped Hugging Face route targets the OpenAI-compatible Inference Providers\nrouter at `https://router.huggingface.co/v1`. Configure auth with\n`HUGGINGFACE_API_KEY` first, or `HF_TOKEN` as a fallback. Configure the endpoint\nwith `HUGGINGFACE_BASE_URL` first, or `HF_BASE_URL` as a fallback; configure the\nmodel with `HUGGINGFACE_MODEL` first, or `HF_MODEL` as a fallback.\n\nThis route does not imply Hub browsing, model-card metadata, dataset access,\nJobs, uploads, or export. Those remain explicit Model Lab work items so\nprovider auth and artifact movement stay separate.\n\n### When a Local Model Prints Tool JSON\n\nCodewhale only executes tools when the provider returns Chat Completions\n`tool_calls` or streamed `delta.tool_calls`. If a local model prints text such\nas `{\"name\":\"File\",\"arguments\":{\"action\":\"search_content\",...}}` in the\nassistant message, that is ordinary model output, not an executable tool\nrequest.\n\nFor OpenAI-compatible or local runtimes, check:\n\n- The endpoint accepts the `tools` array in `/v1/chat/completions` requests.\n- The selected model or chat template is configured for function/tool calls.\n- The server returns `tool_calls` in the response rather than plain JSON text.\n- The compatibility layer does not strip tools before forwarding the request.\n- If in doubt, test a small `File` `read` or `search_content` action against a\n known tool-calling model before debugging Codewhale's tool registry.\n\nChanging `provider`, `base_url`, or `model` can select a route that supports the\nOpenAI-compatible payload shape, but Codewhale cannot convert arbitrary JSON\ntext into a trusted tool call after the model has emitted it as prose.\n\nDeepSeek will retire `deepseek-chat` and `deepseek-reasoner` on 2026-07-24 at\n15:59 UTC. Codewhale migrates either name to `deepseek-v4-flash` before a\nrequest reaches DeepSeek's first-party OpenAI or Anthropic endpoint. If no\nreasoning tier was configured, `deepseek-chat` also migrates to `off` and\n`deepseek-reasoner` to `high`, preserving their former non-thinking / thinking\nintent; an explicit `reasoning_effort` remains authoritative. The mapping is\ndeliberately not global: Wanjie Ark, aggregators, self-hosted runtimes, and\ncustom endpoints continue to own their model ids.\n\n## Reasoning Effort\n\n`/reasoning ` (and the `reasoning_effort` config key) is translated to\neach provider's wire dialect by the client before the request is sent. `off`\ndisables thinking where the route supports it. Both exact K3 routes map `off`\nto their lowest supported tier, `low`, and the model is never switched to\nsatisfy `off` — but they do so for different reasons:\n\n- **Kimi Code membership K3** (exact `https://api.kimi.com/coding/v1` with\n `model = \"k3\"` or `model = \"k3-256k\"`) — the membership roster declares K3 always-thinking, so `off`\n cannot be honored without changing what the model is. The clamp preserves the\n fixed K3 identity.\n- **Direct Moonshot K3** (exact `https://api.moonshot.ai/v1` with\n `model = \"kimi-k3\"`) — this clamp is *defensive*, not a documented contract.\n The direct platform publishes no `off` state for K3, and Codewhale will not\n assert a fixed-thinking guarantee it cannot verify for a given key's\n entitlement, so the requested `off` is normalized to the lowest tier with the\n live entitlement left unknown.\n\nNormal dispatched\n`auto` uses Codewhale's auto-reasoning selector and sends a concrete tier;\nonly an omitted reasoning setting leaves the provider default in control.\nProviders marked \"omitted\" receive no reasoning fields at all for that tier.\n\n| Provider | `off` | `low`/`medium`/`high` | `max`/`xhigh` |\n| --- | --- | --- | --- |\n| `deepseek`, `deepseek-cn`, `siliconflow`, `siliconflow-CN`, `sglang`, `volcengine`, `atlascloud` | `thinking: {type: disabled}` | `reasoning_effort: \"high\"` + `thinking: {type: enabled}` | `reasoning_effort: \"max\"` + `thinking: {type: enabled}` |\n| `openrouter`, `novita`, other `together` models | `thinking: {type: disabled}` | `reasoning_effort` pass-through + `thinking: {type: enabled}` | `reasoning_effort: \"xhigh\"` + `thinking: {type: enabled}` |\n| `together` + `thinkingmachines/inkling` | `reasoning_effort: \"none\"` | exact `minimal`/`low`/`medium`/`high` `reasoning_effort` | `reasoning_effort: \"max\"` |\n| Direct Moonshot `kimi-k3` at exact `https://api.moonshot.ai/v1` | top-level `reasoning_effort: \"low\"` (effective normalization) | top-level `reasoning_effort: \"low\"` / `\"high\"` (`medium` becomes `high`) | top-level `reasoning_effort: \"max\"` |\n| Kimi Code membership `k3`, `k3-256k` at exact `https://api.kimi.com/coding/v1` | `thinking: {type: enabled, effort: \"low\"}` (effective normalization) | `thinking: {type: enabled, effort: \"low\" | \"high\"}` | `thinking: {type: enabled, effort: \"max\"}` |\n| Other `moonshot` routes | `thinking: {type: disabled}` | `thinking: {type: enabled}` | `thinking: {type: enabled}` |\n| `ollama` | `think: false` | `think: true` | `think: true` |\n| `ollama-cloud` | `reasoning_effort: \"none\"` | exact `low`/`medium`/`high` `reasoning_effort` | `reasoning_effort: \"max\"` |\n| `xiaomi-mimo` | `thinking: {type: disabled}` | `thinking: {type: enabled}` | `thinking: {type: enabled}` |\n| First-party `minimax` `MiniMax-M3` | `reasoning_split: true` + `thinking: {type: disabled}` | `reasoning_split: true` + `thinking: {type: adaptive}`; effective tier granularity unavailable | `reasoning_split: true` + `thinking: {type: adaptive}`; effective tier granularity unavailable |\n| First-party Z.ai `GLM-5.2` | `thinking: {type: disabled}`; no `reasoning_effort` | enabled thinking; only effective `high` adds `reasoning_effort: \"high\"` | enabled thinking + `reasoning_effort: \"max\"` |\n| First-party Z.ai `GLM-5.3` | `thinking: {type: disabled}`; no `reasoning_effort` | enabled thinking; only effective `high` adds `reasoning_effort: \"high\"` | enabled thinking + `reasoning_effort: \"max\"` |\n| First-party Z.ai `GLM-5.3-Flash` | `thinking: {type: disabled}`; no `reasoning_effort` | enabled thinking; only effective `high` adds `reasoning_effort: \"high\"` | enabled thinking + `reasoning_effort: \"max\"` |\n| First-party Z.ai `GLM-5-Turbo` | `thinking: {type: disabled}` | enabled thinking; effort granularity unavailable | enabled thinking; effort granularity unavailable |\n| Compatible gateways configured as `zai` | omitted; effective unavailable | omitted; effective unavailable | omitted; effective unavailable |\n| `nvidia-nim` | `chat_template_kwargs.thinking: false` | `chat_template_kwargs`: `thinking: true` + `reasoning_effort: \"high\"` | `chat_template_kwargs`: `thinking: true` + `reasoning_effort: \"max\"` |\n| `vllm` | `chat_template_kwargs.enable_thinking: false` | `chat_template_kwargs.enable_thinking: true` + `reasoning_effort` low/medium/high | `chat_template_kwargs.enable_thinking: true` + `reasoning_effort: \"high\"` (vLLM has no max tier) |\n| `arcee`, `huggingface` | omitted | `reasoning_effort` pass-through | `reasoning_effort: \"high\"` |\n| `fireworks` | omitted | `reasoning_effort: \"high\"` | `reasoning_effort: \"max\"` |\n| `openai`, `wanjie-ark`, `telecomjs` | omitted | omitted | omitted |\n| `openmodel` | Anthropic Messages adapter handles thinking/output configuration | Anthropic Messages adapter handles thinking/output configuration | Anthropic Messages adapter handles thinking/output configuration |\n| `openai-codex` | Responses API `reasoning` field (handled by the Responses bridge) | Responses API `reasoning` field | Responses API `reasoning` field |\n\nAtlasCloud serves DeepSeek models, so it speaks the DeepSeek reasoning dialect,\nincluding the `max` tier (#3024).\n\nOn the exact MiniMax OpenAI-compatible Chat endpoints, `MiniMax-M3` uses\n`max_completion_tokens`. Other MiniMax models and compatible gateways retain\n`max_tokens`; the MiniMax Anthropic endpoints use the separate Messages\nadapter.\n\n## Drift Check\n\nRun this before changing provider IDs, provider TOML tables, static model\nregistry rows, or provider default strings:\n\n```bash\npython3 scripts/check-provider-registry.py\n```\n\nThe check fails when:\n\n- `docs/PROVIDERS.md` omits a canonical `ProviderKind::as_str()` ID.\n- Descriptor presentation IDs or released wire tags are duplicated or drift\n from intrinsic kinds, or a second provider enum/ordinal bridge is introduced.\n- The shipped-provider table omits or adds a `[providers.*]` TOML table.\n- The static model registry table drifts from providers used by\n `crates/agent/src/lib.rs`.\n- A provider default model or base URL constant in `crates/tui/src/config.rs`\n is no longer mentioned here.\n\n## Provider Gotchas\n\nThree failures that look like something else (measured by a provider operator\non live traffic):\n\n- **A cold model looks broken.** The first call to a model the provider has not\n loaded can take 90 to 150 seconds, then drop to a second or less once warm.\n Raise `[stream].open_timeout_secs` (default 45 seconds, at most 300) before reading a timeout as \"model unavailable\".\n- **Listed is not callable.** A catalog entry can be visible and still fail\n every call. The same error text on every attempt (for example a \"resources\n busy\" 502) is not transient: a genuinely busy upstream returns successes in\n between. Probe a model before trusting it, and stop retrying one that fails\n identically.\n- **A truncated answer can report `stop`.** A reasoning model can spend the\n whole `max_tokens` allowance on reasoning. When the provider reports\n `completion_tokens` exactly equal to the `max_tokens` the request asked for\n and the finish reason is `stop`, Codewhale treats the turn as cut at the\n ceiling rather than finished (`stop_contradicts_output_ceiling` in\n `codewhale-models`); retrying the same prompt stops at the same count, so\n raise the ceiling or shorten the task instead. No inference is made when the\n provider reports no usage.\n\n## Planned, Not Shipped Yet\n\nThese items belong to the v0.8.48+ provider-abstraction milestone or related\nprovider docs work, but they are not native shipped behavior in this checkout:\n\n- A unified `Provider` trait in `codewhale-agent` that owns env precedence,\n secret resolution, base URL normalization, auth-header construction, and\n provider metadata. Those responsibilities are still split across\n `crates/config`, `crates/secrets`, and `crates/tui/src/client.rs`.\n- Hugging Face model passport metadata in the picker, including license, base\n model, context length, chat template, tool-call support, reasoning support,\n and gated/private status.\n\n## Sign in with Claude\n\nRun `codewhale auth claude` (or `/auth claude` in a running session), open the\nprinted Claude sign-in URL, and paste the complete `code#state` callback.\n`/provider setup anthropic` also offers API-key and Claude Pro / Max choices.\nThen select `anthropic` or its `claude` alias. API keys continue to use separate\nAnthropic API billing; selecting subscription sign-in never falls back to one.\n\nCodewhale stores the grant in its private credentials directory, commits the\nprovider's credential pointer atomically, and refreshes it before requests on a\nworker. Subscription tokens go only to `https://api.anthropic.com`; a custom\nendpoint is rejected. Each request keeps the existing Messages adapter, tool\nprotocol, cache prefix, and Codewhale client identity. No Claude Code credential\nfile or keychain entry is read or modified.\n\n`codewhale auth claude-revoke` (or `/auth claude-revoke`) removes the owned local\ngrant. End remote access separately in your Claude account settings. The\nsubscription route then needs another login; select API-key billing explicitly\nto use an API key. Login and inference follow Anthropic's provider-side\navailability; local checks do not\nestablish that an account is eligible or that live subscription inference works.\n\nThe callback and JSON token exchange follow the interoperable flow used by\n[pi's Anthropic OAuth adapter](https://github.com/fivewillow/badlogic-pi-mono/blob/main/packages/ai/src/utils/oauth/anthropic.ts).\n" }, { "path": "docs/MODEL_LAB.md", From 5975bd7a25490937102649c0d71e4e0edb8f9c32 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Mon, 12 Oct 2026 23:08:26 -0700 Subject: [PATCH 08/26] fix(authority): route unclassified Windows commands through normal approval Founder approved the normal approval path for a Windows command the scanner cannot classify. Keep the captured classification failure and its direct reason, but do not label it an npm-launcher kill or a hard safety gate. Auto-Review can consult its reviewer; Ask and Never keep their ordinary approval machinery. Actual unbounded Node image kills retain their existing reviewing-mode hold and Full Access still bypasses the floor. Verified on macOS against the frozen combined source slice: - Existing focused TUI tests: 81 tests run: 81 passed, 15230 skipped (79 approval tests and 2 modal/caret tests; skips are filter exclusions). - npm test: 1312 passed; 0 failed; 7 skipped. - npm run check:web: passed, including latest-release and production build. - cargo fmt --all -- --check, git diff --check, dated version and changelog synchronization checks: passed. Windows target was already installed. Cross-target check failed in ring's C build because Windows assert.h is unavailable on this Mac; this is not Windows type-check or execution proof. Hosted Windows CI remains required. No new test functions or code comments added; existing tests were updated. Refs #6963 --- crates/tui/src/core/authority/auto_review.rs | 44 ++++++++++++++++++- .../src/core/engine/tests/test_cases_09.rs | 19 ++------ 2 files changed, 46 insertions(+), 17 deletions(-) diff --git a/crates/tui/src/core/authority/auto_review.rs b/crates/tui/src/core/authority/auto_review.rs index b4aa76d172..d983c1afb6 100644 --- a/crates/tui/src/core/authority/auto_review.rs +++ b/crates/tui/src/core/authority/auto_review.rs @@ -400,7 +400,7 @@ impl SessionRuntimeRisk { "unbounded process termination can kill this or another Codewhale npm session's Node launcher; stop the owned server by PID or port instead" } Self::UnclassifiedWindowsInvocation => { - "Windows command input cannot be classified safely enough to exclude termination of Codewhale npm launchers; use a direct PID- or port-specific command" + "Windows command input could not be classified; review the command before execution" } } } @@ -651,7 +651,14 @@ fn deterministic_fallback( if ctx.approval_mode != ApprovalMode::Bypass && let Some(risk) = ctx.session_runtime_risk { - return AutoReviewDecision::safety_gate(risk.reason()); + return match risk { + SessionRuntimeRisk::WindowsNodeImageKill => { + AutoReviewDecision::safety_gate(risk.reason()) + } + SessionRuntimeRisk::UnclassifiedWindowsInvocation => { + AutoReviewDecision::new(AutoReviewAction::AskUser, risk.reason()) + } + }; } // Gate on the action, not the broad modal-styling risk bucket. Full Access @@ -2029,6 +2036,39 @@ mod tests { windows_session_runtime_risk(&nested), Some(SessionRuntimeRisk::UnclassifiedWindowsInvocation) ); + for mode in [ + ApprovalMode::Suggest, + ApprovalMode::Auto, + ApprovalMode::Never, + ] { + let mut unclassified = ctx_for( + "bash", + json!({"command": nested}), + RunOrigin::Interactive, + mode, + ); + unclassified.session_runtime_risk = + Some(SessionRuntimeRisk::UnclassifiedWindowsInvocation); + let policy = AutoReviewPolicy::default(); + let decision = policy.evaluate(&unclassified); + assert_eq!(decision.action, AutoReviewAction::AskUser); + assert!(!decision.built_in_safety_gate); + assert!(decision.reason.contains("could not be classified")); + let (plan, audit) = + crate::core::engine::auto_review_plan_decision_for_context(&policy, &unclassified); + assert_eq!(audit["decision"], "ask_user"); + if mode == ApprovalMode::Auto { + assert!(matches!( + plan, + crate::core::engine::AutoReviewPlanDecision::ConsultReviewer(_) + )); + } else { + assert!(matches!( + plan, + crate::core::engine::AutoReviewPlanDecision::NoChange + )); + } + } let ctx = ctx_for( "bash", json!({"command": "taskkill /F /IM node.exe"}), diff --git a/crates/tui/src/core/engine/tests/test_cases_09.rs b/crates/tui/src/core/engine/tests/test_cases_09.rs index 66e2c5bcab..2743c76ded 100644 --- a/crates/tui/src/core/engine/tests/test_cases_09.rs +++ b/crates/tui/src/core/engine/tests/test_cases_09.rs @@ -471,21 +471,10 @@ fn auto_review_routes_shell_commands_requiring_approval_to_reviewer() { None, ); - if cfg!(windows) && command == "cargo test & curl https://example.com" { - // Unclassified Windows input hits the built-in floor before the reviewer. - assert_eq!( - decision, - AutoReviewPlanDecision::Block( - "Built-in safety gate requires approval: Windows command input cannot be classified safely enough to exclude termination of Codewhale npm launchers; use a direct PID- or port-specific command".into() - ) - ); - assert_eq!(audit["decision"], "hold_for_review"); - } else { - assert!( - matches!(decision, AutoReviewPlanDecision::ConsultReviewer(_)), - "Auto-Review must not auto-approve {command} without reviewer judgment" - ); - } + assert!( + matches!(decision, AutoReviewPlanDecision::ConsultReviewer(_)), + "Auto-Review must not auto-approve {command} without reviewer judgment" + ); assert_ne!(audit["decision"], "allow", "unexpected allow for {command}"); } } From f9210a4428969ce4f55f4a08a3d652c6fabab5ef Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Mon, 12 Oct 2026 23:08:28 -0700 Subject: [PATCH 09/26] fix(tui): keep modal paste out of hidden history search Bracketed paste prioritized history search ahead of an open view, allowing text to change the covered query. Respect modal ownership before that path. Extend the existing modal-input check with committed CJK paste and key data; record this and the Windows approval clarification in the 0.10.2 notes. Terminal-owned IME preedit and Windows native acceptance remain unverified. Verified on macOS against the frozen combined source slice: - Existing focused TUI tests: 81 tests run: 81 passed, 15230 skipped (79 approval tests and 2 modal/caret tests; skips are filter exclusions). - npm test: 1312 passed; 0 failed; 7 skipped. - npm run check:web: passed, including latest-release and production build. - cargo fmt --all -- --check, git diff --check, dated version and changelog synchronization checks: passed. Windows target was already installed. Cross-target check failed in ring's C build because Windows assert.h is unavailable on this Mac; this is not Windows type-check or execution proof. Hosted Windows CI remains required. No new test functions or code comments added; existing tests were updated. Refs #2323, #6963 --- CHANGELOG.md | 11 +++++++++++ crates/tui/CHANGELOG.md | 11 +++++++++++ crates/tui/src/tui/ui/handlers.rs | 2 +- crates/tui/src/tui/ui/tests.rs | 11 +++++++++++ 4 files changed, 34 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a37d2af24d..d6742ac22c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.10.2] - 2026-10-13 +- Windows commands that cannot be classified use the normal approval path + with a direct explanation. Ask can request approval and Auto-Review can + consult its reviewer; an unclassified command is no longer reported as an + npm-launcher kill. Actual unbounded `node.exe` image kills keep their hold + in reviewing modes; Full Access continues to bypass that floor. Thanks + @jayanthvee for separating these cases in #6963. +- Pasting while a modal is open no longer changes a history-search query + hidden underneath it. Existing modal key ownership and hidden-caret guards + remain in place. Windows Terminal IME preedit is still controlled by the + terminal; native IME retesting remains open (#2323). + - Model lists, resolution, picker controls and Runtime facts use the same endpoint-scoped catalog facts. Cost estimates preserve complete price tiers, calculate exact decimal amounts per request, and freeze rates at dispatch. diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 6432505ee8..f7a3b19cab 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.10.2] - 2026-10-13 +- Windows commands that cannot be classified use the normal approval path + with a direct explanation. Ask can request approval and Auto-Review can + consult its reviewer; an unclassified command is no longer reported as an + npm-launcher kill. Actual unbounded `node.exe` image kills keep their hold + in reviewing modes; Full Access continues to bypass that floor. Thanks + @jayanthvee for separating these cases in #6963. +- Pasting while a modal is open no longer changes a history-search query + hidden underneath it. Existing modal key ownership and hidden-caret guards + remain in place. Windows Terminal IME preedit is still controlled by the + terminal; native IME retesting remains open (#2323). + - Model lists, resolution, picker controls and Runtime facts use the same endpoint-scoped catalog facts. Cost estimates preserve complete price tiers, calculate exact decimal amounts per request, and freeze rates at dispatch. diff --git a/crates/tui/src/tui/ui/handlers.rs b/crates/tui/src/tui/ui/handlers.rs index cc8a1354ae..35c0f79ebc 100644 --- a/crates/tui/src/tui/ui/handlers.rs +++ b/crates/tui/src/tui/ui/handlers.rs @@ -227,7 +227,7 @@ pub(crate) fn handle_bracketed_paste(app: &mut App, text: &str) { // so fast typing / IME commits / autocomplete bursts don't get // mis-classified as a paste. app.bracketed_paste_seen = true; - if app.is_history_search_active() { + if app.is_history_search_active() && app.view_stack.is_empty() { app.history_search_insert_str(text); } else if paste_text_into_provider_picker(app, text) || app.view_stack.handle_paste(text) { // Modal consumed the paste (e.g. provider picker key entry). diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 080fcd31fa..321a60b533 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -7336,6 +7336,17 @@ fn paste_burst_does_not_leak_into_composer_while_a_modal_owns_keys() { "composer must not gain leaked burst text under a modal: {:?}", app.input ); + app.start_history_search(); + let query = app.history_search_query().unwrap().to_owned(); + handle_bracketed_paste(&mut app, "中文输入"); + assert_eq!(app.history_search_query(), Some(query.as_str())); + let before = app.input.clone(); + route_key_to_view_stack( + &mut app, + KeyEvent::new(KeyCode::Char('中'), KeyModifiers::NONE), + Instant::now(), + ); + assert_eq!(app.input, before); } #[test] From 618d35f15f82f4eca8175a99863ac46370b1cec4 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Mon, 12 Oct 2026 23:08:29 -0700 Subject: [PATCH 10/26] docs(release): clarify contributor replies and vendored package provenance Record the founder-approved 0.10.2 exception for substantive contributor thanks, status, answers and questions. Keep the outside-release rule and review-workflow boundary explicit. The workspace-level control was updated separately and is not copied into the product repository. Distinguish the current vendored codewhale-ratatui 0.1.2 package from its original 0.1.0 upstream snapshot so historical hashes remain provenance. The exact vendored package passed cargo publish --dry-run --locked with --allow-dirty: the tarball built and upload was aborted by dry-run mode. No crate was uploaded. Final publication still requires the frozen candidate and founder authorization; the 29-crate dry run waits on that dependency. Evidence: npm test 1312 passed; 0 failed; 7 skipped, check:web passed; version/changelog/fmt/whitespace checks passed. No runtime source changes. --- AGENTS.md | 15 +++++++++------ vendor/codewhale-ratatui/UPSTREAM.json | 4 +++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 01bf0e7023..8cf36a0136 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,12 +88,15 @@ base prompt". Two more corollaries earned here: - **Write down what a design does not do**, beside the behaviour it owns — a short known-limitations note in the owning module. A stated limit stops the next reader from assuming a capability that was never built. -- **Agents do not comment on issues or PRs** (founder, 2026-09-22). Spend the - time on code: evidence goes in the commit message and PR body, claims go in - Linear. Do not reply to review bots or post status, "superseded", or - "for the record" notes. The one exception is closing or superseding a human - contributor's PR or issue: one sentence saying why, with the link. The PR and - issue review workflows are disabled; re-enable one only by founder decision. +- **Substantive contributor replies are authorized for the 0.10.2 release** + (founder, 2026-10-13). Thank people specifically for their reports, diagnosis, + fixes and testing; link the carrying commit or PR and distinguish candidate + work from a shipped release. Status, answers and useful questions are welcome; + repetitive progress posts and bot noise are not. Evidence stays in commits + and PR bodies; ownership stays in Linear. Outside this release, the previous + no-comment rule remains, except a one-sentence reason when closing or + superseding a human contribution. Review workflows remain disabled unless + the founder separately authorizes enabling them. - **A user feature lands with its registry row.** A new command, `[features]` flag, provider or user-visible feature adds or updates its row in `docs/features.toml` in the same change; diff --git a/vendor/codewhale-ratatui/UPSTREAM.json b/vendor/codewhale-ratatui/UPSTREAM.json index 57586e5107..1551d182e8 100644 --- a/vendor/codewhale-ratatui/UPSTREAM.json +++ b/vendor/codewhale-ratatui/UPSTREAM.json @@ -1,7 +1,9 @@ { "repository": "https://github.com/Hmbown/codewhale-ratatui", "commit": "2ad795b37782990844fd9cc2d78bac460929365a", - "version": "0.1.0", + "version": "0.1.2", + "upstream_version": "0.1.0", + "version_note": "version identifies the current vendored Cargo package; upstream_version and files retain the original snapshot provenance. Publish 0.1.2 only from the frozen Engine candidate directory, including its local patches.", "license": "MIT", "files": { "BENCHMARKS.md": "e9512637752467a627d1d15b3d3b6e23af72741ef713a243862ac600cbd6b9b7", From 535ca661cfe06fb074ca98a6d0c29dea16f8b08d Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Mon, 12 Oct 2026 23:33:26 -0700 Subject: [PATCH 11/26] docs(providers): refresh Claude sign-in reference against Pi v1.2.0 Pin the current canonical Pi adapter, distinguish login registration from Codewhale inference identity, and state experimental qualification accurately. Keep the feature's runtime behavior unchanged while its release choice remains open. Pi compatibility does not establish account eligibility or authorization. Verification on macOS, source 9fa1cca0cf plus these two documentation files: npm test: 1312 passed; 0 failed; 7 skipped. Node receipts: 101 + 19 + 413 + 7 passed; Vitest: 772 passed. npm run check:web: passed. git diff --check: passed. No Rust change, live Claude login, Windows execution or publication performed. DeepSeek via Codewhale supplied a bounded read-only review; findings were checked against source and provider documentation, not treated as acceptance. Refs #6932 --- docs/PROVIDERS.md | 19 ++++++++++++++++--- web/data/agent-docs.generated.json | 4 ++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 9c2ca5b4b2..745089c3b2 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -1446,6 +1446,9 @@ provider docs work, but they are not native shipped behavior in this checkout: ## Sign in with Claude +This is an experimental 0.10.2 candidate flow; live sign-in, inference and +provider authorization are not release-qualified. + Run `codewhale auth claude` (or `/auth claude` in a running session), open the printed Claude sign-in URL, and paste the complete `code#state` callback. `/provider setup anthropic` also offers API-key and Claude Pro / Max choices. @@ -1456,7 +1459,9 @@ Codewhale stores the grant in its private credentials directory, commits the provider's credential pointer atomically, and refreshes it before requests on a worker. Subscription tokens go only to `https://api.anthropic.com`; a custom endpoint is rejected. Each request keeps the existing Messages adapter, tool -protocol, cache prefix, and Codewhale client identity. No Claude Code credential +protocol, cache prefix, and Codewhale inference-client identity. The login +registration is a separate fact: this candidate uses Claude Code’s OAuth +client ID, not a Codewhale-issued registration. No Claude Code credential file or keychain entry is read or modified. `codewhale auth claude-revoke` (or `/auth claude-revoke`) removes the owned local @@ -1466,5 +1471,13 @@ to use an API key. Login and inference follow Anthropic's provider-side availability; local checks do not establish that an account is eligible or that live subscription inference works. -The callback and JSON token exchange follow the interoperable flow used by -[pi's Anthropic OAuth adapter](https://github.com/fivewillow/badlogic-pi-mono/blob/main/packages/ai/src/utils/oauth/anthropic.ts). +Reference refreshed on 2026-10-13: upstream Pi is now `earendil-works/pi`. +Its [v1.2.0 OAuth adapter](https://github.com/earendil-works/pi/blob/06a466935536f8c9bcacb2d5efc0fc021be1bdff/packages/ai/src/auth/oauth/anthropic.ts) +supports loopback and copy-code flows and uses `platform.claude.com` token and +copy-code endpoints; this candidate still uses `console.anthropic.com`. +Pi also uses [Claude Code inference identity](https://github.com/earendil-works/pi/blob/06a466935536f8c9bcacb2d5efc0fc021be1bdff/packages/ai/src/api/anthropic-messages.ts), +which Codewhale does not adopt. Pi's implementation is a compatibility +reference, not proof of account eligibility, billing behavior or permission +to offer the same flow. See Anthropic's current +[authentication guidance](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use) +and [Agent SDK plan guidance](https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan). diff --git a/web/data/agent-docs.generated.json b/web/data/agent-docs.generated.json index 2c3d622885..9e4ede274e 100644 --- a/web/data/agent-docs.generated.json +++ b/web/data/agent-docs.generated.json @@ -48,8 +48,8 @@ "sources": [ { "path": "docs/PROVIDERS.md", - "sha256": "c9dfba513b12ac26aaa87d84f74a3ae8e2ccbcf51fd49fa99feff0b168aaaca9", - "content": "# Provider Registry\n\n> 阅读简体中文版:[zh_hans/PROVIDERS.md](zh_hans/PROVIDERS.md)\n\nSpeech recognition, synthesis, translation and video have separate execution\npaths. See [media models](MEDIA_MODELS.md) for current support and reviewed\nupstream candidates; a chat catalogue row alone does not add a media backend.\n\nThis registry describes provider behavior that is wired into the current\nCodewhale codebase. It is intentionally conservative: shipped entries are\nlimited to provider IDs, config keys, auth paths, base URLs, model resolution,\nand capability metadata that the code already knows about.\n\nDeepSeek remains the default provider, but every entry in `ProviderKind::ALL`\nis a first-class selectable provider route. `ALL` is the catalog/picker\nsurface — one identity per vendor. Dual-wire dialect kinds (`*Anthropic`, e.g.\n`deepseek-anthropic`) and the Model Studio plan variants stay on the enum for\nserde and `provider_for_kind` but are deliberately **not** catalog rows:\na plan is `mode`/`base_url` and a dialect is `wire = openai|anthropic` on the\nprimary provider config (`crates/config/src/provider_kind.rs:221-226`). Hosted\nroutes, generic OpenAI-compatible endpoints, the OpenAI Codex/ChatGPT route,\nnative Anthropic, and local runtimes all run the same terminal harness against\nthe selected provider/model/base URL.\n\nA host reached over plain Chat Completions is an ordinary named provider,\nnot a `ProviderKind`: enum variants are reserved for distinct *wires*\n(Anthropic Messages, Codex Responses, Google thought signatures). Any such\nhost is a `[providers.]` table with a base URL, a model, and a key env\n(`docs/CONFIGURATION.md`); `/provider` and `/setup` keep a \"paste a Base URL\nand a key\" path for exactly this. Offerings come from live `GET /v1/models`\nplus the Codewhale catalog rather than a compiled roster (#5350, #6289).\n\nKnown-good hosts. These ship as bundled descriptor rows in\n`crates/config/assets/provider_descriptors.json` (compiled in by\n`crates/config/src/descriptors.rs`): each row says how to reach the host — wire,\nbase URL, key env, aliases — while model ids stay live from `GET /v1/models`\nand the Codewhale catalog; the example model is only a bootstrap hint. Verify\nagainst the vendor's own docs before trusting any value here:\n\n| Host | Base URL | Example models | API key env |\n| --- | --- | --- | --- |\n| SenseNova | `https://token.sensenova.cn/v1` | `deepseek-v4-flash` | `SENSENOVA_API_KEY` |\n| Baseten | `https://inference.baseten.co/v1` | `deepseek-ai/DeepSeek-V4-Pro` | `BASETEN_API_KEY` |\n| Groq | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | `GROQ_API_KEY` |\n| Cerebras | `https://api.cerebras.ai/v1` | `llama-3.3-70b` | `CEREBRAS_API_KEY` |\n| Command Code | `https://api.commandcode.ai/provider/v1` | `deepseek/deepseek-v4-flash` | `COMMAND_CODE_API_KEY` |\n| Alibaba Model Studio (DashScope) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `qwen3.8-flash` | `DASHSCOPE_API_KEY` |\n| AICraft | `https://aicraftapi.com/v1` | `claude-4.6-sonnet`; DeepSeek / Claude / Gemini / Qwen / GLM / MiniMax / Doubao families | `AICRAFT_API_KEY` |\n| Tsubasa | `https://api.tsubasa.sh/v1` | `tsubasa-pro`, `tsubasa-fast` (32,768-token context) | `TSUBASA_API_KEY` |\n| Cheaper Inference | `https://api.cheaperinference.com/v1` | `gpt-5.4-mini`, `claude-sonnet-5`, `gemini-3.1-pro` | `CHEAPER_INFERENCE_API_KEY` |\n| Yolo-Auto | `https://yolo-auto.com/v1` | `qwen3.8-flash`; `yolo` / `yolo-small` | `YOLO_AUTO_API_KEY` |\n\nAICraft's roster spans DeepSeek, Anthropic Claude, Google Gemini, Qwen, GLM,\nMiniMax and Doubao ids on its OpenAI-compatible endpoint. The authority is\n`GET https://aicraftapi.com/v1/models` with your key — pick a model from that\nlist, not from this table.\nTsubasa implements only `GET /v1/models` and Chat Completions. Its two public\nmodel ids share a 32,768-token context, smaller than the 128,000 tokens Codewhale assumes\nfor an unknown model, so set it on the route after saving:\n`codewhale config set providers.tsubasa.context_window 32768`.\n\nCheaper Inference is an OpenAI-compatible gateway with one key for models from\nseveral labs. Model ids are bare, such as `gpt-5.4-mini` or `claude-sonnet-5`.\nPricing varies by model and route; consult the provider’s current catalog.\nThe authority is `GET https://api.cheaperinference.com/v1/models` with your key.\nDocs: .\n\nOpenCode Zen and OpenCode Go are first-class provider routes, configured like\nany other provider below; they are not part of this table. In `/provider`,\ntype to filter the list (letters not bound to a row action); `Ctrl+T` probes the\nselected row's `/models` and records reachability only (a 2xx is not\nmodel-ready).\n\nSources to keep in sync:\n\n- `crates/config/assets/provider_descriptors.json` - built-in and compatible-host\n labels, defaults, aliases, key-env lists, config/secret slots, and credential\n guidance. `crates/config/build.rs` generates immutable typed views and the\n existing constant projections from this one data owner.\n- `crates/config/src/provider_kind.rs` and `src/lib.rs` - legacy identity serde,\n config schema, environment precedence and Rust route/auth behavior.\n- `crates/tui/src/config.rs` - captures and verifies exact configured provider\n identities and keeps provider-specific credential and route policy in Rust.\n- `crates/config/assets/catalog_corrections.json` `reviewed` - intrinsic facts,\n scoped selector aliases, completion references and pure transport metadata.\n The existing seed renderer embeds this reviewed supplement in Models.dev.\n- `crates/tui/src/provider_lake.rs` and `route_runtime.rs` - resolve current\n model facts for both CLI model commands, the picker and Runtime API. Public\n catalog facts, provider/account rosters and explicit configuration retain\n their separate ownership. `crates/agent/src/lib.rs` keeps the legacy selector\n compatibility projection; it does not decide the CLI's current route.\n- `config.example.toml` and `docs/CONFIGURATION.md` - user-facing config\n examples and environment variable reference.\n- `scripts/check-provider-registry.py` - drift check for canonical provider\n IDs, live TUI provider IDs, TOML table names, static registry rows, and\n documented defaults.\n\n## Captured Provider Identity\n\nPresentation names, labels, aliases, and historical wire tags come from\n`provider_descriptors.json`. `ProviderKind` remains the Rust-owned intrinsic\ncredential, protocol, and region distinction. A configured route captures both\nits kind and exact table key; a custom table named `openai` stays custom and\ndoes not acquire OpenAI credentials or protocol rules from its name. Case is\nsignificant for custom keys.\n\nDeepSeek China has three preserved representations: `deepseek_c_n` in released\nTUI serde wrappers, `deepseek-cn` as its captured route ID, and `deepseek_cn` as\nits config leaf. Its intrinsic kind is DeepSeek, while its table and endpoint\nstay distinct.\n\nAn old custom route without an additive provider ID can resume only with a\nverified root `base_url` migration into the active `providers.custom` table.\nThat private receipt is bound to the parsed table generation. A table-only,\nprofile-only, conflicting, or later replaced table cannot establish the\nmissing identity; an explicit empty ID is refused. Writes reuse the existing\nlocked config mutation and undo comparison, obtain the fresh migration receipt\nunder that lock, and verify the captured table before changing a leaf.\n\nIn-flight requests retain their captured identity, endpoint, and credential\ngeneration. Health lookup cannot treat an opaque credential reference as ready\nmerely because an earlier request used the same authentication class.\n\n## Provider Selection\n\nWith no saved model, no `default_text_model`, and no `CODEWHALE_MODEL` or\nprovider-specific model variable, a fresh install runs `deepseek-flash` on the\nDeepSeek provider. Precedence is the active provider's configured default, then\nits catalog default — so an explicit `default_text_model` is not silently\noverridden by whichever model the shipped catalog lists first.\n\nRefresh model catalogs without installing a new Codewhale release:\n\n```sh\ncodewhale models --update\ncodewhale models --update --provider openai\ncodewhale models --provider openai --json\n```\n\n`models --update` (also `--refresh`) updates the shared Models.dev metadata\nand calls the existing `/models` endpoint for each configured provider with\nits own credentials. `--provider ID` restricts the refresh to that exact\nprovider, including named custom endpoints. It makes no inference requests\nand never changes the saved provider or model. A command-line API key is\nconfined to the active provider; other routes are reported as skipped for\nthat invocation.\n\nPlain `models` lists the active provider's saved catalog without provider requests.\nFor ChatGPT, it validates the selected registration locally before reading\nthat account's saved roster. Successful refreshes are saved under Codewhale's\ncatalog directory and used by the model/provider pickers. Cache files are\nscoped to provider identity and endpoint; a failed refresh preserves prior\nrows. Text output reports source, last successful fetch time (Unix seconds),\nand freshness. `--update --json` adds per-source receipts and aggregate counts;\npartial failures return a nonzero exit code after writing those receipts.\nOrdinary `models --json` keeps its model-array format. Bundled/configured\nfallbacks are not proof that an account can use every listed model.\n\n### Sign in with ChatGPT\n\nThe `openai-codex` provider ID now uses OpenAI's official open-source\n[Sign in with ChatGPT flow](https://developers.openai.com/siwc/token-sharing-open-source).\nSign in and refresh the selected account's catalog:\n\n```sh\ncodewhale auth chatgpt\ncodewhale models --update --provider openai-codex\ncodewhale --provider openai-codex\n```\n\nSign-in opens your system browser, uses a loopback callback and PKCE, validates\nthe returned identity, and stores the issued registration and renewable tokens\nin Codewhale's protected credential storage. Permission to use your ChatGPT\nplan is separate from identity sign-in. A declined or missing plan grant stops\ninference; choose another provider explicitly if you want another billing path.\n`codewhale auth chatgpt-revoke` signs out of the Codewhale-owned session.\nThis experimental implementation retains one active registration. A picker\nfor multiple saved ChatGPT accounts is not implemented yet.\n\nModel discovery uses your granted bearer token at\n`GET https://api.openai.com/v1/models`; inference uses the public\n`POST https://api.openai.com/v1/responses` endpoint. The model picker keeps\nOpenAI's display labels and order and lists only entries with `visibility: list`.\nThe catalog contains no credentials and is bound to the verified issuer,\nissued client ID, and account subject. Another account or workspace never\ninherits those model choices. Missing, invalid, or older-than-one-day rosters\nare reported explicitly and provide no account entitlement evidence. Run the\nrefresh command after signing in. Neither model discovery nor ordinary listing\nstarts an inference request. Imported Codex CLI tokens and legacy process-token\nvariables cannot authorize this official plan route.\n\nEligible requests consume your ChatGPT plan or credits. ChatGPT Plus's\nfive-hour allowance is shared across apps; each app receives no separate\nallowance. The documented five-hour limit does not apply to Pro. App-specific\nlimits can also apply. Review limits and access in\n[ChatGPT usage settings](https://chatgpt.com/settings/usage). Codewhale does\nnot silently switch to an API key or another provider when a limit is reached.\n\nThis is an OpenAI preview for open-source/local apps. It does not grant access\nto ChatGPT conversation history. Requests stream with `store: false` and\n`stream: true`, while Codewhale retains its own session history and tools.\nOpenAI-hosted image generation, file search, Code Interpreter, native computer\nuse, hosted MCP/connectors, and Responses `tool_search` are unavailable on this\nroute; Codewhale's own tools use supported function/custom tool calls.\nThe ChatGPT preview requests one function call at a time.\nSee [preview limitations](https://developers.openai.com/siwc/token-sharing-open-source/preview-limitations).\nPaid or remotely hosted applications require the\n[commercial partner interest process](https://openai.com/form/sign-in-with-chatgpt-interest/);\nthis local integration is not commercial approval.\n\nThe canonical provider IDs are the entries of `ProviderKind::ALL`\n(`crates/config/src/provider_kind.rs`), in that order:\n\n`deepseek`, `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`,\n`openrouter`, `orcarouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `arcee`,\n`siliconflow-CN`, `moonshot`, `sglang`, `vllm`, `ollama`, `ollama-cloud`, `huggingface`,\n`together`, `qianfan`, `openai-codex`, `anthropic`, `openmodel`, `zai`,\n`stepfun`, `minimax`, `deepinfra`, `sakana`, `longcat`, `opencode-go`,\n`opencode-zen`, `meta`, `xai`, `mistral`, `telecomjs`, `modelstudio-token-plan`, `modelscope`,\n`google`, `edenai`, `zenmux`, `csdn`, `concentrate`, `codewhale`, and `custom`.\n\n`deepseek-anthropic` is *not* on this list — it is a wire dialect of\n`deepseek`, reached with `wire = \"anthropic\"`, not a separate route to select.\n\nUse any of these surfaces to select a provider:\n\n- CLI: `codewhale --provider `\n- TUI: `/provider ` or the provider picker\n- Env: `CODEWHALE_PROVIDER=`; `DEEPSEEK_PROVIDER=` is the legacy alias\n- Config: `provider = \"\"`\n\n`deepseek-cn`, `deepseek_china`, `deepseekcn`, and `deepseek-china` are accepted\nas legacy aliases for `deepseek`. They do not select a different official host;\nDeepSeek uses the same official API host worldwide.\n\n`deepseek_anthropic`, `deepseek-claude`, and `deepseek_claude` select\n`deepseek-anthropic`, the opt-in DeepSeek route that speaks the Anthropic\nMessages API at `https://api.deepseek.com/anthropic`. It keeps the normal\nDeepSeek API key path but uses `x-api-key` plus `anthropic-version: 2023-06-01`\ninstead of Bearer auth. If the key already lives in official DeepSeek Harness\n(`dsh`) at `$DSH_HOME/.credentials.yaml`, grant read-only access with\n`codewhale auth external-consent --provider deepseek --mode read-only`.\nCodewhale never writes that file and only reads `DEEPSEEK_API_KEY`.\n\n`huggingface`, `hugging-face`, `hugging_face`, and `hf` all select the\nHugging Face Inference Providers route. This is the OpenAI-compatible router\npath for chat/inference, not Hub browsing, model-card inspection, uploads, or\nartifact export.\n\n`telecomjs`, `telecom-js`, `telecom_js`, `telecomjs-cn`, and `tokenhub` all\nselect the TelecomJS TokenHub route. Its authenticated `/models` catalog is\nkey-scoped and remains isolated from every other provider's live snapshot.\n\nFresh shared config writes to `~/.codewhale/config.toml`. Existing\n`~/.deepseek/config.toml` files are still read for compatibility.\n\n### Legacy Antigravity tombstone\n\nAntigravity is not a Codewhale provider and cannot be selected or run. Existing\nlegacy Antigravity provider state is recognized only as a non-runnable migration\ntombstone. Run `codewhale auth clear --provider antigravity` to forget only\nCodewhale-owned legacy configuration and consent metadata. This does not sign\nout of, revoke, read, or otherwise alter any official Google or Antigravity\nsession. For Gemini, select the supported `google` provider and supply\n`GEMINI_API_KEY`.\n\n### Wire Protocol Compatibility\n\nProvider selection is explicit. A model string prefix such as\n`deepseek-ai/...`, `deepseek/...`, `qwen/...`, or `arcee-ai/...` is a\nprovider-owned wire ID or catalog namespace hint under the selected provider.\nIt is not a provider switch and must not be treated as proof that the route is\nDeepSeek, OpenRouter, or any other provider.\n\nSet the route with `provider = \"\"`, `CODEWHALE_PROVIDER=`, or\n`codewhale --provider `. Set the request model with `CODEWHALE_MODEL`, a\nprovider-specific model env var, top-level `default_text_model`, or\n`[providers.
].model`. Set the endpoint with `CODEWHALE_BASE_URL`, a\nprovider-specific base URL env var, or `[providers.
].base_url`. Set auth\nwith `codewhale auth set --provider `, `[providers.
].api_key`, or\nthe listed provider env vars.\n\n| Provider ID | TOML table | Wire protocol | Auth env vars |\n| --- | --- | --- | --- |\n| `deepseek` | `[providers.deepseek]` | Model-aware: Responses default (`deepseek-flash`); Chat Completions (`deepseek-v4-pro`) | `DEEPSEEK_API_KEY` |\n| `deepseek-anthropic` | `[providers.deepseek_anthropic]` | Anthropic Messages | `DEEPSEEK_API_KEY` |\n| `nvidia-nim` | `[providers.nvidia_nim]` | OpenAI Chat Completions | `NVIDIA_API_KEY`, `NVIDIA_NIM_API_KEY` |\n| `openai` | `[providers.openai]` | OpenAI Chat Completions | `OPENAI_API_KEY` |\n| `atlascloud` | `[providers.atlascloud]` | OpenAI Chat Completions | `ATLASCLOUD_API_KEY` |\n| `wanjie-ark` | `[providers.wanjie_ark]` | OpenAI Chat Completions | `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, `WANJIE_MAAS_API_KEY` |\n| `volcengine` | `[providers.volcengine]` | OpenAI Chat Completions | `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, `ARK_API_KEY` |\n| `openrouter` | `[providers.openrouter]` | OpenAI Chat Completions | `OPENROUTER_API_KEY` |\n| `xiaomi-mimo` | `[providers.xiaomi_mimo]` | OpenAI Chat Completions | `XIAOMI_MIMO_TOKEN_PLAN_API_KEY`, `MIMO_TOKEN_PLAN_API_KEY`, `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, `MIMO_API_KEY` |\n| `novita` | `[providers.novita]` | OpenAI Chat Completions | `NOVITA_API_KEY` |\n| `fireworks` | `[providers.fireworks]` | OpenAI Chat Completions | `FIREWORKS_API_KEY` |\n| `siliconflow` | `[providers.siliconflow]` | OpenAI Chat Completions | `SILICONFLOW_API_KEY` |\n| `arcee` | `[providers.arcee]` | OpenAI Chat Completions | `ARCEE_API_KEY` |\n| `siliconflow-CN` | `[providers.siliconflow_cn]` | OpenAI Chat Completions | `SILICONFLOW_API_KEY` |\n| `moonshot` | `[providers.moonshot]` | OpenAI Chat Completions | `MOONSHOT_API_KEY`, `KIMI_API_KEY` |\n| `sglang` | `[providers.sglang]` | OpenAI Chat Completions | `SGLANG_API_KEY` |\n| `vllm` | `[providers.vllm]` | OpenAI Chat Completions | `VLLM_API_KEY` |\n| `ollama` | `[providers.ollama]` | Local OpenAI-compatible Chat Completions | `OLLAMA_API_KEY` (optional; only for an authenticated local route) |\n| `ollama-cloud` | `[providers.ollama_cloud]` | Hosted OpenAI-compatible Chat Completions | `OLLAMA_CLOUD_API_KEY`, `OLLAMA_API_KEY` |\n| `huggingface` | `[providers.huggingface]` | OpenAI Chat Completions | `HUGGINGFACE_API_KEY`, `HF_TOKEN` |\n| `modelscope` | `[providers.modelscope]` | OpenAI Chat Completions | `MODELSCOPE_API_KEY` |\n| `together` | `[providers.together]` | OpenAI Chat Completions | `TOGETHER_API_KEY` |\n| `qianfan` | `[providers.qianfan]` | OpenAI Chat Completions | `QIANFAN_API_KEY`, `BAIDU_QIANFAN_API_KEY` |\n| `openai-codex` | `[providers.openai_codex]` | OpenAI Responses | Official Sign in with ChatGPT (`codewhale auth chatgpt`) with a validated Codewhale-owned plan grant |\n| `anthropic` | `[providers.anthropic]` | Anthropic Messages | `ANTHROPIC_API_KEY` or `codewhale auth claude` |\n| `openmodel` | `[providers.openmodel]` | Anthropic Messages | `OPENMODEL_API_KEY` |\n| `zai` | `[providers.zai]` | OpenAI Chat Completions | `ZAI_API_KEY`, `Z_AI_API_KEY` |\n| `stepfun` | `[providers.stepfun]` | OpenAI Chat Completions | `STEPFUN_API_KEY`, `STEP_API_KEY` |\n| `minimax` | `[providers.minimax]` | OpenAI Chat Completions | `MINIMAX_API_KEY` |\n| `deepinfra` | `[providers.deepinfra]` | OpenAI Chat Completions | `DEEPINFRA_API_KEY`, `DEEPINFRA_TOKEN` |\n| `sakana` | `[providers.sakana]` | OpenAI Chat Completions | `FUGU_API_KEY`, `SAKANA_API_KEY` |\n| `longcat` | `[providers.longcat]` | OpenAI Chat Completions | `LONGCAT_API_KEY` |\n| `opencode-go` | `[providers.opencode_go]` | OpenAI Chat Completions | `OPENCODE_GO_API_KEY` |\n| `opencode-zen` | `[providers.opencode_zen]` | Model-aware: OpenAI Responses, Anthropic Messages, or OpenAI Chat Completions | `OPENCODE_ZEN_API_KEY`, `OPENCODE_API_KEY` |\n| `meta` | `[providers.meta]` | OpenAI Chat Completions | `META_MODEL_API_KEY`, `MODEL_API_KEY` |\n| `telecomjs` | `[providers.telecomjs]` | OpenAI Chat Completions | `TELECOMJS_API_KEY` |\n| `xai` | `[providers.xai]` | OpenAI Chat Completions | `XAI_API_KEY` |\n| `mistral` | `[providers.mistral]` | OpenAI Chat Completions | `MISTRAL_API_KEY` |\n| `google` | `[providers.google]` | OpenAI Chat Completions (official Gemini OpenAI-compat route; captures and replays thought signatures on tool calls) | `GOOGLE_API_KEY`, `GEMINI_API_KEY` |\n| `edenai` | `[providers.edenai]` | OpenAI Chat Completions | `EDENAI_API_KEY` |\n| `zenmux` | `[providers.zenmux]` | OpenAI Chat Completions | `ZENMUX_API_KEY` |\n| `csdn` | `[providers.csdn]` | OpenAI Chat Completions | `CSDN_API_KEY` |\n| `concentrate` | `[providers.concentrate]` | OpenAI Responses (`/v1/responses`) | `CONCENTRATE_API_KEY` |\n| `codewhale` | `[providers.codewhale]` | Model-aware: OpenAI Chat Completions (`/v1/chat/completions`) or Anthropic Messages (`/v1/messages`), chosen per model by the account catalog | `CODEWHALE_API_KEY` |\n| `modelstudio-token-plan` | `[providers.modelstudio_token_plan]` | OpenAI Chat Completions | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` |\n| `modelstudio-token-plan-anthropic` | `[providers.modelstudio_token_plan_anthropic]` | Anthropic Messages | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` |\n| `modelstudio-coding-plan` | `[providers.modelstudio_coding_plan]` | OpenAI Chat Completions | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` |\n| `modelstudio-coding-plan-anthropic` | `[providers.modelstudio_coding_plan_anthropic]` | Anthropic Messages | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` |\n\nDefault base URLs and models for each route are listed in the shipped provider\ntable below. The wire protocol values above are derived from\n`crates/config/src/provider.rs`: `ChatCompletions` is the default,\n`openai-codex` overrides to `Responses`; `deepseek-anthropic`, `anthropic`, and\n`openmodel` override to `AnthropicMessages`; `opencode-zen` resolves the\nprotocol from the selected model's curated offering; and `deepseek` is\nmodel-aware — the shipped default `deepseek-flash` (and legacy\n`deepseek-v4-flash`) rides the Responses endpoint while `deepseek-v4-pro`\nstays on Chat Completions.\n\n## Auth And Env Rules\n\nFor hosted providers, `codewhale auth set --provider ` saves an API key for\nthat provider. API-key environment variables are fallback inputs after saved\nconfig and keyring credentials; an explicit process-level `--api-key` still\nwins for that launch.\n\nFor base URL and model selection, prefer:\n\n- `CODEWHALE_BASE_URL` / `CODEWHALE_MODEL` for the active provider.\n- Provider-specific base URL/model env vars when listed below.\n- `DEEPSEEK_BASE_URL`, `DEEPSEEK_MODEL`, and `DEEPSEEK_DEFAULT_TEXT_MODEL` as\n legacy aliases.\n\nNon-local `http://` base URLs are rejected unless\n`DEEPSEEK_ALLOW_INSECURE_HTTP=1` is set. Loopback HTTP URLs are allowed for\nself-hosted runtimes.\n\n## Custom DeepSeek-Compatible Endpoints\n\nMost custom DeepSeek-compatible deployments can use an existing provider ID.\nDo not create `[providers.deepseek_custom]`; the provider table names are fixed.\nInstead, choose the closest shipped route and override its endpoint/model:\n\n- DeepSeek-compatible hosted API: keep `provider = \"deepseek\"` and set\n `[providers.deepseek].base_url` plus `[providers.deepseek].model`, or launch\n with `DEEPSEEK_BASE_URL` and `DEEPSEEK_MODEL`.\n- Generic OpenAI-compatible gateway: use `provider = \"openai\"` with\n `[providers.openai].base_url` plus `[providers.openai].model`, or launch with\n `OPENAI_BASE_URL` and `OPENAI_MODEL`.\n- Multiple named OpenAI-compatible gateways, or local routes you want to pin\n from an AgentProfile, can use a custom table such as\n `[providers.lm-studio] kind = \"openai-compatible\"` and select it with\n `provider = \"lm-studio\"` or a profile `provider = \"lm-studio\"`.\n- Local OpenAI-compatible runtimes: use `provider = \"vllm\"`, `\"sglang\"`, or\n `\"ollama\"` with the matching provider-specific base URL/model values.\n\nExample user config for a DeepSeek-compatible host:\n\n```toml\nprovider = \"deepseek\"\n\n[providers.deepseek]\napi_key = \"YOUR_API_KEY\"\nbase_url = \"https://your-provider.example/v1\"\nmodel = \"deepseek-ai/DeepSeek-V4-Pro\"\n```\n\nExample user config for a generic gateway:\n\n```toml\nprovider = \"openai\"\n\n[providers.openai]\napi_key = \"YOUR_GATEWAY_API_KEY\"\nbase_url = \"https://gateway.example/v1\"\nmodel = \"your-deepseek-compatible-model\"\n```\n\nAlibaba Cloud Model Studio (Bailian / DashScope) is a first-class provider as\nof v0.9.4 with two plan profiles: Token Plan (Personal / Team) and Coding Plan.\nBoth plans expose an OpenAI-compatible Chat Completions endpoint and an\nAnthropic-compatible Messages endpoint.\n\n**Token Plan** (Personal and Team share the same AP-Southeast endpoint):\n\n```toml\nprovider = \"modelstudio-token-plan\"\n\n[providers.modelstudio_token_plan]\napi_key = \"YOUR_MODELSTUDIO_API_KEY\"\n# base_url defaults to https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1\nmodel = \"qwen3.8-max\" # or qwen3.8-max-preview | qwen3.7-plus | qwen3.7-max |\n # qwen3.6-flash | deepseek-v4-pro | deepseek-v4-flash-0731 |\n # glm-5.2\n```\n\n**Coding Plan** (separate international endpoint):\n\n```toml\nprovider = \"modelstudio-coding-plan\"\n\n[providers.modelstudio_coding_plan]\napi_key = \"YOUR_MODELSTUDIO_API_KEY\"\n# base_url defaults to https://coding-intl.dashscope.aliyuncs.com/v1\nmodel = \"qwen3.8-max\"\n```\n\n**Anthropic-compatible dialect** — both plans also expose a native Anthropic\nMessages path. Select it with the `-anthropic` provider suffix:\n\n```toml\nprovider = \"modelstudio-token-plan-anthropic\"\n\n[providers.modelstudio_token_plan_anthropic]\napi_key = \"YOUR_MODELSTUDIO_API_KEY\"\n# base_url defaults to https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic\nmodel = \"qwen3.8-max\"\n```\n\nCreate or copy a Model Studio API key from the\n[Bailian console](https://bailian.console.aliyun.com/). The API key is shared\nacross all four provider IDs above; only the base URL and wire protocol differ.\n\n**Thinking / reasoning.** Reasoning surfaces in the TUI's Thinking view on both\ndialects, per Model Studio's\n[deep-thinking docs](https://www.alibabacloud.com/help/en/model-studio/deep-thinking).\n\nOn the OpenAI-compatible routes the top-level controls are **route- and\nmodel-specific**, and Codewhale fails closed: they are sent only when the\nconfigured `base_url` is an official Alibaba Chat Completions host\n(`*.maas.aliyuncs.com/compatible-mode/v1`, including workspace-scoped hosts, or\n`coding-intl.dashscope.aliyuncs.com/v1`). A custom `base_url` on the same\nprovider ID gets `thinking`, `enable_thinking`, `preserve_thinking`, and\n`reasoning_effort` stripped, so an arbitrary OpenAI-compatible gateway is never\nhanded Alibaba's dialect. On a verified host:\n\n- **Hybrid models** (`qwen3.7-*`, `qwen3.6-*`, `deepseek-v4*`, `glm-*`,\n `kimi-k2.6*`) get `enable_thinking`: `false` for `off`, `true` otherwise.\n- **Thinking-only models** — `qwen3.8-max` (catalogued `thinking: always_on`),\n `qwen3.8-max-preview` (effort/budget options, no toggle), and\n `kimi-k2.7-code` — get **no** enable/disable switch at all. Sending one is at\n best ignored.\n- `preserve_thinking` is sent for the models documented to accept it\n (`qwen3.7-max`/`-plus`, `qwen3.6-max-preview`/`-plus`/`-flash`, `kimi-k2.6*`,\n `kimi-k2.7-code`), so the next turn keeps the assistant's trace.\n- `reasoning_effort` is sent only for the two families with a documented ladder\n — `deepseek-v4*` and `glm-5`/`5.1`/`5.2` — mapped to `high` or `max`.\n\nReasoning streams back as `delta.reasoning_content`. It is replayed to the\nprovider on later turns only for the `preserve_thinking` models above and the\nthinking-only models; `deepseek-v3.1`, `deepseek-v3.2`, and `glm-*` history\nstays stripped pending live confirmation that DashScope accepts\n`reasoning_content` in input messages. (`deepseek-v4*` replays regardless — the\nDeepSeek thinking-mode contract requires it on every provider.)\n\nOn the Anthropic-compatible routes, thinking uses the documented\n`{\"type\":\"enabled\",\"budget_tokens\":N}` / `{\"type\":\"disabled\"}` shapes from the\n[Anthropic-compatible Messages API](https://www.alibabacloud.com/help/en/model-studio/anthropic-api-messages),\nwith `budget_tokens` derived from the effort level.\n\nDeepSeek (`deepseek-v4-pro`, `deepseek-v4-flash-0731`) and GLM (`glm-5.2`)\nmodels served by Model Studio are provider-scoped and do not collide with the\nfirst-party DeepSeek or Zhipu/Z.ai routes. Model Studio publishes no `glm-5.3`\nentry, so Codewhale does not offer one on this route.\nPay-as-you-go workspace-id templating is not yet in the built-in provider; use\na custom provider entry for that plan until a follow-up adds it.\n\nPrivate gateways with broken or intercepted certificates should use\n`SSL_CERT_FILE` with a trusted CA bundle. The legacy\n`insecure_skip_tls_verify = true` key is still parsed so `codewhale doctor` can\nreport stale configs, but provider clients reject it instead of skipping TLS\ncertificate verification.\n\nKeep `provider`, `api_key`, and `base_url` in user config or process\nenvironment. Project-local config overlays intentionally cannot set those keys,\nso a repository cannot silently redirect prompts or credentials to another\nendpoint.\n\n## Local Models (DS4, Ollama, vLLM, SGLang)\n\nSelf-hosted OpenAI-compatible runtimes are first-class routes and are keyless\nby default — set an API key only when your server requires one. Start your\nrunner, then point Codewhale at it with `--provider` / `/provider` or a config\ntable.\n\n| Runner | Default base URL | Default model | Base URL override |\n| --- | --- | --- | --- |\n| `ollama` | `http://localhost:11434/v1` | live tag from `GET /v1/models` (pre-refresh: `unknown`) | `OLLAMA_BASE_URL` |\n| `vllm` | `http://localhost:8000/v1` | `deepseek-ai/DeepSeek-V4-Pro` | `VLLM_BASE_URL` |\n| `sglang` | `http://localhost:30000/v1` | `deepseek-ai/DeepSeek-V4-Pro` | `SGLANG_BASE_URL` |\n\n### DS4 (DwarfStar)\n\n[DS4](https://github.com/antirez/ds4/tree/84cc882352757baf628a1776badf7cc54d584e28)\nserves DeepSeek V4 Flash and Pro locally\nthrough an OpenAI-compatible API. Start DS4, then open Codewhale's prefilled,\nkeyless setup form:\n\n```bash\n./ds4-server --ctx 100000 --kv-disk-dir /tmp/ds4-kv --kv-disk-space-mb 8192\ncodewhale\n# In Codewhale: /setup provider ds4\n```\n\nReview the prefilled route and press Enter to save it. The preset budgets a\n100,000-token context to match that starter command and defaults to the Flash\ncompatibility alias. Check the local route explicitly with\n`codewhale doctor --probe-local`.\n\nDS4 loads the actual GGUF when the server starts. Its `deepseek-v4-flash` and\n`deepseek-v4-pro` API ids are compatibility aliases; changing `/model` does\nnot swap the resident model. To run Pro, download the supported Pro weights\nand start `ds4-server -m ...` as described by DS4. Update\n`context_window` whenever the server's `--ctx` value changes.\n\nThe equivalent config is:\n\n```toml\nprovider = \"ds4\"\n\n[providers.ds4]\nkind = \"openai-compatible\"\nbase_url = \"http://127.0.0.1:8000/v1\"\nmodel = \"deepseek-v4-flash\"\nauth_mode = \"none\"\ncontext_window = 100000\n```\n\nCodewhale reuses its existing OpenAI-compatible transport and DeepSeek\nreasoning/tool-call shaping for DS4. It does not invent an API key, confuse an\nAPI alias with the loaded GGUF, or silently switch to a hosted DeepSeek route.\nThe pinned DS4 [agent-client contract](https://github.com/antirez/ds4/blob/84cc882352757baf628a1776badf7cc54d584e28/README.md#agent-client-usage)\ndocuments Chat Completions at `/v1`, DeepSeek thinking replay, streamed usage,\n`max_tokens`, and no strict-tool mode; Codewhale follows those exact route\nfacts instead of inheriting unsupported capabilities from a generic gateway.\nThe primary sources for model-facing behavior are DeepSeek's official\n[thinking-mode](https://api-docs.deepseek.com/guides/thinking_mode),\n[tool-call](https://api-docs.deepseek.com/guides/tool_calls), and\n[Chat Completion](https://api-docs.deepseek.com/api/create-chat-completion)\ncontracts. The pinned\n[DeepSeek Harness adapter](https://github.com/deepseek-ai/deepseek-harness/blob/47f943859bef60e4160492346772ded9b24f765a/packages/llm/llm-deepseek/README.md)\nis only a secondary implementation cross-check; it is not the API contract.\n\n### Ollama\n\n```bash\nollama serve # if not already running\nollama pull # e.g. deepseek-v4-flash, or any tag you prefer\ncodewhale --provider ollama --model \n```\n\nProvider-hinted model names are sent as-is, so `--model qwen3:8b` works with\nany tag Ollama has pulled.\n\n### Ollama Cloud\n\nOllama Cloud is a separate hosted provider. It uses the authenticated\nOpenAI-compatible `/v1/chat/completions` route and defaults to `gpt-oss:120b`:\n\n```toml\nprovider = \"ollama-cloud\"\n\n[providers.ollama_cloud]\nbase_url = \"https://ollama.com/v1\"\nmodel = \"gpt-oss:120b\"\n```\n\nCreate a key in [Ollama account settings](https://ollama.com/settings/keys),\nthen run `codewhale auth set --provider ollama-cloud`. For ambient auth,\n`OLLAMA_CLOUD_API_KEY` wins over Ollama's official `OLLAMA_API_KEY`.\n`OLLAMA_CLOUD_BASE_URL` and `OLLAMA_CLOUD_MODEL` override the Cloud defaults;\narbitrary provider-owned model IDs pass through unchanged. Local `ollama`\nremains a separate, keyless-by-default provider.\n\nCompatibility is read-only and in memory: a released config that selected\n`provider = \"ollama\"` with the exact normalized\n`[providers.ollama] base_url = \"https://ollama.com/v1\"` tuple is treated as\n`ollama-cloud` at runtime. Only that exact tuple may fall back to the legacy\n`ollama` secret slot. Codewhale does not rewrite the config, copy or delete a\nsecret, migrate neighboring paths, or make an explicit `ollama-cloud` route\nconsume the legacy slot.\n\n### vLLM\n\n```bash\nvllm serve --port 8000\n# or: python -m vllm.entrypoints.openai.api_server --model --port 8000\ncodewhale --provider vllm --model \n```\n\nvLLM's OpenAI-compatible server listens on port 8000 by default, matching\nCodewhale's `VLLM_BASE_URL`.\n\n### SGLang\n\n```bash\npython -m sglang.launch_server --model-path --port 30000\ncodewhale --provider sglang --model \n```\n\nSGLang's default port 30000 matches Codewhale's `SGLANG_BASE_URL`.\n\n### Pinning a local route in config\n\n```toml\nprovider = \"ollama\" # or \"vllm\" / \"sglang\"\n\n[providers.ollama]\nmodel = \"qwen3:8b\" # default is deepseek-v4-flash\n# base_url defaults to http://localhost:11434/v1\n```\n\nLocal models that print tool-call JSON without the wire markers: see\n[When a Local Model Prints Tool JSON](#when-a-local-model-prints-tool-json).\n\n## Credential Links\n\nProvider setup surfaces use the same typed credential metadata as onboarding,\n`/provider`, `/links`, setup receipts, and doctor output. A missing URL is\nintentional: local, OAuth-only, and user-defined routes show their supported\nconfiguration path instead of guessing a vendor page.\n\n| Provider ID | Credential or console link |\n| --- | --- |\n| `deepseek`, `deepseek-anthropic` | [DeepSeek API keys](https://platform.deepseek.com/api_keys) |\n| `nvidia-nim` | [NVIDIA NIM API keys](https://build.nvidia.com/settings/api-keys) |\n| `openai` | [OpenAI API keys](https://platform.openai.com/api-keys) |\n| `atlascloud` | [Atlas Cloud API keys](https://atlascloud.ai/docs/en/api-keys) |\n| `wanjie-ark` | [Wanjie MaaS APIKEY docs](https://docs.wanjiedata.com/maas/maas-openapi-v1.html) |\n| `volcengine` | [Volcengine Ark API keys](https://console.volcengine.com/ark/apiKey) |\n| `openrouter` | [OpenRouter keys](https://openrouter.ai/settings/keys) |\n| `xiaomi-mimo` | [Xiaomi MiMo Token Plan](https://platform.xiaomimimo.com/token-plan) |\n| `novita` | [Novita key management](https://novita.ai/en/settings/key-management) |\n| `fireworks` | [Fireworks API keys](https://fireworks.ai/api-keys) |\n| `siliconflow` | [SiliconFlow global API keys](https://cloud.siliconflow.com/account/ak) |\n| `siliconflow-CN` | [SiliconFlow China API keys](https://cloud.siliconflow.cn/account/ak) |\n| `arcee` | [Arcee API key guide](https://docs.arcee.ai/other/create-your-first-api-key) |\n| `moonshot` | [Kimi API platform keys](https://platform.kimi.ai/console/api-keys) or [Kimi Code membership console](https://www.kimi.com/code/console) |\n| `zai` | [Z.ai model API](https://z.ai/model-api) |\n| `stepfun` | [StepFun Open Platform](https://platform.stepfun.ai/) |\n| `minimax`, `minimax-anthropic` | [MiniMax interface keys](https://platform.minimax.io/user-center/basic-information/interface-key) |\n| `huggingface` | [Hugging Face tokens](https://huggingface.co/settings/tokens) |\n| `modelscope` | [ModelScope API Keys](https://modelscope.cn/my/settings/token) |\n| `deepinfra` | [DeepInfra API keys](https://deepinfra.com/dash/api_keys) |\n| `together` | [Together API keys](https://api.together.ai/settings/api-keys) |\n| `qianfan` | [Baidu Cloud access keys](https://console.bce.baidu.com/iam/#/iam/accesslist) |\n| `anthropic` | [Anthropic API keys](https://console.anthropic.com/settings/keys) |\n| `openmodel` | [OpenModel console](https://console.openmodel.ai/) ([authentication guide](https://docs.openmodel.ai/en/docs/getting-started/authentication)) |\n| `openai-codex` | Official Sign in with ChatGPT via `codewhale auth chatgpt` (eligible ChatGPT plan or credits, Codewhale-owned tokens). The `openai` API-key route has separate billing. Legacy imported Codex credentials do not authorize this route. |\n| `sglang`, `vllm` | Local OpenAI-compatible endpoints are keyless by default; configure a key only when the server requires one. |\n| `ollama` | Local Ollama is keyless by default; configure a key only when the local server requires one. |\n| `ollama-cloud` | Create an [Ollama API key](https://ollama.com/settings/keys), save it with `codewhale auth set --provider ollama-cloud`, or set `OLLAMA_CLOUD_API_KEY` / `OLLAMA_API_KEY` in that precedence order. |\n| `sakana` | [Sakana AI API keys](https://console.sakana.ai/api-keys) ([get started](https://console.sakana.ai/get-started)) |\n| `longcat` | [Meituan LongCat platform](https://longcat.chat/platform) |\n| `opencode-go` | [OpenCode Go](https://opencode.ai/docs/go/) |\n| `opencode-zen` | [OpenCode Zen](https://opencode.ai/docs/zen/) |\n| `meta` | [Meta Model API](https://developer.meta.com/ai/) |\n| `telecomjs` | [TelecomJS TokenHub](https://aigw.telecomjs.com/) |\n| `xai` | [xAI Console](https://console.x.ai/) for an API key, Codewhale-owned device login, or explicitly consented read-only Grok CLI credentials. |\n| `mistral` | [Mistral Console (la Plateforme)](https://console.mistral.ai/api-keys) |\n| `google` | [Google AI Studio](https://aistudio.google.com/apikey) — Codewhale uses the official Gemini OpenAI-compatible endpoint and never reads Google OAuth files. |\n| `edenai` | [Eden AI API keys](https://app.edenai.run/settings/api-keys) |\n| `zenmux` | [ZenMux API keys](https://zenmux.ai/platform/pay-as-you-go) |\n| `csdn` | [CSDN 星图 console](https://ai.csdn.net/workbench/api-key) — choose the Coding Plan key type for the `glm_for_coding` plan route; a general key bills metered. Docs: [Coding Plan](https://ai.csdn.net/coding-plan). |\n| `concentrate` | [Concentrate dashboard](https://concentrate.ai/) → API Keys → Create API Key (a Universal API key); docs: [API introduction](https://concentrate.ai/docs/api-reference/introduction). BYOK only — the key stays in the local secret store. |\n| `modelstudio-token-plan`, `modelstudio-token-plan-anthropic`, `modelstudio-coding-plan`, `modelstudio-coding-plan-anthropic` | [Alibaba Cloud Model Studio (Bailian console)](https://bailian.console.aliyun.com/) — create or copy a Model Studio API key. |\n| `codewhale` | [Codewhale account settings](https://app.codewhale.net/settings?section=api) — create an API key with the `models:infer` scope, or run `codewhale account api-keys create --name --use`. |\n| `custom` | Set the named provider's `base_url` and `api_key_env` or `api_key`; no canonical vendor credential page exists. |\n\nFor Kimi, the official [quickstart](https://platform.kimi.ai/docs/overview)\ndirects users to sign in, open **API Keys**, create and copy a key, and keep it\nsecret. Codewhale links straight to that console and accepts the copied key.\nIt never probes or impersonates `kimi_cli`/`kimi_code_cli`; first-class Kimi\nOAuth remains blocked on a vendor-registered Codewhale identity.\n\n### Subscription sign-in accounts (ChatGPT, xAI)\n\nCodewhale keeps one sign-in of its own per subscription provider. It is\nseparate from the browser, the ChatGPT or Grok apps, and the Codex or Grok\nCLIs, so those can be signed in to a different account. A successful login\nreplaces the previous owned grant; there is no picker for multiple saved\nChatGPT accounts.\n\n- **See which account is signed in.** `codewhale auth status --provider\n openai-codex` (or `--provider xai`) and `codewhale auth list` show an account\n label when the ID token contains an email. ChatGPT plan and workspace\n details appear only when those claims are present; a workspace label uses\n the first characters of its account ID. The label is display metadata, not\n proof of plan entitlement. A valid sign-in may have no email label. Status\n reads local credential state without contacting the issuer: an expired\n access token with a stored refresh token remains structurally usable, while\n missing or invalid storage, or an expired token without a refresh token,\n is reported unusable. The provider picker shows the label of the sign-in\n the route would use (for xAI this includes a consented Grok CLI import).\n Successful login reports the account when known and whether it replaced\n an earlier sign-in, in the TUI's selected language. No token is printed.\n- **Reauthorize ChatGPT.** Run `/auth chatgpt` inside Codewhale to update the\n running session, or `codewhale auth chatgpt` from a shell and restart open\n Codewhale sessions. Ordinary login reuses the selected account/workspace's\n issued client ID and checks that the returned account is the same. The\n browser opens with `prompt=login`; `CODEWHALE_CHATGPT_OAUTH_NO_PROMPT=1`\n omits that parameter if the issuer refuses it.\n- **Replace the ChatGPT account or workspace.** From a shell, run\n `CODEWHALE_CHATGPT_NEW_ACCOUNT=1 codewhale auth chatgpt`, then refresh its\n model catalog and restart open Codewhale sessions. If the browser holds the\n wrong account, sign out there or open the printed URL in a private window.\n Only a fully validated new grant replaces the active owned sign-in.\n- **Switch xAI accounts.** Run `/auth xai-device` inside Codewhale, or\n `codewhale auth xai-device` from a shell and restart open sessions. The\n device page approves with whichever account the browser holds; sign out\n there or use a private window to approve another account. The login names\n the account it replaced, or says it is the same account as before.\n- **Usage limits.** ChatGPT plan requests can fail with\n `subscription_sharing_usage_limit_exceeded` or\n `subscription_sharing_usage_unavailable`. These errors, and subscription\n quota errors from other providers, name the account that made the request\n when its label is known and offer recovery guidance. Codewhale does not\n retry these terminal errors or silently choose another billing route. An\n xAI route set to OAuth that fell back to an API key gets no sign-in\n guidance, since no sign-in was used.\n- **ChatGPT credential authority.** The official ChatGPT plan route uses\n only its selected, verified Codewhale-owned grant.\n `OPENAI_CODEX_ACCESS_TOKEN`, `CODEX_ACCESS_TOKEN`, and consented Codex CLI\n tokens cannot override or authorize this route.\n\n### External CLI credential consent\n\nCredential files owned by another CLI are disabled by default. Without an\nexplicit grant, provider discovery, setup, routing, `auth status`, and doctor\ndo not stat, read, refresh, contact an identity provider for, or rewrite Codex,\nGrok, Kimi, or future external credential files.\n\nCodewhale currently supports exact-path, provider-scoped **read-only** grants\nfor the Codex CLI and Grok CLI. Codex grants remain available for legacy\ncredential inspection only; they cannot authorize the official ChatGPT plan\nroute. Use `codewhale auth chatgpt` for that route.\n\n```bash\n# Legacy Codex credential inspection; does not authorize ChatGPT plan requests.\ncodex login\ncodewhale auth external-consent --provider openai-codex --mode read-only\n\ngrok login\ncodewhale auth external-consent --provider xai --mode read-only\n\ncodewhale auth status --provider openai-codex\ncodewhale auth external-revoke --provider openai-codex\n```\n\nPass `--path /absolute/path/to/auth.json` when the external CLI uses a custom\nlocation. Consent persists the provider, external owner, exact absolute path,\nand consent schema version. Later environment-variable changes do not redirect\nthat authority to a different file. Read-only grants never refresh, contact an\nidentity/discovery service, or rewrite the external file. Supported routes such\nas xAI may use the external token after explicit selection. The official ChatGPT\nplan route ignores imported Codex tokens and requires its own verified grant.\nAn expired external token fails with login guidance. Doctor reports structural\nconsent/config state without\nopening credential files and is always non-mutating.\n\n`managed` is reserved for a future provider-specific preservation adapter.\nv0.9.1 rejects it before file or network I/O because no reviewed adapter can\nyet preserve every unknown external schema field safely. Codewhale-started xAI\ndevice login instead atomically activates a Codewhale-owned generation named\n`$CODEWHALE_HOME/credentials/xai-auth-.json`, stores only that\nvalidated basename in config, and revokes any Grok-file grant. Superseded\ngenerations are cleaned only after the new config pointer commits.\nKimi remains API-key-only; external consent for Kimi is rejected.\n\nThe official DeepSeek Harness (`dsh`) is a third read-only credential owner:\n`codewhale auth external-consent --provider deepseek --mode read-only` grants\nexact-path read access to `DEEPSEEK_API_KEY` in `$DSH_HOME/.credentials.yaml`\n(or `~/.dsh/.credentials.yaml`), which Codewhale never writes, refreshes, or\nloads into the process environment. This is separate from the DSH *harness*\nintegration (`codewhale integrations dsh …`, see\n[INTEGRATIONS_DSH.md](INTEGRATIONS_DSH.md)), which never touches credentials\nin either direction: it pins Codewhale's route identity into a `--patch`\noverlay and lets DSH resolve its own keys.\n\n## Shipped Providers\n\n| Provider ID | TOML table | Auth env | Base URL env and default | Default or static models | Notes |\n| --- | --- | --- | --- | --- | --- |\n| `deepseek` | `[providers.deepseek]` | `DEEPSEEK_API_KEY` | `CODEWHALE_BASE_URL` / `DEEPSEEK_BASE_URL`; default `https://api.deepseek.com/beta` | `deepseek-flash` (shipped default; V4.1 Flash, unversioned id), `deepseek-v4-pro`, `deepseek-v4-flash`, experimental `deepseek-v4-flash-vision-exp`; vision aliases `flash-vision`, `deepseek-v4flashvisionexp`; compatibility aliases `deepseek-chat`, `deepseek-reasoner` | First-class default. The live Pro backend is labeled `DeepSeek-V4-Pro-0813`; the callable API ID remains `deepseek-v4-pro`. Beta URL enables strict tool mode, chat prefix completion, and FIM completion. The documented V4 routes can use provider-native web search through a separate bounded Responses request; compatible custom endpoints do not inherit that capability. Set `https://api.deepseek.com` or `/v1` explicitly to opt out of beta-only features. The shipped default `deepseek-flash` speaks the Responses API (DeepSeek's documented path for Codex-style integration, since the 2026-07-31 Flash production update); the Chat-only controls (the `thinking` toggle and strict-tool `/beta` routing) apply to Chat Completions routes, and `deepseek-v4-pro` stays on Chat Completions until its announced Responses rollout. Reasoning effort follows DeepSeek's documented requested-to-actual mapping: `minimal`/`low` land on `low`, `medium`/`xhigh` on `high`, and `max`/`ultra` on `max`; `off` disables thinking (`thinking: {\"type\":\"disabled\"}` on Chat, `reasoning.effort: \"none\"` on Responses). The experimental vision ID was observed in the authenticated `/models` roster on 2026-08-21 and is advertised as image-input capable on the direct Chat Completions route only. Its limits, reasoning, and tool-call flags provisionally inherit Flash; pricing remains unknown, and no funded image round trip was made during this release work. |\n| `deepseek-anthropic` | `[providers.deepseek_anthropic]` | `DEEPSEEK_API_KEY` | `DEEPSEEK_ANTHROPIC_BASE_URL`; default `https://api.deepseek.com/anthropic` | `deepseek-v4-pro`, `deepseek-v4-flash`; compatibility aliases `deepseek-chat`, `deepseek-reasoner` | Opt-in DeepSeek route for the Anthropic Messages wire protocol. Uses `/v1/messages`, `x-api-key`, and `anthropic-version: 2023-06-01`. Keep `provider = \"deepseek\"` for the default Chat Completions path. |\n| `nvidia-nim` | `[providers.nvidia_nim]` | `NVIDIA_API_KEY`, `NVIDIA_NIM_API_KEY` | `NVIDIA_NIM_BASE_URL`, `NIM_BASE_URL`, `NVIDIA_BASE_URL`; default `https://integrate.api.nvidia.com/v1` | `deepseek-ai/deepseek-v4-pro`, `deepseek-ai/deepseek-v4-flash` | Hosted DeepSeek V4 through NVIDIA NIM. `NVIDIA_NIM_MODEL` is accepted by the TUI config path. |\n| `openai` | `[providers.openai]` | `OPENAI_API_KEY` | `OPENAI_BASE_URL`; default `https://api.openai.com/v1` | `gpt-5.6` (default), `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` | Generic OpenAI-compatible route whose built-in endpoint and fallback catalog are native to OpenAI. The [GPT-5.6 family](https://developers.openai.com/api/docs/models/gpt-5.6-sol) uses OpenAI's documented 1.05M context, 128K max output, and reasoning levels. Custom gateways remain free to select an explicit gateway-owned model. `OPENAI_MODEL` is accepted. |\n| `atlascloud` | `[providers.atlascloud]` | `ATLASCLOUD_API_KEY` | `ATLASCLOUD_BASE_URL`; default `https://api.atlascloud.ai/v1` | Default `deepseek-ai/deepseek-v4-flash`; explicit `vendor/model-id` values pass through when AtlasCloud is selected | OpenAI-compatible hosted route. `ATLASCLOUD_MODEL` is accepted by the TUI config path, the static `ModelRegistry` keeps DeepSeek V4 fallback rows, and provider-hinted CLI model IDs are sent to AtlasCloud exactly as requested. Use Atlas Cloud's own catalog or Coding Plan page for the current provider-owned model list and pricing. |\n| `wanjie-ark` | `[providers.wanjie_ark]` | `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, `WANJIE_MAAS_API_KEY` | `WANJIE_ARK_BASE_URL`, `WANJIE_BASE_URL`, `WANJIE_MAAS_BASE_URL`; default `https://maas-openapi.wanjiedata.com/api/v1` | `deepseek-reasoner` | OpenAI-compatible hosted route. `WANJIE_ARK_MODEL`, `WANJIE_MODEL`, and `WANJIE_MAAS_MODEL` are accepted. |\n| `volcengine` | `[providers.volcengine]` | `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, `ARK_API_KEY` | `VOLCENGINE_BASE_URL`, `VOLCENGINE_ARK_BASE_URL`, `ARK_BASE_URL`; default `https://ark.cn-beijing.volces.com/api/coding/v3` | `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash` | Volcengine/Volcano Engine Ark OpenAI-compatible coding endpoint. `VOLCENGINE_MODEL` and `VOLCENGINE_ARK_MODEL` are accepted. |\n| `openrouter` | `[providers.openrouter]` | `OPENROUTER_API_KEY` | `OPENROUTER_BASE_URL`; default `https://openrouter.ai/api/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`; recent large IDs include `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`, `qwen/qwen3.6-flash`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`, `qwen/qwen3.6-plus`, `google/gemma-4-31b-it`, `z-ai/glm-5.1`, `z-ai/glm-5.2`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6` | Additive open-model routing layer. It does not replace DeepSeek; it lets users route supported model IDs through OpenRouter when they choose it. |\n| `orcarouter` | `[providers.orcarouter]` | `ORCAROUTER_API_KEY` | `ORCAROUTER_BASE_URL`; default `https://api.orcarouter.ai/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`; router alias `orcarouter/auto`; recent large IDs mirror the OpenRouter namespaced catalog | [OrcaRouter](https://www.orcarouter.ai) OpenAI-compatible aggregation gateway. Shares the namespaced `vendor/model` wire-model format and DeepSeek model set with OpenRouter, so the OpenRouter base-URL and model-normalization rules apply. `ORCAROUTER_MODEL` is accepted. Provider aliases: `orcarouter`, `orca_router`, `orca`. |\n| `xiaomi-mimo` | `[providers.xiaomi_mimo]` | `XIAOMI_MIMO_TOKEN_PLAN_API_KEY`, `MIMO_TOKEN_PLAN_API_KEY`, `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, `MIMO_API_KEY` | `XIAOMI_MIMO_BASE_URL`, `MIMO_BASE_URL`, `XIAOMI_MIMO_MODE`, `MIMO_MODE`; default `https://token-plan-sgp.xiaomimimo.com/v1` | Chat: `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, `mimo-v2.5`; speech/TTS: `mimo-v2.5-tts`, `mimo-v2.5-tts-voicedesign`, `mimo-v2.5-tts-voiceclone`, `mimo-v2-tts` | Xiaomi MiMo OpenAI-compatible chat completions route. `mimo-v2.5-pro` and `mimo-v2.5` can use the documented provider-native web-search plugin; ultraspeed, speech/TTS, and custom-compatible routes do not inherit it. Token Plan keys (`tp-...`) use `api-key` auth and the token-plan endpoint by default; pay-as-you-go mode uses standard API keys (`sk-...`) and `https://api.xiaomimimo.com/v1`. It sends `max_completion_tokens` and uses MiMo's `thinking` field for reasoning control. Token Plan cost/usage is credit/quota based; Codewhale shows it as unknown until Xiaomi exposes a reliable balance API. `codewhale speech` / `tts` uses the TTS models. |\n| `novita` | `[providers.novita]` | `NOVITA_API_KEY` | `NOVITA_BASE_URL`; default `https://api.novita.ai/openai/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | OpenAI-compatible hosted route for DeepSeek model IDs. Use config or `CODEWHALE_MODEL` / `DEEPSEEK_MODEL` for model overrides. |\n| `fireworks` | `[providers.fireworks]` | `FIREWORKS_API_KEY` | `FIREWORKS_BASE_URL`; default `https://api.fireworks.ai/inference/v1` | `accounts/fireworks/models/deepseek-v4-pro` | OpenAI-compatible hosted route. Use config or `CODEWHALE_MODEL` / `DEEPSEEK_MODEL` for model overrides. |\n| `siliconflow` | `[providers.siliconflow]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.com/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | OpenAI-compatible hosted route. Official docs use the `.com` endpoint. `SILICONFLOW_MODEL` is accepted. Reasoning aliases `deepseek-reasoner` and `deepseek-r1` map to Pro; `deepseek-chat` and `deepseek-v3` map to Flash. |\n| `siliconflow-CN` | `[providers.siliconflow_cn]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.cn/v1` | Uses the SiliconFlow model set | China regional SiliconFlow route. Falls back to `[providers.siliconflow]` for api_key / base_url / model when unset. Select it with `provider = \"siliconflow-CN\"` or `CODEWHALE_PROVIDER=siliconflow-CN`. |\n| `arcee` | `[providers.arcee]` | `ARCEE_API_KEY` | `ARCEE_BASE_URL`; default `https://api.arcee.ai/api/v1` | `trinity-large-thinking`, `trinity-large-preview` | Arcee AI direct OpenAI-compatible route, tracked as 256K-context BF16 serving. `ARCEE_MODEL` is accepted. OpenRouter's `arcee-ai/trinity-large-thinking` remains the OpenRouter namespaced model ID; direct Arcee uses the bare `trinity-large-thinking` ID. |\n| `moonshot` | `[providers.moonshot]` | `MOONSHOT_API_KEY`, `KIMI_API_KEY` | `MOONSHOT_BASE_URL`, `KIMI_BASE_URL`; default `https://api.moonshot.ai/v1` | Direct Moonshot: `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.7-code-highspeed`, `kimi-k2.6`; Kimi Code membership: `k3`, `kimi-for-coding`, `kimi-for-coding-highspeed` at `https://api.kimi.com/coding/v1` | Moonshot/Kimi route. Exact direct `kimi-k3` routes use the documented Formula web-search tool/fiber loop; direct `kimi-k2.6` retains the built-in `$web_search` contract, and exact Kimi Code membership routes use their structured `/search` service. Adjacent paths, K2.7 direct models, and cross-product model IDs do not inherit native search. `kimi` and `kimi-k2` aliases select `kimi-k2.7-code`; `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, and `KIMI_MODEL` are accepted. Kimi thinking streams through `reasoning_content`; Codewhale keeps it in Thinking cells and replays it for thinking/tool-call continuity. For direct K3, use exact `base_url = \"https://api.moonshot.ai/v1\"` and `model = \"kimi-k3\"`; it is always-thinking and receives top-level `reasoning_effort = \"low\" | \"high\" | \"max\"` (`off` normalizes to `low`), uses only `max_completion_tokens`, and omits `temperature`/`top_p` per the [K3 quickstart](https://platform.kimi.ai/docs/guide/kimi-k3-quickstart). For Kimi Code K3, use a key from the [Kimi Code console](https://www.kimi.com/code/console), exact `base_url = \"https://api.kimi.com/coding/v1\"`, and bare `model = \"k3\"`; `off` becomes enabled `low`, while normal dispatched `auto` selects and sends a concrete Codewhale tier. Only an omitted reasoning setting leaves the provider default in control. That membership route defaults safely to 262,144 context tokens; the [Kimi Code model-tier table](https://www.kimi.com/code/docs/en/kimi-code/models.html) grants Allegretto and higher plans up to 1M, which those plans may express as `context_window = 1048576`. `k3[1m]` is Claude Code-only and Codewhale rejects it. `kimi-for-coding` remains the valid K2.7 membership route, and `kimi-for-coding-highspeed` is its own high-speed roster entry (262,144 context); membership ids are rejected on the direct platform endpoint, and `kimi-k3` stays rejected on the membership endpoint. Billing is decided by the endpoint the route resolves to, judged once against the two exact product endpoints: direct Moonshot (`https://api.moonshot.ai/v1` or the default) bills metered with dollar estimates, the exact Kimi Code membership endpoint bills as Kimi Code quota and never shows dollar estimates, and anything else — a gateway host, a neighboring Kimi-hosted path — reports `cost: unknown` rather than borrowing either product. An imported Kimi Code token with no `base_url` in its table still resolves to the membership endpoint, so it bills as Kimi Code quota and never accrues dollars. A completed turn, parent or sub-agent, is billed from the immutable endpoint receipt its own client was built with, never from a later config re-read: `MOONSHOT_BASE_URL`/`KIMI_BASE_URL` are merged into the *active* provider's table only, and an in-turn provider switch can move the ambient config off the route that actually ran. Legacy `auth_mode = \"kimi_oauth\"` fails to API-key guidance without probing Kimi CLI files. Codewhale does not impersonate `kimi_cli` or `kimi_code_cli`. **China-region keys:** contributor field evidence (@vFONGv, PR #5229, verified on Windows 10) reports that a China-region Moonshot key must be paired with `base_url = \"https://api.moonshot.cn/v1\"`; left on the default international host (`https://api.moonshot.ai/v1`) it fails authentication. We have no China-region key to verify this ourselves, so it is recorded as a user report rather than a tested route. Note also that editing `base_url` alone does not take effect until `codewhale auth set` is re-run for that provider. |\n| `google` | `[providers.google]` | `GOOGLE_API_KEY`, `GEMINI_API_KEY` | `GOOGLE_BASE_URL`, `GEMINI_BASE_URL`; default `https://generativelanguage.googleapis.com/v1beta/openai/` | `gemini-3.1-pro-preview` (default); `/model` also lists `gemini-3-pro-preview`, `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-2.5-pro`, `gemini-2.5-flash` | Google Gemini as its own backend on the official OpenAI-compatible Chat Completions route. Thinking models capture `extra_content.google.thought_signature` on tool calls and replay it with the assistant tool-call messages; replaying a tool call whose signature was not captured fails closed with an actionable error instead of letting the tool loop break. `gemini-2.5-flash-lite` ships thinking off and degrades with a warning instead. Reasoning effort maps onto the documented `google.thinking_config.thinking_level` (`low`/`high`). The dialect binds to the exact official base URL: a `google` row pointed at another gateway gets plain OpenAI semantics and no signature requirements. Codewhale never reads Google OAuth files; only an AI Studio API key is used. Not live-tested against the real endpoint in this environment. |\n| `zai` | `[providers.zai]` | `ZAI_API_KEY`, `Z_AI_API_KEY`, `ZHIPU_API_KEY`, `GLM_API_KEY` | `ZAI_BASE_URL`, `Z_AI_BASE_URL`; default `https://api.z.ai/api/coding/paas/v4`; general APIs `https://api.z.ai/api/paas/v4` and `https://open.bigmodel.cn/api/paas/v4` | `GLM-5.3` default; `/model` also lists `GLM-5.3-Flash`, `GLM-5.2`, `GLM-5.1`, and `GLM-5-Turbo` | Z.AI GLM Coding Plan route. All three first-party Chat routes (both api.z.ai products and BigModel's general platform endpoint) share one request dialect, so reasoning controls — the GLM-5.2 thinking toggle, tiered effort, and the forced-thinking GLM-5.3 rewrite that sends `off` as `enabled` + `reasoning_effort: \"low\"` — apply on BigModel too; neighboring paths such as `/preview` stay fail-closed. The two general API products expose structured provider-native web search (`search-prime` globally, `search_std` in China); Coding Plan and compatible custom endpoints do not inherit it. `GLM-5.3` is the default and a first-class picker row (`model = \"GLM-5.3\"` or `ZAI_MODEL=GLM-5.3`); `GLM-5.3-Flash` is the 1M multimodal fast sibling (`model = \"GLM-5.3-Flash\"`). An explicit `GLM-5.2` selection keeps its own id. Limits and reasoning options for 5.3 are inherited from `GLM-5.2` until Z.ai publishes distinct 5.3 metadata; 5.3 carries no price. Flash ships the published $0.15/$0.50 list. A live call can still 429 with entitlement code 1311 on accounts that are not provisioned for 5.3. |\n| `stepfun` | `[providers.stepfun]` | `STEPFUN_API_KEY`, `STEP_API_KEY` | `STEPFUN_BASE_URL`, `STEP_BASE_URL`; default `https://api.stepfun.ai/v1`; Coding Plan endpoint `https://api.stepfun.ai/step_plan/v1` | `step-3.7-flash` | StepFun / StepFlash direct OpenAI-compatible route. `/provider` setup asks which billing route the key belongs to — pay-as-you-go or Step Plan — validates the key against the chosen endpoint, and writes the answer to `[providers.stepfun].base_url` only. A base URL that is neither recognized route is left alone and the question is skipped. You can also set `[providers.stepfun].base_url` or `STEP_BASE_URL` to the Coding Plan URL by hand. Offline accounting labels recognized routes as `stepfun-payg` or `stepfun-plan` without persisting the raw endpoint, and only the standard PAYG route receives token pricing. `STEPFUN_MODEL` and `STEP_MODEL` are accepted. |\n| `minimax` | `[providers.minimax]` | `MINIMAX_API_KEY` | `MINIMAX_BASE_URL`; default `https://api.minimax.io/v1`; China `https://api.minimaxi.com/v1` | `MiniMax-M3`, `MiniMax-M2.7`, `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `MiniMax-M2.1`, `MiniMax-M2.1-highspeed`, `MiniMax-M2` | MiniMax direct OpenAI-compatible route. Codewhale sends `reasoning_split = true` so MiniMax thinking arrives separately from answer text. Both MiniMax dialects sell pay-as-you-go and Token Plan over the same endpoints and the same key, so billing is classified from the credential *product*, never from the endpoint or from a default. `mode = \"token-plan\"` in `[providers.minimax]`/`[providers.minimax_anthropic]`, or a Token Plan key shaped `sk-cp…`, bills as MiniMax Token Plan quota with no dollar estimates; an explicit pay-as-you-go mode (`pay-as-you-go`/`payg`/`metered`) wins over key shape. The key's product prefix is only visible when the key is in config, bound by `api_key_env`, or exported as `MINIMAX_API_KEY` on an official endpoint — a key saved through `codewhale auth set` (secret store / OS keyring) is deliberately not read to classify billing. With no explicit mode and no visible product marker the route reports `cost: unknown` rather than assuming pay-as-you-go, so a Token Plan account is never charged invented dollars. Custom/gateway endpoints also fail closed with `cost: unknown`. Official M3 input modalities are text, image, and video; M2.7 is text-only. |\n| `minimax-anthropic` | `[providers.minimax_anthropic]` | `MINIMAX_API_KEY` | `MINIMAX_ANTHROPIC_BASE_URL`; default `https://api.minimax.io/anthropic`; China `https://api.minimaxi.com/anthropic` | `MiniMax-M3`, `MiniMax-M2.7` | MiniMax direct Anthropic-compatible Messages route. Keep the `/anthropic` suffix because Codewhale appends `/v1/messages`; the route uses `x-api-key`. M3 supports adaptive or disabled thinking. M2.7 always keeps thinking enabled. |\n| `sglang` | `[providers.sglang]` | Optional `SGLANG_API_KEY` | `SGLANG_BASE_URL`; default `http://localhost:30000/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Self-hosted OpenAI-compatible route. Localhost deployments commonly omit auth. `SGLANG_MODEL` is accepted. |\n| `vllm` | `[providers.vllm]` | Optional `VLLM_API_KEY` | `VLLM_BASE_URL`; default `http://localhost:8000/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Self-hosted vLLM OpenAI-compatible route. Localhost deployments commonly omit auth. `VLLM_MODEL` is accepted. |\n| `ollama` | `[providers.ollama]` | Local optional `OLLAMA_API_KEY` | `OLLAMA_BASE_URL`; default `http://localhost:11434/v1` | live tag from the local catalog; pre-refresh placeholder `unknown`; provider-hinted custom tags pass through | Local Ollama is keyless by default. `OLLAMA_MODEL` is accepted. The header must not paint a hosted id the local daemon did not list. |\n| `ollama-cloud` | `[providers.ollama_cloud]` | `OLLAMA_CLOUD_API_KEY`, then `OLLAMA_API_KEY` | `OLLAMA_CLOUD_BASE_URL`; default `https://ollama.com/v1` | `gpt-oss:120b`; arbitrary provider-owned IDs pass through | Hosted OpenAI-compatible `/v1/chat/completions` route. Save credentials under `ollama-cloud`; the exact released `ollama` + Cloud URL tuple has bounded read-only in-memory compatibility with its legacy table and secret slot. `OLLAMA_CLOUD_MODEL` is accepted. |\n| `huggingface` | `[providers.huggingface]` | `HUGGINGFACE_API_KEY`, `HF_TOKEN` | `HUGGINGFACE_BASE_URL`, `HF_BASE_URL`; default `https://router.huggingface.co/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Hugging Face Inference Providers OpenAI-compatible router route. Accepted aliases: `huggingface`, `hugging-face`, `hugging_face`, `hf`. Org-prefixed model IDs pass through. `HUGGINGFACE_MODEL` and `HF_MODEL` are accepted. Hub browsing/export are separate future features. |\n| `modelscope` | `[providers.modelscope]` | `MODELSCOPE_API_KEY` | `MODELSCOPE_BASE_URL`; default `https://api-inference.modelscope.cn/v1` | `Qwen/Qwen3.5-397B-A17B` (default), `Qwen/Qwen3.5-122B-A10B`, `Qwen/Qwen3.5-27B`, `Qwen/Qwen3.5-35B-A3B`, `Qwen/Qwen3.8-27B`, `Qwen/Qwen3.8-Flash-Next`, `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Pro-0813`, `deepseek-ai/DeepSeek-V4.1-Flash`, `ZhipuAI/GLM-4.7-Flash`, `ZhipuAI/GLM-5.2` | ModelScope OpenAI-compatible inference route. Org-prefixed model IDs pass through. `MODELSCOPE_MODEL` is accepted. |\n| `deepinfra` | `[providers.deepinfra]` | `DEEPINFRA_API_KEY`, `DEEPINFRA_TOKEN` | `DEEPINFRA_BASE_URL`; default `https://api.deepinfra.com/v1/openai` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | DeepInfra OpenAI-compatible route. Drop-in replacement for OpenAI SDK. |\n| `together` | `[providers.together]` | `TOGETHER_API_KEY` | `TOGETHER_BASE_URL`; default `https://api.together.xyz/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash`, `thinkingmachines/inkling` | Together AI OpenAI-compatible route. `TOGETHER_MODEL` is accepted. Model aliases `deepseek-v4-pro` and `deepseek-v4-flash` normalize to Together's org-prefixed IDs; `inkling` and `together-inkling` normalize to Together's published lowercase Inkling wire ID. Inkling uses the exact `none`/`minimal`/`low`/`medium`/`high`/`max` reasoning vocabulary from Thinking Machines' [official model repository](https://huggingface.co/thinkingmachines/Inkling). Together's [launch post](https://www.together.ai/blog/together-ai-brings-thinking-machines-labs-new-model-inkling-on-day-0) currently says Inkling is live with 1M context, while its [model detail page](https://www.together.ai/models/inkling) says coming soon with 256K context and publishes no price. Until Together's active `/models` endpoint and the Models.dev catalog resolve that conflict, Inkling is not seeded into Codewhale's offline picker and no route-specific context or cost is inferred. |\n| `qianfan` | `[providers.qianfan]` | `QIANFAN_API_KEY`, `BAIDU_QIANFAN_API_KEY` | `QIANFAN_BASE_URL`, `BAIDU_QIANFAN_BASE_URL`; default `https://api.baiduqianfan.ai/v1` | `ernie-4.0-turbo-8k`; provider-scoped custom Qianfan service/model IDs pass through | Baidu Qianfan OpenAI-compatible route. Requests use Bearer auth and Chat Completions payloads. `QIANFAN_MODEL` and `BAIDU_QIANFAN_MODEL` are accepted; aliases `baidu-qianfan`, `baidu_qianfan`, and `baidu` resolve to this provider. Tool/function calling is model-scoped in Qianfan docs, so Codewhale preserves the selected wire model and leaves live capability proof to follow-up route/capability work. |\n| `openai-codex` | `[providers.openai_codex]` | Official Sign in with ChatGPT (`codewhale auth chatgpt` / `/provider setup openai-codex`) | Official `https://api.openai.com/v1` | Selected account catalog; configured model IDs remain explicit selections | **Experimental.** Public Responses endpoint (`/v1/responses`) with a validated `chatgpt.tokens.use.direct` grant. Dynamic OSS registration issues a client ID for the selected account/workspace; Codewhale protects and renews its own tokens. The account catalog supplies selectable models; public model lists and legacy Codex tokens do not establish plan permission. No silent billing fallback. See [Sign in with ChatGPT](#sign-in-with-chatgpt) for setup, usage limits, and preview boundaries. |\n| `anthropic` | `[providers.anthropic]` | `ANTHROPIC_API_KEY` | `ANTHROPIC_BASE_URL`; default `https://api.anthropic.com` | `claude-opus-4-8`, `claude-sonnet-4-6` (default), `claude-haiku-4-5` | Native Anthropic Messages API route (`/v1/messages`, `x-api-key` + `anthropic-version: 2023-06-01`) — not OpenAI-compatible. Prompt caching via `cache_control` breakpoints, adaptive thinking + `output_config.effort`, signed thinking blocks replayed verbatim, cache telemetry normalized per #2961. `ANTHROPIC_MODEL` is accepted. |\n| `openmodel` | `[providers.openmodel]` | `OPENMODEL_API_KEY` | `OPENMODEL_BASE_URL`; default `https://api.openmodel.ai` | `deepseek-v4-flash`; provider-scoped custom model IDs pass through | OpenModel Anthropic-compatible Messages route. Uses `/v1/messages`, Bearer auth, and `anthropic-version: 2023-06-01`; OpenModel selects DeepSeek, DashScope, Xiaomi, Claude, and other routes by model id. `OPENMODEL_MODEL` is accepted. |\n| `sakana` | `[providers.sakana]` | `FUGU_API_KEY`, `SAKANA_API_KEY` | `SAKANA_BASE_URL`; default `https://api.sakana.ai/v1` | `fugu` (default), `fugu-ultra-20260615` | Sakana AI Fugu OpenAI-compatible route. Standard Chat Completions wire protocol; streaming supported. `fugu-ultra-20260615` is the heavy/reasoning variant. Env var aliases: `FUGU_API_KEY` (primary), `SAKANA_API_KEY`; provider aliases: `sakana-ai`, `sakana_ai`, `fugu`. |\n| `longcat` | `[providers.longcat]` | `LONGCAT_API_KEY` | `LONGCAT_BASE_URL`; default `https://api.longcat.chat/openai/v1` | `LongCat-2.0` (default) | Meituan LongCat curated model gateway. OpenAI-compatible Chat Completions wire protocol. Sign up at https://longcat.chat/platform for an API key. Provider aliases: `long-cat`, `meituan-longcat`, `meituan`. |\n| `opencode-go` | `[providers.opencode_go]` | `OPENCODE_GO_API_KEY` | `OPENCODE_GO_BASE_URL`; default `https://opencode.ai/zen/go/v1` | `deepseek-v4-pro` (default), `grok-4.5`, `glm-5.2`, `glm-5.1`, `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.6`, `deepseek-v4-flash`, `mimo-v2.5`, `mimo-v2.5-pro` | [OpenCode Go](https://opencode.ai/docs/go/) subscription route using OpenAI-compatible Chat Completions. `OPENCODE_GO_MODEL` is accepted. Codewhale uses bare wire IDs; familiar `opencode-go/` input aliases normalize to the bare ID. Go models documented only on the Anthropic `/messages` endpoint are deliberately not advertised by this route until Codewhale supports per-model wire selection. Billing surfaces show the Go allowance instead of token-price estimates. |\n| `opencode-zen` | `[providers.opencode_zen]` | `OPENCODE_ZEN_API_KEY`, fallback `OPENCODE_API_KEY` | `OPENCODE_ZEN_BASE_URL`; default `https://opencode.ai/zen/v1` | `gpt-5.6` (default); current GPT, Claude, Qwen, DeepSeek, MiniMax, GLM, Kimi, Grok, Muse Spark, and free-model IDs | [OpenCode Zen](https://opencode.ai/docs/zen/) model-aware gateway. `OPENCODE_ZEN_MODEL` is accepted, and official `opencode/` selectors normalize to bare wire IDs. Each model's wire comes from the curated snapshot, then from the AI SDK package its [Models.dev](https://models.dev) row names: GPT, Grok, and Muse Spark rows use `/responses`; Claude and most Qwen rows use `/messages`; DeepSeek, MiniMax, GLM, Kimi, `qwen3.8-max`, and the free rows use `/chat/completions`. Responses and Chat Completions authenticate with Bearer `Authorization`, while Anthropic Messages uses `x-api-key`; none of these routes use ChatGPT/Codex OAuth guidance or headers. Gemini fails closed because its model-specific Google wire protocol is not implemented; Models.dev rows marked `deprecated` and models no loaded catalog lists also fail closed. See [OpenCode Zen protocol catalog](#opencode-zen-protocol-catalog). |\n| `meta` | `[providers.meta]` | `META_MODEL_API_KEY`, `MODEL_API_KEY` | `META_MODEL_API_BASE_URL`, `MODEL_API_BASE_URL`; default `https://api.meta.ai/v1` | `muse-spark-1.2` (default) | [Meta Model API](https://developer.meta.com/ai/resources/blog/build-with-muse-spark/) public-preview route using OpenAI-compatible Chat Completions. Muse Spark 1.2 keeps its wire ID, tool support, 1M context, 32K output metadata, and `none` through `xhigh` reasoning effort. `META_MODEL_API_MODEL` and `MODEL_API_MODEL` are accepted. Provider aliases: `meta-ai`, `meta_model_api`, `muse`, `muse-spark`. |\n| `telecomjs` | `[providers.telecomjs]` | `TELECOMJS_API_KEY` | `TELECOMJS_BASE_URL`; default `https://aigw.telecomjs.com/v1` | `deepseek-v4-pro` conservative fallback; authenticated `/models` rows when a key is configured | TelecomJS TokenHub OpenAI-compatible Chat Completions route. Live catalogs are isolated by provider and key fingerprint, stale rows survive transient refresh failures, and unsupported reasoning request fields are omitted. `TELECOMJS_MODEL` is accepted. Provider aliases: `telecom-js`, `telecom_js`, `telecomjs-cn`, `tokenhub`. |\n| `mistral` | `[providers.mistral]` | `MISTRAL_API_KEY` | `MISTRAL_BASE_URL`; default `https://api.mistral.ai/v1` | `mistral-code-latest` (default; `codestral-latest` accepted as alias), `mistral-medium-latest` (aliases: `mistral-medium-3-5`), `mistral-small-latest` (aliases: `mistral-small-2603`), `mistral-large-latest` | Mistral AI (la Plateforme) OpenAI-compatible Chat route. On the documented first-party HTTPS `/v1` hosts, Medium and Small send adjustable `reasoning_effort` (`none` or `high` only), parse Mistral's polymorphic thinking/text blocks, and replay stored thinking in that same wire shape. Deprecated native Magistral IDs remain explicit-configuration compatibility routes: they are always-reasoning and never receive the adjustable effort field. Code and Large are non-reasoning. A custom `MISTRAL_BASE_URL` keeps generic Chat semantics unless it is one of the documented first-party hosts. `MISTRAL_MODEL` is accepted. Provider aliases: `mistral-ai`, `mistralai`, `la-plateforme`. |\n| `edenai` | `[providers.edenai]` | `EDENAI_API_KEY` | `EDENAI_BASE_URL`; default `https://api.edenai.run/v3`; EU `https://api.eu.edenai.run/v3` | `deepseek/deepseek-v4-pro` (default); live `/models` catalog of `provider/model` ids | Eden AI OpenAI-compatible aggregation gateway. Catalog rows remain provider-scoped; generic reasoning controls are omitted because supported fields depend on the selected upstream family. `EDENAI_MODEL` is accepted. The default `deepseek/deepseek-v4-pro` is listed on the global catalog only; on the EU endpoint set `EDENAI_MODEL` (or `model`) to a row from the EU `/models` list, for example `qwen/deepseek-v4-pro`. Provider aliases: `eden-ai`, `eden_ai`. |\n| `zenmux` | `[providers.zenmux]` | `ZENMUX_API_KEY` | `ZENMUX_BASE_URL`; default `https://zenmux.ai/api/v1` | `deepseek/deepseek-v4.1-flash` (default); live `/models` catalog of `provider/model` ids (keyless-readable) | ZenMux OpenAI-compatible aggregation gateway (~190 models). Catalog rows remain provider-scoped; generic reasoning controls are omitted because supported fields depend on the selected upstream family. `ZENMUX_MODEL` is accepted. Provider aliases: `zen-mux`, `zen_mux`. |\n| `csdn` | `[providers.csdn]` | `CSDN_API_KEY` | `CSDN_BASE_URL`; default `https://ai.csdn.net/api/model/v1` | `glm_for_coding` (default; the Coding Plan's dedicated model id); other marketplace model ids pass through | CSDN 星图 (Starmap) OpenAI-compatible hosted platform. Coding Plan keys and general marketplace keys share the one endpoint, so billing follows the credential product, never the URL alone: routing `glm_for_coding` — the shipped default — or setting `mode = \"coding_plan\"`/`\"plan\"`/`\"subscription\"` bills as CSDN Coding Plan quota with no dollar estimates; any other model, or an explicit `pay-as-you-go`/`metered` mode, bills metered; an unrecognized mode or an endpoint off `ai.csdn.net/api/model/v1` reports `cost: unknown`. `CSDN_MODEL` is accepted. Provider aliases: `csdn-ai`, `csdn_ai`, `csdn-coding-plan`, `csdn_coding_plan`, `starmap`. |\n| `concentrate` | `[providers.concentrate]` | `CONCENTRATE_API_KEY` | `CONCENTRATE_BASE_URL`; default `https://api.concentrate.ai/v1` | `deepseek-v4-pro` (default; a plain catalog id lets the gateway pick the upstream provider); `provider/model` ids such as `openai/gpt-5.6-sol` pin one upstream; `concentrate/auto` is the gateway's own router; unauthenticated live `/v1/models` catalog | Concentrate OpenAI Responses-compatible gateway (`POST /v1/responses`, bearer Universal API key). Opt-in and BYOK only: your key, your Concentrate bill, zero Codewhale fee, no managed default. Requests carry only documented fields (the system prompt rides as a `system` input item). Contract: [API introduction](https://concentrate.ai/docs/api-reference/introduction), [request parameters](https://concentrate.ai/docs/api-reference/endpoint/request-parameters), [streaming](https://concentrate.ai/docs/api-reference/endpoint/streaming), [errors](https://concentrate.ai/docs/api-reference/endpoint/errors). See [Concentrate Notes](#concentrate-notes). |\n| `codewhale` | `[providers.codewhale]` | `CODEWHALE_API_KEY` | `CODEWHALE_API_BASE`; default `https://api.codewhale.net/v1` | `deepseek/deepseek-v4-pro` (default), `anthropic/claude-sonnet-5`, `openai/gpt-5.6` are offline bootstrap rows only; the authenticated `GET /v1/models` listing of the account's connected providers is the catalog authority | Codewhale API: account-backed model access over the provider keys the customer connected to their Codewhale account. One `cwc_key_…` account API key with the `models:infer` scope authenticates every model. Model ids are `provider/model` exactly as the account catalog returns them, and each row states its protocol (`chat-completions` → `/v1/chat/completions`, `anthropic-messages` → `/v1/messages`, `responses` → `/v1/responses`); every protocol uses `Authorization: Bearer`, never `x-api-key`. `CODEWHALE_API_BASE` must be HTTPS except on loopback. Connect provider keys with `codewhale account keys set `. Provider aliases: `codewhale-api`, `cw-api`, `codewhale-cloud`. |\n| `xai` | `[providers.xai]` | `XAI_API_KEY`, Codewhale-owned device OAuth, or explicit read-only Grok CLI consent | `XAI_BASE_URL`; default `https://api.x.ai/v1` | `grok-4.6` (default), `grok-4.7`, `grok-4.5`, `grok-4.3`, `grok-build`, `grok-composer-2.5-fast`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning` | xAI/Grok OpenAI-compatible Chat Completions route. Grok 4.6 has a 500K context window, text/image input, function calls, structured output, server-side web search, and `low`/`medium`/`high`/`xhigh` reasoning (default `high`). [Grok 4.7](https://docs.x.ai/docs/models/grok-4.7) has the same 500K window, text/image input, function calls, structured output, reasoning ladder and $2.00 / $0.50 cached / $6.00 rates; xAI documents no web-search support for it yet, so Codewhale does not claim it. Grok reasoning cannot be disabled ([reasoning guide](https://docs.x.ai/docs/guides/reasoning)). Its standard rates double when the prompt reaches 200K tokens; the same 2x long-context rule applies to `grok-4.5` (500K context, $2.00 / $0.30 cached / $6.00) and `grok-4.3` (1M context, $1.25 / $0.20 cached / $2.50) per their [model pages](https://docs.x.ai/docs/models/grok-4.5). There is no documented `latest`/`fast` alias and no published numeric output limit. **API-key** (default): Bearer token from console.x.ai via `XAI_API_KEY` / keyring / `api_key`. **OAuth**: `codewhale auth xai-device` uses SSH-friendly device login and Codewhale-owned storage, which may refresh itself. Existing Grok CLI credentials require `codewhale auth external-consent --provider xai --mode read-only`; the granted external file is never refreshed or rewritten. OAuth may return HTTP 403 on some SuperGrok tiers — keep API-key as the reliable fallback. `XAI_MODEL` is accepted. Provider aliases: `x-ai`, `x_ai`, `grok`. |\n| `modelstudio-token-plan` | `[providers.modelstudio_token_plan]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | `MODELSTUDIO_TOKEN_PLAN_BASE_URL`; default `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` | `qwen3.8-max` (default), `qwen3.8-max-preview`, `qwen3.7-plus`, `qwen3.7-max`, `qwen3.6-flash`, `deepseek-v4-pro`, `deepseek-v4-flash-0731`, `glm-5.2` | Alibaba Cloud Model Studio Token Plan OpenAI-compatible Chat Completions route. Token Plan Personal and Team share this endpoint. `qwen3.8-max`, `qwen3.7-plus`, and `qwen3.7-max` can use provider-native web search through the Token Plan Responses Harness; the preview, Coding Plan, and Anthropic routes do not inherit that capability. All listed models are reasoning-capable text/coding models. DeepSeek and GLM entries are provider-scoped and do not collide with first-party routes. `MODELSTUDIO_TOKEN_PLAN_MODEL` is accepted. Provider aliases: `modelstudio-token-plan`, `alibaba-token-plan`, `dashscope-token-plan`. |\n| `modelstudio-token-plan-anthropic` | `[providers.modelstudio_token_plan_anthropic]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | default `https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic` | Same model catalog as `modelstudio-token-plan` | Token Plan Anthropic-compatible Messages route (`/apps/anthropic`). Same API key as the OpenAI dialect. Provider aliases: `modelstudio-token-plan-anthropic`, `alibaba-token-plan-anthropic`. |\n| `modelstudio-coding-plan` | `[providers.modelstudio_coding_plan]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | `MODELSTUDIO_CODING_PLAN_BASE_URL`; default `https://coding-intl.dashscope.aliyuncs.com/v1` | `qwen3.8-max` (default); same catalog as Token Plan | Alibaba Cloud Model Studio Coding Plan OpenAI-compatible Chat Completions route. `MODELSTUDIO_CODING_PLAN_MODEL` is accepted. Provider aliases: `modelstudio-coding-plan`, `alibaba-coding-plan`, `dashscope-coding-plan`. |\n| `modelstudio-coding-plan-anthropic` | `[providers.modelstudio_coding_plan_anthropic]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | default `https://coding-intl.dashscope.aliyuncs.com/apps/anthropic` | Same model catalog as `modelstudio-coding-plan` | Coding Plan Anthropic-compatible Messages route (`/apps/anthropic`). Provider aliases: `modelstudio-coding-plan-anthropic`, `alibaba-coding-plan-anthropic`. |\n\nStepFun's four coding models are available through both its standard API and\n[Step Plan](https://platform.stepfun.ai/docs/en/step-plan/integrations/reasoning-api).\nChoose the billing route in `/provider`, then the model in `/model`; existing\nStep 3.7 selections remain unchanged. [Step 5 Preview](https://platform.stepfun.ai/docs/en/guides/models/step-5-preview)\nhas a 1M context window. Step 5 and Step 3.7 expose low/medium/high reasoning;\nStep 3.5 Flash 2603 exposes low/high; base Step 3.5 uses provider-default reasoning.\n[Published API prices](https://platform.stepfun.ai/docs/en/guides/pricing/details)\napply only to verified PAYG routes. Step Plan displays subscription allowance.\nSpeech, music and image-generation models use separate interfaces and are not\npresented as coding models. Provider video capability metadata does not imply\nthat every Codewhale client can attach video.\n\n\n### OpenCode Zen protocol catalog\n\nZen Responses and Chat Completions requests authenticate with Bearer\n`Authorization`; Zen Anthropic Messages requests use `x-api-key`. None of these\nroutes add ChatGPT/Codex OAuth headers.\n\nEach Zen model's wire comes from two sources, and neither is guessed from a\nmodel-id family (`qwen3.8-flash` is Messages while `qwen3.8-max` is Chat):\n\n1. **The curated snapshot** compiled into Codewhale, verified against the\n [official endpoint table](https://opencode.ai/docs/zen/) and the `opencode`\n provider in [Models.dev](https://models.dev). It is the offline floor, and\n it wins when a catalog row names a different wire for the same id.\n2. **The Models.dev catalog.** Its `opencode` provider is Zen's published\n catalog: each model row names the AI SDK package OpenCode itself uses, which\n Codewhale maps exactly — `@ai-sdk/openai` to Responses, `@ai-sdk/anthropic`\n to Anthropic Messages, and `@ai-sdk/openai-compatible` (the provider\n default) to Chat Completions. A Zen model released after this build routes\n once the catalog lists it, without a Codewhale release. The interactive TUI\n and `codewhale exec` both load the persisted catalog; refresh it with\n `codewhale models --update`.\n\nThe curated snapshot:\n\n- Responses: `gpt-6-astra`, `gpt-6-sol`, `gpt-6-luna`, `gpt-5.6-sol`,\n `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5`, `gpt-5.5-pro`, `gpt-5.4`,\n `gpt-5.4-pro`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.3-codex`,\n `gpt-5.3-codex-spark`, `gpt-5.2`, `gpt-5.2-codex`, `gpt-5.1`,\n `gpt-5.1-codex`, `gpt-5.1-codex-max`, `gpt-5.1-codex-mini`, `gpt-5`,\n `gpt-5-codex`, `gpt-5-nano`, `grok-4.7`, `grok-4.6`, `grok-4.5`,\n `grok-build-0.1`, `muse-spark-1.3`, `muse-spark-1.3-contributor-free`,\n `muse-spark-1.2`, `muse-spark-1.2-contributor`,\n `muse-spark-1.2-contributor-free`.\n- Anthropic Messages: `claude-fable-5-1`, `claude-fable-5`,\n `claude-opus-5-5`, `claude-opus-5`, `claude-opus-4-8`, `claude-opus-4-7`,\n `claude-opus-4-6`, `claude-opus-4-5`, `claude-sonnet-5-5`,\n `claude-sonnet-5`, `claude-sonnet-4-6`, `claude-sonnet-4-5`,\n `claude-sonnet-4`, `claude-haiku-4-5`, `qwen3.8-flash`, `qwen3.7-max`,\n `qwen3.7-plus`, `qwen3.6-plus`, `qwen3.5-plus`.\n- Chat Completions: `deepseek-v4.1-flash`, `deepseek-v4-pro`,\n `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp`, `minimax-m3`,\n `minimax-m2.7`, `minimax-m2.5`, `glm-5.3-flash`, `glm-5.3`, `glm-5.2`,\n `glm-5.1`, `glm-5`, `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.6`, `kimi-k2.5`,\n `qwen3.8-max`, `big-pickle`, `space-bunny-free`,\n `longcat-2.5-preview-free`, `mimo-v2.6-flash-free`, `mimo-v2.5-free`,\n `ling-3.0-flash-fin-free`, `north-mini-code-free`,\n `nemotron-3-ultra-free`, `nemotron-3.5-lightning-free`,\n `deepseek-v4-flash-free`.\n\nThese fail closed locally, with the reason in the error, instead of reaching\nZen: Gemini (`@ai-sdk/google`, Google's model-specific protocol, which Codewhale\ndoes not speak); a catalog row naming any other package; a catalog row Models.dev\nmarks `deprecated`; and a model neither source lists. A miss never falls back to\nanother Zen wire shape, including when a custom Zen base URL is configured.\n\n### Concentrate Notes\n\nConcentrate (`concentrate`) is an OpenAI **Responses**-compatible gateway:\n`POST https://api.concentrate.ai/v1/responses` with `Authorization: Bearer\n`. It is opt-in and **BYOK only**.\n\n- **Model ids pass through.** A plain catalog id (`gpt-5.6-sol`,\n `deepseek-v4-pro`) lets the gateway choose the upstream provider;\n `provider/model` (`openai/gpt-5.6-sol`) pins the upstream provider inside the\n gateway; `concentrate/auto` sends the gateway's own `auto` router. Codewhale's\n bare `--model auto` remains the resolver sentinel (provider default), which is\n why the gateway router has its own explicit spelling. Once streaming begins\n the gateway commits to one provider ([auto routing](https://concentrate.ai/docs/api-reference/endpoint/auto-routing)).\n- **Wire.** Fixed Responses dialect. The request carries only documented\n fields — `model`, `input`, `stream`, `max_output_tokens`, `tools`,\n `tool_choice`, `parallel_tool_calls`, `reasoning.effort` — and the system\n prompt travels as a leading `system` input item because `instructions` is\n not in the gateway's [parameter reference](https://concentrate.ai/docs/api-reference/endpoint/request-parameters).\n Streaming follows the typed `response.*` SSE events and ends on\n `response.completed` without a `[DONE]` sentinel ([streaming](https://concentrate.ai/docs/api-reference/endpoint/streaming)).\n- **Errors.** `{ \"error\", \"message\", \"model\"?, \"retry_after\"? }` with 400\n (bad request / unknown model), 401 (invalid key), 402 (insufficient\n credits), 424 (upstream provider unavailable), 429 (rate limit) —\n surfaced verbatim and classified by message ([errors](https://concentrate.ai/docs/api-reference/endpoint/errors)).\n- **Catalog.** `GET /v1/models` needs no key\n ([list models](https://concentrate.ai/docs/api-reference/endpoint/list-models)); the live rows\n are provider-scoped and unclaimed (no pricing or capability claims).\n- **Commercial boundary.** Concentrate's [Terms of Service](https://concentrate.ai/legal/terms-of-service)\n forbid reselling, white-labeling, or providing the service on a\n service-bureau basis without written consent, and its\n [Acceptable Use Policy](https://concentrate.ai/legal/acceptable-use-policy)\n forbids sharing or sublicensing keys. Codewhale therefore ships **no**\n Codewhale-owned Concentrate key, no stored customer key, no default or\n managed routing, and no markup — activation of any hosted lane is gated on\n written consent, terms, and billing approval (tracked in the Codewhale ops\n evidence checklist `concentrate-gateway-20260829/CHECKLIST.md`). Public\n pricing states \"No platform markup on tokens\".\n- **Self-test without a key.** `scripts/concentrate-selftest.sh` boots a\n local stub that speaks the documented contract and drives the real\n `codewhale exec` path through it, asserting the URL, bearer header, model\n passthrough, streaming, and the completed-turn receipt. No network call\n leaves the machine and no account is required.\n\n### Hugging Face Provider vs MCP vs Hub\n\nCodewhale's `huggingface` provider ID is only the OpenAI-compatible chat\ninference route through Hugging Face Inference Providers. It is selected with\n`/provider huggingface`, `CODEWHALE_PROVIDER=huggingface`, or\n`provider = \"huggingface\"`.\n\nHugging Face MCP is a separate external-tool route. Configure it through the\nMCP config described in `docs/MCP.md`, preferably using the settings-generated\nsnippet from . In the TUI, `/hf mcp status`\nchecks whether the Hugging Face MCP server appears in the resolved MCP config,\n`/hf mcp setup` prints the settings workflow and a placeholder-only shape, and\n`/hf concepts` explains the provider/MCP/Hub distinction.\n\nHub publishing or repository management remains explicit user action through\nHub-native tooling such as `huggingface_hub` or git. The `/hf` helper does not\nupload to Hugging Face and does not perform direct Hugging Face Hub HTTP search.\n\n### Xiaomi MiMo Notes\n\n`xiaomi-mimo` defaults to `mimo-v2.5-pro` for long-context reasoning and coding\nwork. The chat picker also exposes `mimo-v2.5-pro-ultraspeed` and the latest\nOmni model `mimo-v2.5`. Xiaomi MiMo TTS is available through\n`codewhale --provider xiaomi-mimo speech \"text\" --model tts` (or the `tts`\nalias). In Act and Operate, the provider-specific `speech` / `tts` tools are\navailable through deferred discovery when the Xiaomi MiMo route is configured.\n\n`/provider xiaomi-mimo ultraspeed` and `/provider xiaomi-mimo pro-ultraspeed`\nboth select `mimo-v2.5-pro-ultraspeed`. Speech aliases such as `tts`,\n`voice-design`, and `voice-clone` are separate from normal chat defaults.\n\nToken Plan keys default to the Singapore endpoint\n`https://token-plan-sgp.xiaomimimo.com/v1`. If your MiMo account is provisioned\nfor the China region, set `base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"`\nexplicitly in `[providers.xiaomi_mimo]` or set `mode = \"token-plan-cn\"`. Europe\nToken Plan accounts can set\n`base_url = \"https://token-plan-ams.xiaomimimo.com/v1\"` or use\n`mode = \"token-plan-ams\"`; `mode = \"pay-as-you-go\"`\nselects the standard API endpoint and standard MiMo key family. Xiaomi Token\nPlan docs and console expose credit/quota semantics, but Codewhale does not\ncurrently have a documented balance endpoint to poll, so cost display remains\nunknown rather than reusing token-price estimates from another provider.\n\nVoice-design and voice-clone shorthands map to `mimo-v2.5-tts-voicedesign` and\n`mimo-v2.5-tts-voiceclone`. Xiaomi's current\n[image-understanding guide](https://platform.xiaomimimo.com/docs/en-US/usage-guide/multimodal-understanding/image-understanding)\nincludes `mimo-v2.5` for image input. Codewhale exposes image analysis through the\nseparate `[vision_model]` / `image_analyze` path; set that model to\n`mimo-v2.5` when using MiMo for vision.\n\n### OpenRouter-Compatible Base URLs\n\nOpenRouter-compatible gateways should usually stay on the `openrouter`\nprovider with a provider-scoped `base_url` override instead of moving through\nthe generic `openai` route. That keeps OpenRouter-style reasoning, streaming,\ncache usage, and namespaced wire model parsing attached to the selected route:\n\n```toml\nprovider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"sk-...\"\nbase_url = \"https://openrouter-compatible.example/v1\"\nmodel = \"deepseek/deepseek-v4-pro\"\n```\n\nCodewhale preserves the `deepseek/` wire-model prefix under the OpenRouter\nprovider scope; it does not infer a switch to the direct DeepSeek provider from\nthat model string. Cache fields such as `prompt_cache_hit_tokens`,\n`prompt_cache_miss_tokens`, and `prompt_tokens_details.cached_tokens` are\nparsed when the upstream gateway sends them. If a key/account type omits those\nfields, Codewhale treats them as absent for that response rather than as a\ndifferent provider route.\n\nOrcaRouter (`https://api.orcarouter.ai/v1`) is a dedicated named route\n([OrcaRouter](https://www.orcarouter.ai)) that speaks the same OpenAI\nChat Completions wire protocol and serves the same namespaced\n`vendor/model` catalog. It does not need an OpenRouter-compatible\n`base_url` override: select `provider = \"orcarouter\"` and its namespaced\nwire models (for example `deepseek/deepseek-v4-pro` or its own\n`orcarouter/auto` router) pass through verbatim, exactly as they do on the\nOpenRouter provider scope.\n\n#### OrcaRouter credentials: API key or OAuth 2.0 + PKCE\n\nOrcaRouter accepts two credential sources, and both write the same durable\n`sk-orca-…` key to the same `orcarouter` secret-store slot with\n`auth_mode = \"api_key\"`:\n\n- **API key** — `codewhale auth set --provider orcarouter`, `/provider`, or\n `ORCAROUTER_API_KEY`. Use this when you already have a key.\n- **Connect with OrcaRouter** — `codewhale auth orcarouter` (CLI) or\n `/auth orcarouter` (in-session). This runs an OAuth 2.0 authorization-code\n flow with PKCE (S256) on a loopback redirect. The browser is sent to\n `GET https://www.orcarouter.ai/auth` with `callback_url`, `code_challenge`,\n `code_challenge_method=S256`, `state`, `app_name`, and `scope`; the code is\n exchanged at `POST https://www.orcarouter.ai/api/v1/auth/keys` for a\n durable API key. There is no client secret and no pre-registered redirect\n URI; state is compared in constant time before the code is used.\n\nAuthentication and inference use different origins: the consent and exchange\nlive on `https://www.orcarouter.ai`; models and chat live on\n`https://api.orcarouter.ai/v1`. Neither is derived from the other, and\n`/v1/auth/keys` on the API origin is not the exchange route. Self-hosted\ndeployments can override each origin explicitly with `ORCA_AUTH_BASE_URL`\n(auth) and `ORCA_API_BASE_URL`/`ORCAROUTER_BASE_URL` (inference); the explicit\nvalue wins. Remote origins must be HTTPS; plain HTTP is accepted only on\nloopback.\n\nA PKCE-issued key is a durable API key, **not** a refresh token: Codewhale\nstores it, reuses it across restarts, and never sends a refresh grant. Revoke\nthe key on the OrcaRouter console (`/auth orcarouter-revoke` clears the local\ncopy) and sign in again to get a new one. A 401 from the relay marks that\ncredential generation as needing re-authentication rather than silently\nretrying.\n\nThe chat model list is discovered live from `GET https://api.orcarouter.ai/v1/models`\nwith the configured key. OrcaRouter publishes `supported_endpoint_types` on\nevery row, so the chat selector keeps only rows advertising `openai`,\n`anthropic`, `gemini`, or `openai-response`, and drops image-generation,\nvideo, and rerank rows instead of guessing from a model name. A row that\nstates `architecture.input_modalities` with `image` is image-input capable;\nrows that state no architecture are treated as unknown, not as text-only.\n\n### Recent OpenRouter Large Models\n\nOpenRouter completions and static registry rows include the April 2026 onward\nlarge models verified through OpenRouter's model metadata:\n`arcee-ai/trinity-large-thinking`, `qwen/qwen3.6-flash`,\n`qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`,\n`qwen/qwen3.6-plus`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`,\n`xiaomi/mimo-v2.5`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`,\n`z-ai/glm-5.1`, `z-ai/glm-5.2`, `z-ai/glm-5-turbo`, `tencent/hy3-preview`,\n`google/gemma-4-31b-it`, `google/gemma-4-26b-a4b-it`, and\n`nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`.\n`minimax/minimax-m3` was added from OpenRouter's May 31, 2026 listing as a 1M\ncontext multimodal model for coding, tool use, and long-horizon agentic work.\n`GLM-5.3` is now the default direct Z.AI Coding Plan model; `GLM-5.2` /\n`z-ai/glm-5.2` remain available (explicit selections keep their own id),\n`GLM-5.1` / `z-ai/glm-5.1` remain available as the smaller model,\n`GLM-5.3-Flash` / `z-ai/glm-5.3-flash` is the faster/explore sibling of\n`GLM-5.3`, and `GLM-5-Turbo` / `z-ai/glm-5-turbo` remains the faster sibling\nof `GLM-5.2`.\n`GLM-5.3` / `z-ai/glm-5.3` and `GLM-5.3-Flash` / `z-ai/glm-5.3-flash` are\nfirst-class picker ids on the Z.ai and OpenRouter routes (`/model` after\n`/provider zai`, or `model = \"GLM-5.3-Flash\"`).\nLimits and reasoning options for 5.3 are inherited from\n`GLM-5.2` until Z.ai publishes distinct 5.3 metadata, and 5.3 carries no\nprice. Flash ships the published $0.15/$0.50 list. A live call can still\n429 with entitlement code 1311 on accounts that are not provisioned for 5.3.\n\n## Static Model Registry\n\nThis table records legacy selector compatibility. Current `codewhale model\nresolve`, `codewhale model list`, the picker and Runtime API use the shared\nprovider lake and route resolver. `models --update` refreshes the existing\ncatalog cache; later routes see new metadata, while dispatched requests retain\ntheir admitted limits and price receipt.\n\nThe reviewed `selections` in `crates/config/assets/catalog_corrections.json`\nremain compatibility metadata for legacy callers. They do not establish account\navailability or executable route permission. Public discovery can contain\nmetadata-only models; an authenticated provider roster establishes a different\nfact. A same-named custom endpoint keeps its own declared limits and prices.\n\n`codewhale model resolve MODEL --provider PROVIDER` reports the exact wire ID,\ncanonical ID when declared, limits and implemented capabilities. Runtime\n`GET /v1/providers/PROVIDER/models` adds source, fetch/expiry metadata, published\nprice structure, reasoning options, configured effective limits and the price\nstatus. `account_access: unverified` means these facts have not established the\nselected account's access. Context responses distinguish an active engine\nwindow from the static route's resolution source, including any fallback.\nSee [Catalog refresh](CATALOG_REFRESH.md) for freshness and estimate boundaries.\n\n| Provider | Static registry entries | Tool calls | Registry reasoning flag |\n| --- | --- | --- | --- |\n| `deepseek` | `deepseek-v4-pro`, `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp` | yes | yes |\n| `nvidia-nim` | `deepseek-ai/deepseek-v4-pro`, `deepseek-ai/deepseek-v4-flash` | yes | yes |\n| `openai` | `deepseek-v4-pro`, `deepseek-v4-flash`, `gpt-5.6`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` | yes | yes |\n| `atlascloud` | `deepseek-ai/deepseek-v4-flash`, `deepseek-ai/deepseek-v4-pro` | yes | yes |\n| `wanjie-ark` | `deepseek-reasoner` | yes | yes |\n| `volcengine` | `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash` | yes | yes |\n| `openrouter` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`, `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `minimax/minimax-m2.7`, `xiaomi/mimo-v2.5-pro`, `xiaomi/mimo-v2.5`, `qwen/qwen3.6-flash`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`, `qwen/qwen3.6-plus`, `qwen/qwen3.7-max`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`, `z-ai/glm-5.1`, `z-ai/glm-5.2`, `z-ai/glm-5.3`, `z-ai/glm-5.3-flash`, `z-ai/glm-5-turbo`, `tencent/hy3-preview`, `google/gemma-4-31b-it`, `google/gemma-4-26b-a4b-it`, `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`, `nvidia/nemotron-3-ultra-550b-a55b` | yes | yes |\n| `orcarouter` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`, `orcarouter/auto` | yes | yes |\n| `xiaomi-mimo` | `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, `mimo-v2.5`; speech/TTS IDs are selected through `codewhale speech` / `tts` | yes | yes for chat models; no for speech/TTS models |\n| `novita` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | yes | yes |\n| `fireworks` | `accounts/fireworks/models/deepseek-v4-pro` | yes | yes |\n| `siliconflow` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes |\n| `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` |\n| `moonshot` | `kimi-k2.7-code`, `kimi-k2.6` | yes | yes |\n| `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 |\n| `stepfun` | `step-3.7-flash` (default), `step-5-preview`, `step-3.5-flash`, `step-3.5-flash-2603` | yes | yes |\n| `minimax` | `MiniMax-M3`, `MiniMax-M2.7`, `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `MiniMax-M2.1`, `MiniMax-M2.1-highspeed`, `MiniMax-M2` | yes | yes |\n| `minimax-anthropic` | `MiniMax-M3`, `MiniMax-M2.7` | yes | yes |\n| `sglang` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes |\n| `vllm` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes |\n| `ollama` | live local tag; custom tags pass through when provider hint is `ollama` | yes | no |\n| `ollama-cloud` | `gpt-oss:120b`; arbitrary provider-owned model IDs pass through | yes | yes |\n| `huggingface` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | no |\n| `modelscope` | `Qwen/Qwen3.5-397B-A17B`, `Qwen/Qwen3.5-122B-A10B`, `Qwen/Qwen3.5-27B`, `Qwen/Qwen3.5-35B-A3B`, `Qwen/Qwen3.8-27B`, `Qwen/Qwen3.8-Flash-Next`, `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Pro-0813`, `deepseek-ai/DeepSeek-V4.1-Flash`, `ZhipuAI/GLM-4.7-Flash`, `ZhipuAI/GLM-5.2` | yes | no |\n| `deepinfra` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes |\n| `together` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash`, `thinkingmachines/inkling` | yes | yes |\n| `openai-codex` | `gpt-5.5` | yes | yes |\n| `anthropic` | `claude-opus-5`, `claude-opus-4-8`, `claude-sonnet-5`, `claude-sonnet-4-6`, `claude-fable-5`, `claude-haiku-4-5` | yes | yes except `claude-haiku-4-5` |\n| `openmodel` | `deepseek-v4-flash`; provider-scoped custom model IDs pass through | yes | model-dependent |\n| `sakana` | `fugu`, `fugu-ultra-20260615` | yes | yes for `fugu-ultra-20260615` |\n| `longcat` | `LongCat-2.0` | yes | yes |\n| `opencode-go` | `deepseek-v4-pro`, `grok-4.5`, `glm-5.2`, `glm-5.1`, `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.6`, `deepseek-v4-flash`, `mimo-v2.5`, `mimo-v2.5-pro` | yes | yes |\n| `meta` | `muse-spark-1.2` | yes | yes |\n| `xai` | `grok-4.6`, `grok-4.7`, `grok-4.5`, `grok-4.3`, `grok-build`, `grok-composer-2.5-fast`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning` | yes | yes for `grok-4.6`, `grok-4.7`, `grok-4.5`, `grok-4.3`, `grok-build`, and `grok-4.20-0309-reasoning` |\n| `google` | `gemini-3.1-pro-preview`, `gemini-3-pro-preview`, `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-2.5-pro`, `gemini-2.5-flash` | yes | yes except `gemini-3.5-flash-lite` |\n| `mistral` | `mistral-code-latest`, `mistral-medium-latest`, `mistral-small-latest`, `mistral-large-latest` | yes | yes for Medium and Small (`reasoning_effort` `none` or `high` on exact first-party routes); deprecated native Magistral remains an always-on explicit compatibility ID; no for Code and Large |\n| `modelstudio-token-plan`, `modelstudio-coding-plan` | `qwen3.8-max`, `qwen3.8-max-preview`, `qwen3.7-plus`, `qwen3.7-max`, `qwen3.6-flash`, `deepseek-v4-pro`, `deepseek-v4-flash-0731`, `glm-5.2` | yes | yes |\n| `csdn` | `glm_for_coding`; other marketplace model IDs pass through | yes | yes |\n\nAtlasCloud keeps the same default model as the config layer and adds\nprovider-scoped aliases for the Pro and Flash rows. Other AtlasCloud model IDs\nshould still be selected through `ATLASCLOUD_MODEL`, config, or live model\nlisting when available.\n\n## Capability Metadata\n\n`codewhale doctor --json` exposes the `capability` object. It is static\nmetadata, not a live API probe. Current fields are:\n\n`resolved_provider`, `resolved_model`, `context_window`, `max_output`,\n`thinking_supported`, `cache_telemetry_supported`, and `request_payload_mode`.\n\nWhen configuration cannot be loaded or validated, `doctor --json` exits\nnonzero and prints a bounded, secret-redacted JSON error envelope with\n`status = \"error\"` and `error.kind = \"config_validation\"` instead of emitting\nmisleading route or capability metadata.\n\nMost shipped providers use the Chat Completions request payload mode. Native\nMessages routes, including `minimax-anthropic`, use `/v1/messages`, and\n`openai-codex` uses Responses.\n\nFor OpenAI-compatible gateways or self-hosted runtimes whose real window\ndiffers from the static table, set `[providers.] context_window = N`.\nThe configured value becomes the route-effective context window for prompts,\ncontext-pressure checks, compaction, and output-cap budgeting.\n\n`max_output` is optional and truthful: it is `null` (and omitted from the\ncapability struct on the wire) when the route publishes no output maximum we\ncan stand behind — the Kimi Code membership `kimi-for-coding` family is the\ncanonical example, since the membership catalog owns their limits. An unknown\noutput ceiling is never backfilled with a placeholder, and it applies **no**\ncompatibility clamp to a turn's requested `max_tokens`; only a concrete\nroute/offering maximum narrows the request. A model the catalogue simply has no\nrow for is a different fact — absence is not permission, so an uncatalogued id\nkeeps a conservative ceiling. The \"Max output metadata\" column below reads\n`unknown` wherever no documented maximum exists.\n\nThe exact Kimi Code membership roster contains `k3`, `k3-256k`,\n`kimi-for-coding`, and `kimi-for-coding-highspeed`. The two K3 ids share the\nsame reasoning and fixed-sampling contract; `k3-256k` stays at 262,144 tokens,\nwhile bare `k3` can use an entitled 1M override.\n\n| Provider/model class | Context window | Max output metadata | Thinking support | Cache telemetry | FIM endpoint |\n| --- | --- | --- | --- | --- | --- |\n| DeepSeek V4 (`deepseek-v4-pro`, `deepseek-v4-flash`) | 1,000,000 | 384,000 | yes | yes | DeepSeek beta only, on Chat Completions routes; not on the default Flash route, which speaks Responses |\n| DeepSeek V4 Flash Vision experimental (`deepseek-v4-flash-vision-exp`) | 1,000,000 inherited from Flash | 384,000 inherited from Flash | yes, inherited | yes, inherited | not claimed; Chat Completions route only |\n| DeepSeek compatibility aliases (`deepseek-chat`, `deepseek-reasoner`) | 1,000,000 | 384,000 | yes | yes | DeepSeek beta only |\n| NVIDIA NIM V4 registry models | 1,000,000 | 384,000 | yes | yes | not documented in code |\n| Volcengine Ark V4 model IDs | 1,000,000 | 384,000 | yes | yes | not documented in code |\n| OpenRouter, Novita, Fireworks, SiliconFlow, SGLang, and vLLM V4 model IDs | 1,000,000 | 384,000 | yes | no | not documented in code |\n| Xiaomi MiMo `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, `mimo-v2.5` | 1,000,000 | 131,072 | yes | no | not documented in code |\n| OpenRouter Qwen 3.6 Flash / Plus | 1,000,000 | 65,536 | yes | no | not documented in code |\n| OpenRouter Qwen 3.6 35B / 27B | 262,144 | 262,140 | yes | no | not documented in code |\n| OpenRouter Qwen 3.6 Max Preview | 262,144 | 65,536 | yes | no | not documented in code |\n| OpenAI API `gpt-5.5` | 1,050,000 | 128,000 | yes | no | not documented in code |\n| OpenAI API `gpt-5.6`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` | 1,050,000 | 128,000 | yes | no | not documented in code |\n| Anthropic API `claude-opus-5`, `claude-opus-4-8`, `claude-sonnet-5`, `claude-sonnet-4-6`, `claude-fable-5` | 1,000,000 | 128,000 | yes | yes | not documented in code |\n| Google Gemini API `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-3.1-pro-preview`, `gemini-2.5-pro`, `gemini-2.5-flash` | 1,048,576 | 65,536 | model-dependent | no | not documented in code |\n| Meta Model API `muse-spark-1.2` | 1,000,000 | 32,000 | yes | no | not documented in code |\n| ChatGPT plan route (`openai-codex`) | conservatively budgeted; account listing does not state a window | no output cap sent in preview | model dependent | no | public `/v1/responses`; no context limit is inferred from account eligibility |\n| OpenModel default/custom model IDs | 200,000 fallback unless model metadata or config overrides it | 64,000 fallback | model-dependent | no | route uses Messages payload at `/v1/messages` |\n| Wanjie Ark `reasoner` / `r1` model IDs | 128,000 | unknown (no documented maximum) | yes | no | not documented in code |\n| Direct Arcee API `trinity-large-thinking` | 262,144 | 262,144 | yes | no | not documented in code |\n| Direct Arcee API `trinity-large-preview` | 262,144 | unknown (no documented maximum) | no in doctor capability metadata | no | not documented in code |\n| Direct Moonshot `kimi-k3` | 1,048,576 | 1,048,576 documented maximum; 131,072 provider default | yes | no | exact route uses `max_completion_tokens` and omits fixed sampling fields ([K3 quickstart](https://platform.kimi.ai/docs/guide/kimi-k3-quickstart)) |\n| Kimi Code membership `k3` | 262,144 safe baseline; 1,048,576 with an explicit entitled-plan override | 131,072 conservative default ceiling; membership maximum is not published | yes | no | exact `https://api.kimi.com/coding/v1` route |\n| Kimi Code membership `k3-256k` | 262,144 fixed | 131,072 conservative default ceiling; membership maximum is not published | yes | no | exact `https://api.kimi.com/coding/v1` route |\n| Direct Moonshot/Kimi K2.7/K2.6 (`kimi-k2.7-code`, `kimi-k2.7-code-highspeed`, `kimi-k2.6`) | 262,144 | 32,768 | yes | no | provider-reported bundled catalog |\n| Kimi Code membership `kimi-for-coding`, `kimi-for-coding-highspeed` | 262,144 | unknown — the membership catalog owns these limits and no client-side ceiling is claimed | yes | no | exact `https://api.kimi.com/coding/v1` route |\n| Direct Z.AI `GLM-5.3` (default) | 1,000,000 | 131,072 | yes | no | live on the GLM Coding Plan; limits inherited from `GLM-5.2` until Z.ai publishes distinct 5.3 numbers; no USD price |\n| Direct Z.AI `GLM-5.3-Flash` | 1,000,000 | 131,072 | yes | no | natively multimodal; $0.15/$0.50 list (2026-08-26); faster/explore sibling of `GLM-5.3` |\n| Direct Z.AI `GLM-5.2` | 1,000,000 | 131,072 | yes | no | not documented in code |\n| Direct Z.AI `GLM-5.1` | 202,752 | 131,072 | yes | no | not documented in code |\n| Direct Z.AI `GLM-5-Turbo` | 202,752 | 131,072 | yes | no | faster/explore sub-agent sibling |\n| Direct MiniMax `MiniMax-M3` | 1,000,000 | 524,288 | yes | no | not documented in code |\n| Direct MiniMax M2.x models | 204,800 | unknown until MiniMax output metadata is promoted | yes | no | not documented in code |\n| MiniMax Messages route (`MiniMax-M3`, `MiniMax-M2.7`) | model-specific values above | model-specific values above | yes | no | route uses `/anthropic/v1/messages` |\n| Generic `openai` and AtlasCloud | 128,000 | unknown (no documented maximum) | no in doctor capability metadata | no | not documented in code |\n| Ollama | 8,192 | unknown (no documented maximum) | no | no | not documented in code |\n| Hugging Face Inference Providers V4 model IDs | 131,072 | unknown (no documented maximum) | yes | no | not documented in code |\n| Other recognized DeepSeek model IDs | 128,000 unless the model name carries an explicit `Nk` hint | unknown (no documented maximum) | no unless V4/reasoner logic matches | DeepSeek/NIM only | DeepSeek beta only |\n\nMiniMax M3 uses input-length and service tiers. Codewhale omits\n`service_tier`, so requests use the standard tier and cost estimates select the\ncorrect standard rate from total input usage. Priority rates are listed to keep\nthe official tier structure visible. Prices are USD per million tokens.\n\n| Model / service tier | Input length | Input | Output | Cache read | Cache write |\n| --- | --- | ---: | ---: | ---: | ---: |\n| `MiniMax-M3` standard | up to 512,000 input tokens | $0.30 | $1.20 | $0.06 | not published |\n| `MiniMax-M3` standard | over 512,000 input tokens | $0.60 | $2.40 | $0.12 | not published |\n| `MiniMax-M3` priority | up to 512,000 input tokens | $0.45 | $1.80 | $0.09 | not published |\n| `MiniMax-M3` priority | over 512,000 input tokens | $0.90 | $3.60 | $0.18 | not published |\n| `MiniMax-M2.7` standard | all supported inputs | $0.30 | $1.20 | $0.06 | $0.375 |\n\nThese values come from the [MiniMax pay-as-you-go pricing\nguide](https://platform.minimax.io/docs/guides/pricing-paygo). M3 thinking is\nadaptive or disabled; the OpenAI-compatible API defaults to adaptive and the\nAnthropic-compatible API defaults to disabled. M2.7 thinking cannot be\ndisabled. Codewhale sends explicit controls when the user selects a reasoning\nmode.\n\nTool-call support is tracked separately by the static `ModelRegistry` and by\nthe endpoint's ability to accept OpenAI-compatible `tools` payloads. A custom\nOpenAI-compatible or local endpoint can still reject tool calls even if\nCodewhale can send the schema.\n\n### Hugging Face Inference Providers Notes\n\nThe shipped Hugging Face route targets the OpenAI-compatible Inference Providers\nrouter at `https://router.huggingface.co/v1`. Configure auth with\n`HUGGINGFACE_API_KEY` first, or `HF_TOKEN` as a fallback. Configure the endpoint\nwith `HUGGINGFACE_BASE_URL` first, or `HF_BASE_URL` as a fallback; configure the\nmodel with `HUGGINGFACE_MODEL` first, or `HF_MODEL` as a fallback.\n\nThis route does not imply Hub browsing, model-card metadata, dataset access,\nJobs, uploads, or export. Those remain explicit Model Lab work items so\nprovider auth and artifact movement stay separate.\n\n### When a Local Model Prints Tool JSON\n\nCodewhale only executes tools when the provider returns Chat Completions\n`tool_calls` or streamed `delta.tool_calls`. If a local model prints text such\nas `{\"name\":\"File\",\"arguments\":{\"action\":\"search_content\",...}}` in the\nassistant message, that is ordinary model output, not an executable tool\nrequest.\n\nFor OpenAI-compatible or local runtimes, check:\n\n- The endpoint accepts the `tools` array in `/v1/chat/completions` requests.\n- The selected model or chat template is configured for function/tool calls.\n- The server returns `tool_calls` in the response rather than plain JSON text.\n- The compatibility layer does not strip tools before forwarding the request.\n- If in doubt, test a small `File` `read` or `search_content` action against a\n known tool-calling model before debugging Codewhale's tool registry.\n\nChanging `provider`, `base_url`, or `model` can select a route that supports the\nOpenAI-compatible payload shape, but Codewhale cannot convert arbitrary JSON\ntext into a trusted tool call after the model has emitted it as prose.\n\nDeepSeek will retire `deepseek-chat` and `deepseek-reasoner` on 2026-07-24 at\n15:59 UTC. Codewhale migrates either name to `deepseek-v4-flash` before a\nrequest reaches DeepSeek's first-party OpenAI or Anthropic endpoint. If no\nreasoning tier was configured, `deepseek-chat` also migrates to `off` and\n`deepseek-reasoner` to `high`, preserving their former non-thinking / thinking\nintent; an explicit `reasoning_effort` remains authoritative. The mapping is\ndeliberately not global: Wanjie Ark, aggregators, self-hosted runtimes, and\ncustom endpoints continue to own their model ids.\n\n## Reasoning Effort\n\n`/reasoning ` (and the `reasoning_effort` config key) is translated to\neach provider's wire dialect by the client before the request is sent. `off`\ndisables thinking where the route supports it. Both exact K3 routes map `off`\nto their lowest supported tier, `low`, and the model is never switched to\nsatisfy `off` — but they do so for different reasons:\n\n- **Kimi Code membership K3** (exact `https://api.kimi.com/coding/v1` with\n `model = \"k3\"` or `model = \"k3-256k\"`) — the membership roster declares K3 always-thinking, so `off`\n cannot be honored without changing what the model is. The clamp preserves the\n fixed K3 identity.\n- **Direct Moonshot K3** (exact `https://api.moonshot.ai/v1` with\n `model = \"kimi-k3\"`) — this clamp is *defensive*, not a documented contract.\n The direct platform publishes no `off` state for K3, and Codewhale will not\n assert a fixed-thinking guarantee it cannot verify for a given key's\n entitlement, so the requested `off` is normalized to the lowest tier with the\n live entitlement left unknown.\n\nNormal dispatched\n`auto` uses Codewhale's auto-reasoning selector and sends a concrete tier;\nonly an omitted reasoning setting leaves the provider default in control.\nProviders marked \"omitted\" receive no reasoning fields at all for that tier.\n\n| Provider | `off` | `low`/`medium`/`high` | `max`/`xhigh` |\n| --- | --- | --- | --- |\n| `deepseek`, `deepseek-cn`, `siliconflow`, `siliconflow-CN`, `sglang`, `volcengine`, `atlascloud` | `thinking: {type: disabled}` | `reasoning_effort: \"high\"` + `thinking: {type: enabled}` | `reasoning_effort: \"max\"` + `thinking: {type: enabled}` |\n| `openrouter`, `novita`, other `together` models | `thinking: {type: disabled}` | `reasoning_effort` pass-through + `thinking: {type: enabled}` | `reasoning_effort: \"xhigh\"` + `thinking: {type: enabled}` |\n| `together` + `thinkingmachines/inkling` | `reasoning_effort: \"none\"` | exact `minimal`/`low`/`medium`/`high` `reasoning_effort` | `reasoning_effort: \"max\"` |\n| Direct Moonshot `kimi-k3` at exact `https://api.moonshot.ai/v1` | top-level `reasoning_effort: \"low\"` (effective normalization) | top-level `reasoning_effort: \"low\"` / `\"high\"` (`medium` becomes `high`) | top-level `reasoning_effort: \"max\"` |\n| Kimi Code membership `k3`, `k3-256k` at exact `https://api.kimi.com/coding/v1` | `thinking: {type: enabled, effort: \"low\"}` (effective normalization) | `thinking: {type: enabled, effort: \"low\" | \"high\"}` | `thinking: {type: enabled, effort: \"max\"}` |\n| Other `moonshot` routes | `thinking: {type: disabled}` | `thinking: {type: enabled}` | `thinking: {type: enabled}` |\n| `ollama` | `think: false` | `think: true` | `think: true` |\n| `ollama-cloud` | `reasoning_effort: \"none\"` | exact `low`/`medium`/`high` `reasoning_effort` | `reasoning_effort: \"max\"` |\n| `xiaomi-mimo` | `thinking: {type: disabled}` | `thinking: {type: enabled}` | `thinking: {type: enabled}` |\n| First-party `minimax` `MiniMax-M3` | `reasoning_split: true` + `thinking: {type: disabled}` | `reasoning_split: true` + `thinking: {type: adaptive}`; effective tier granularity unavailable | `reasoning_split: true` + `thinking: {type: adaptive}`; effective tier granularity unavailable |\n| First-party Z.ai `GLM-5.2` | `thinking: {type: disabled}`; no `reasoning_effort` | enabled thinking; only effective `high` adds `reasoning_effort: \"high\"` | enabled thinking + `reasoning_effort: \"max\"` |\n| First-party Z.ai `GLM-5.3` | `thinking: {type: disabled}`; no `reasoning_effort` | enabled thinking; only effective `high` adds `reasoning_effort: \"high\"` | enabled thinking + `reasoning_effort: \"max\"` |\n| First-party Z.ai `GLM-5.3-Flash` | `thinking: {type: disabled}`; no `reasoning_effort` | enabled thinking; only effective `high` adds `reasoning_effort: \"high\"` | enabled thinking + `reasoning_effort: \"max\"` |\n| First-party Z.ai `GLM-5-Turbo` | `thinking: {type: disabled}` | enabled thinking; effort granularity unavailable | enabled thinking; effort granularity unavailable |\n| Compatible gateways configured as `zai` | omitted; effective unavailable | omitted; effective unavailable | omitted; effective unavailable |\n| `nvidia-nim` | `chat_template_kwargs.thinking: false` | `chat_template_kwargs`: `thinking: true` + `reasoning_effort: \"high\"` | `chat_template_kwargs`: `thinking: true` + `reasoning_effort: \"max\"` |\n| `vllm` | `chat_template_kwargs.enable_thinking: false` | `chat_template_kwargs.enable_thinking: true` + `reasoning_effort` low/medium/high | `chat_template_kwargs.enable_thinking: true` + `reasoning_effort: \"high\"` (vLLM has no max tier) |\n| `arcee`, `huggingface` | omitted | `reasoning_effort` pass-through | `reasoning_effort: \"high\"` |\n| `fireworks` | omitted | `reasoning_effort: \"high\"` | `reasoning_effort: \"max\"` |\n| `openai`, `wanjie-ark`, `telecomjs` | omitted | omitted | omitted |\n| `openmodel` | Anthropic Messages adapter handles thinking/output configuration | Anthropic Messages adapter handles thinking/output configuration | Anthropic Messages adapter handles thinking/output configuration |\n| `openai-codex` | Responses API `reasoning` field (handled by the Responses bridge) | Responses API `reasoning` field | Responses API `reasoning` field |\n\nAtlasCloud serves DeepSeek models, so it speaks the DeepSeek reasoning dialect,\nincluding the `max` tier (#3024).\n\nOn the exact MiniMax OpenAI-compatible Chat endpoints, `MiniMax-M3` uses\n`max_completion_tokens`. Other MiniMax models and compatible gateways retain\n`max_tokens`; the MiniMax Anthropic endpoints use the separate Messages\nadapter.\n\n## Drift Check\n\nRun this before changing provider IDs, provider TOML tables, static model\nregistry rows, or provider default strings:\n\n```bash\npython3 scripts/check-provider-registry.py\n```\n\nThe check fails when:\n\n- `docs/PROVIDERS.md` omits a canonical `ProviderKind::as_str()` ID.\n- Descriptor presentation IDs or released wire tags are duplicated or drift\n from intrinsic kinds, or a second provider enum/ordinal bridge is introduced.\n- The shipped-provider table omits or adds a `[providers.*]` TOML table.\n- The static model registry table drifts from providers used by\n `crates/agent/src/lib.rs`.\n- A provider default model or base URL constant in `crates/tui/src/config.rs`\n is no longer mentioned here.\n\n## Provider Gotchas\n\nThree failures that look like something else (measured by a provider operator\non live traffic):\n\n- **A cold model looks broken.** The first call to a model the provider has not\n loaded can take 90 to 150 seconds, then drop to a second or less once warm.\n Raise `[stream].open_timeout_secs` (default 45 seconds, at most 300) before reading a timeout as \"model unavailable\".\n- **Listed is not callable.** A catalog entry can be visible and still fail\n every call. The same error text on every attempt (for example a \"resources\n busy\" 502) is not transient: a genuinely busy upstream returns successes in\n between. Probe a model before trusting it, and stop retrying one that fails\n identically.\n- **A truncated answer can report `stop`.** A reasoning model can spend the\n whole `max_tokens` allowance on reasoning. When the provider reports\n `completion_tokens` exactly equal to the `max_tokens` the request asked for\n and the finish reason is `stop`, Codewhale treats the turn as cut at the\n ceiling rather than finished (`stop_contradicts_output_ceiling` in\n `codewhale-models`); retrying the same prompt stops at the same count, so\n raise the ceiling or shorten the task instead. No inference is made when the\n provider reports no usage.\n\n## Planned, Not Shipped Yet\n\nThese items belong to the v0.8.48+ provider-abstraction milestone or related\nprovider docs work, but they are not native shipped behavior in this checkout:\n\n- A unified `Provider` trait in `codewhale-agent` that owns env precedence,\n secret resolution, base URL normalization, auth-header construction, and\n provider metadata. Those responsibilities are still split across\n `crates/config`, `crates/secrets`, and `crates/tui/src/client.rs`.\n- Hugging Face model passport metadata in the picker, including license, base\n model, context length, chat template, tool-call support, reasoning support,\n and gated/private status.\n\n## Sign in with Claude\n\nRun `codewhale auth claude` (or `/auth claude` in a running session), open the\nprinted Claude sign-in URL, and paste the complete `code#state` callback.\n`/provider setup anthropic` also offers API-key and Claude Pro / Max choices.\nThen select `anthropic` or its `claude` alias. API keys continue to use separate\nAnthropic API billing; selecting subscription sign-in never falls back to one.\n\nCodewhale stores the grant in its private credentials directory, commits the\nprovider's credential pointer atomically, and refreshes it before requests on a\nworker. Subscription tokens go only to `https://api.anthropic.com`; a custom\nendpoint is rejected. Each request keeps the existing Messages adapter, tool\nprotocol, cache prefix, and Codewhale client identity. No Claude Code credential\nfile or keychain entry is read or modified.\n\n`codewhale auth claude-revoke` (or `/auth claude-revoke`) removes the owned local\ngrant. End remote access separately in your Claude account settings. The\nsubscription route then needs another login; select API-key billing explicitly\nto use an API key. Login and inference follow Anthropic's provider-side\navailability; local checks do not\nestablish that an account is eligible or that live subscription inference works.\n\nThe callback and JSON token exchange follow the interoperable flow used by\n[pi's Anthropic OAuth adapter](https://github.com/fivewillow/badlogic-pi-mono/blob/main/packages/ai/src/utils/oauth/anthropic.ts).\n" + "sha256": "1800d37eadc1163bae5f5f5293801bbb1282531c0b6810f079ad3d845ec3f2ae", + "content": "# Provider Registry\n\n> 阅读简体中文版:[zh_hans/PROVIDERS.md](zh_hans/PROVIDERS.md)\n\nSpeech recognition, synthesis, translation and video have separate execution\npaths. See [media models](MEDIA_MODELS.md) for current support and reviewed\nupstream candidates; a chat catalogue row alone does not add a media backend.\n\nThis registry describes provider behavior that is wired into the current\nCodewhale codebase. It is intentionally conservative: shipped entries are\nlimited to provider IDs, config keys, auth paths, base URLs, model resolution,\nand capability metadata that the code already knows about.\n\nDeepSeek remains the default provider, but every entry in `ProviderKind::ALL`\nis a first-class selectable provider route. `ALL` is the catalog/picker\nsurface — one identity per vendor. Dual-wire dialect kinds (`*Anthropic`, e.g.\n`deepseek-anthropic`) and the Model Studio plan variants stay on the enum for\nserde and `provider_for_kind` but are deliberately **not** catalog rows:\na plan is `mode`/`base_url` and a dialect is `wire = openai|anthropic` on the\nprimary provider config (`crates/config/src/provider_kind.rs:221-226`). Hosted\nroutes, generic OpenAI-compatible endpoints, the OpenAI Codex/ChatGPT route,\nnative Anthropic, and local runtimes all run the same terminal harness against\nthe selected provider/model/base URL.\n\nA host reached over plain Chat Completions is an ordinary named provider,\nnot a `ProviderKind`: enum variants are reserved for distinct *wires*\n(Anthropic Messages, Codex Responses, Google thought signatures). Any such\nhost is a `[providers.]` table with a base URL, a model, and a key env\n(`docs/CONFIGURATION.md`); `/provider` and `/setup` keep a \"paste a Base URL\nand a key\" path for exactly this. Offerings come from live `GET /v1/models`\nplus the Codewhale catalog rather than a compiled roster (#5350, #6289).\n\nKnown-good hosts. These ship as bundled descriptor rows in\n`crates/config/assets/provider_descriptors.json` (compiled in by\n`crates/config/src/descriptors.rs`): each row says how to reach the host — wire,\nbase URL, key env, aliases — while model ids stay live from `GET /v1/models`\nand the Codewhale catalog; the example model is only a bootstrap hint. Verify\nagainst the vendor's own docs before trusting any value here:\n\n| Host | Base URL | Example models | API key env |\n| --- | --- | --- | --- |\n| SenseNova | `https://token.sensenova.cn/v1` | `deepseek-v4-flash` | `SENSENOVA_API_KEY` |\n| Baseten | `https://inference.baseten.co/v1` | `deepseek-ai/DeepSeek-V4-Pro` | `BASETEN_API_KEY` |\n| Groq | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | `GROQ_API_KEY` |\n| Cerebras | `https://api.cerebras.ai/v1` | `llama-3.3-70b` | `CEREBRAS_API_KEY` |\n| Command Code | `https://api.commandcode.ai/provider/v1` | `deepseek/deepseek-v4-flash` | `COMMAND_CODE_API_KEY` |\n| Alibaba Model Studio (DashScope) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `qwen3.8-flash` | `DASHSCOPE_API_KEY` |\n| AICraft | `https://aicraftapi.com/v1` | `claude-4.6-sonnet`; DeepSeek / Claude / Gemini / Qwen / GLM / MiniMax / Doubao families | `AICRAFT_API_KEY` |\n| Tsubasa | `https://api.tsubasa.sh/v1` | `tsubasa-pro`, `tsubasa-fast` (32,768-token context) | `TSUBASA_API_KEY` |\n| Cheaper Inference | `https://api.cheaperinference.com/v1` | `gpt-5.4-mini`, `claude-sonnet-5`, `gemini-3.1-pro` | `CHEAPER_INFERENCE_API_KEY` |\n| Yolo-Auto | `https://yolo-auto.com/v1` | `qwen3.8-flash`; `yolo` / `yolo-small` | `YOLO_AUTO_API_KEY` |\n\nAICraft's roster spans DeepSeek, Anthropic Claude, Google Gemini, Qwen, GLM,\nMiniMax and Doubao ids on its OpenAI-compatible endpoint. The authority is\n`GET https://aicraftapi.com/v1/models` with your key — pick a model from that\nlist, not from this table.\nTsubasa implements only `GET /v1/models` and Chat Completions. Its two public\nmodel ids share a 32,768-token context, smaller than the 128,000 tokens Codewhale assumes\nfor an unknown model, so set it on the route after saving:\n`codewhale config set providers.tsubasa.context_window 32768`.\n\nCheaper Inference is an OpenAI-compatible gateway with one key for models from\nseveral labs. Model ids are bare, such as `gpt-5.4-mini` or `claude-sonnet-5`.\nPricing varies by model and route; consult the provider’s current catalog.\nThe authority is `GET https://api.cheaperinference.com/v1/models` with your key.\nDocs: .\n\nOpenCode Zen and OpenCode Go are first-class provider routes, configured like\nany other provider below; they are not part of this table. In `/provider`,\ntype to filter the list (letters not bound to a row action); `Ctrl+T` probes the\nselected row's `/models` and records reachability only (a 2xx is not\nmodel-ready).\n\nSources to keep in sync:\n\n- `crates/config/assets/provider_descriptors.json` - built-in and compatible-host\n labels, defaults, aliases, key-env lists, config/secret slots, and credential\n guidance. `crates/config/build.rs` generates immutable typed views and the\n existing constant projections from this one data owner.\n- `crates/config/src/provider_kind.rs` and `src/lib.rs` - legacy identity serde,\n config schema, environment precedence and Rust route/auth behavior.\n- `crates/tui/src/config.rs` - captures and verifies exact configured provider\n identities and keeps provider-specific credential and route policy in Rust.\n- `crates/config/assets/catalog_corrections.json` `reviewed` - intrinsic facts,\n scoped selector aliases, completion references and pure transport metadata.\n The existing seed renderer embeds this reviewed supplement in Models.dev.\n- `crates/tui/src/provider_lake.rs` and `route_runtime.rs` - resolve current\n model facts for both CLI model commands, the picker and Runtime API. Public\n catalog facts, provider/account rosters and explicit configuration retain\n their separate ownership. `crates/agent/src/lib.rs` keeps the legacy selector\n compatibility projection; it does not decide the CLI's current route.\n- `config.example.toml` and `docs/CONFIGURATION.md` - user-facing config\n examples and environment variable reference.\n- `scripts/check-provider-registry.py` - drift check for canonical provider\n IDs, live TUI provider IDs, TOML table names, static registry rows, and\n documented defaults.\n\n## Captured Provider Identity\n\nPresentation names, labels, aliases, and historical wire tags come from\n`provider_descriptors.json`. `ProviderKind` remains the Rust-owned intrinsic\ncredential, protocol, and region distinction. A configured route captures both\nits kind and exact table key; a custom table named `openai` stays custom and\ndoes not acquire OpenAI credentials or protocol rules from its name. Case is\nsignificant for custom keys.\n\nDeepSeek China has three preserved representations: `deepseek_c_n` in released\nTUI serde wrappers, `deepseek-cn` as its captured route ID, and `deepseek_cn` as\nits config leaf. Its intrinsic kind is DeepSeek, while its table and endpoint\nstay distinct.\n\nAn old custom route without an additive provider ID can resume only with a\nverified root `base_url` migration into the active `providers.custom` table.\nThat private receipt is bound to the parsed table generation. A table-only,\nprofile-only, conflicting, or later replaced table cannot establish the\nmissing identity; an explicit empty ID is refused. Writes reuse the existing\nlocked config mutation and undo comparison, obtain the fresh migration receipt\nunder that lock, and verify the captured table before changing a leaf.\n\nIn-flight requests retain their captured identity, endpoint, and credential\ngeneration. Health lookup cannot treat an opaque credential reference as ready\nmerely because an earlier request used the same authentication class.\n\n## Provider Selection\n\nWith no saved model, no `default_text_model`, and no `CODEWHALE_MODEL` or\nprovider-specific model variable, a fresh install runs `deepseek-flash` on the\nDeepSeek provider. Precedence is the active provider's configured default, then\nits catalog default — so an explicit `default_text_model` is not silently\noverridden by whichever model the shipped catalog lists first.\n\nRefresh model catalogs without installing a new Codewhale release:\n\n```sh\ncodewhale models --update\ncodewhale models --update --provider openai\ncodewhale models --provider openai --json\n```\n\n`models --update` (also `--refresh`) updates the shared Models.dev metadata\nand calls the existing `/models` endpoint for each configured provider with\nits own credentials. `--provider ID` restricts the refresh to that exact\nprovider, including named custom endpoints. It makes no inference requests\nand never changes the saved provider or model. A command-line API key is\nconfined to the active provider; other routes are reported as skipped for\nthat invocation.\n\nPlain `models` lists the active provider's saved catalog without provider requests.\nFor ChatGPT, it validates the selected registration locally before reading\nthat account's saved roster. Successful refreshes are saved under Codewhale's\ncatalog directory and used by the model/provider pickers. Cache files are\nscoped to provider identity and endpoint; a failed refresh preserves prior\nrows. Text output reports source, last successful fetch time (Unix seconds),\nand freshness. `--update --json` adds per-source receipts and aggregate counts;\npartial failures return a nonzero exit code after writing those receipts.\nOrdinary `models --json` keeps its model-array format. Bundled/configured\nfallbacks are not proof that an account can use every listed model.\n\n### Sign in with ChatGPT\n\nThe `openai-codex` provider ID now uses OpenAI's official open-source\n[Sign in with ChatGPT flow](https://developers.openai.com/siwc/token-sharing-open-source).\nSign in and refresh the selected account's catalog:\n\n```sh\ncodewhale auth chatgpt\ncodewhale models --update --provider openai-codex\ncodewhale --provider openai-codex\n```\n\nSign-in opens your system browser, uses a loopback callback and PKCE, validates\nthe returned identity, and stores the issued registration and renewable tokens\nin Codewhale's protected credential storage. Permission to use your ChatGPT\nplan is separate from identity sign-in. A declined or missing plan grant stops\ninference; choose another provider explicitly if you want another billing path.\n`codewhale auth chatgpt-revoke` signs out of the Codewhale-owned session.\nThis experimental implementation retains one active registration. A picker\nfor multiple saved ChatGPT accounts is not implemented yet.\n\nModel discovery uses your granted bearer token at\n`GET https://api.openai.com/v1/models`; inference uses the public\n`POST https://api.openai.com/v1/responses` endpoint. The model picker keeps\nOpenAI's display labels and order and lists only entries with `visibility: list`.\nThe catalog contains no credentials and is bound to the verified issuer,\nissued client ID, and account subject. Another account or workspace never\ninherits those model choices. Missing, invalid, or older-than-one-day rosters\nare reported explicitly and provide no account entitlement evidence. Run the\nrefresh command after signing in. Neither model discovery nor ordinary listing\nstarts an inference request. Imported Codex CLI tokens and legacy process-token\nvariables cannot authorize this official plan route.\n\nEligible requests consume your ChatGPT plan or credits. ChatGPT Plus's\nfive-hour allowance is shared across apps; each app receives no separate\nallowance. The documented five-hour limit does not apply to Pro. App-specific\nlimits can also apply. Review limits and access in\n[ChatGPT usage settings](https://chatgpt.com/settings/usage). Codewhale does\nnot silently switch to an API key or another provider when a limit is reached.\n\nThis is an OpenAI preview for open-source/local apps. It does not grant access\nto ChatGPT conversation history. Requests stream with `store: false` and\n`stream: true`, while Codewhale retains its own session history and tools.\nOpenAI-hosted image generation, file search, Code Interpreter, native computer\nuse, hosted MCP/connectors, and Responses `tool_search` are unavailable on this\nroute; Codewhale's own tools use supported function/custom tool calls.\nThe ChatGPT preview requests one function call at a time.\nSee [preview limitations](https://developers.openai.com/siwc/token-sharing-open-source/preview-limitations).\nPaid or remotely hosted applications require the\n[commercial partner interest process](https://openai.com/form/sign-in-with-chatgpt-interest/);\nthis local integration is not commercial approval.\n\nThe canonical provider IDs are the entries of `ProviderKind::ALL`\n(`crates/config/src/provider_kind.rs`), in that order:\n\n`deepseek`, `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`,\n`openrouter`, `orcarouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `arcee`,\n`siliconflow-CN`, `moonshot`, `sglang`, `vllm`, `ollama`, `ollama-cloud`, `huggingface`,\n`together`, `qianfan`, `openai-codex`, `anthropic`, `openmodel`, `zai`,\n`stepfun`, `minimax`, `deepinfra`, `sakana`, `longcat`, `opencode-go`,\n`opencode-zen`, `meta`, `xai`, `mistral`, `telecomjs`, `modelstudio-token-plan`, `modelscope`,\n`google`, `edenai`, `zenmux`, `csdn`, `concentrate`, `codewhale`, and `custom`.\n\n`deepseek-anthropic` is *not* on this list — it is a wire dialect of\n`deepseek`, reached with `wire = \"anthropic\"`, not a separate route to select.\n\nUse any of these surfaces to select a provider:\n\n- CLI: `codewhale --provider `\n- TUI: `/provider ` or the provider picker\n- Env: `CODEWHALE_PROVIDER=`; `DEEPSEEK_PROVIDER=` is the legacy alias\n- Config: `provider = \"\"`\n\n`deepseek-cn`, `deepseek_china`, `deepseekcn`, and `deepseek-china` are accepted\nas legacy aliases for `deepseek`. They do not select a different official host;\nDeepSeek uses the same official API host worldwide.\n\n`deepseek_anthropic`, `deepseek-claude`, and `deepseek_claude` select\n`deepseek-anthropic`, the opt-in DeepSeek route that speaks the Anthropic\nMessages API at `https://api.deepseek.com/anthropic`. It keeps the normal\nDeepSeek API key path but uses `x-api-key` plus `anthropic-version: 2023-06-01`\ninstead of Bearer auth. If the key already lives in official DeepSeek Harness\n(`dsh`) at `$DSH_HOME/.credentials.yaml`, grant read-only access with\n`codewhale auth external-consent --provider deepseek --mode read-only`.\nCodewhale never writes that file and only reads `DEEPSEEK_API_KEY`.\n\n`huggingface`, `hugging-face`, `hugging_face`, and `hf` all select the\nHugging Face Inference Providers route. This is the OpenAI-compatible router\npath for chat/inference, not Hub browsing, model-card inspection, uploads, or\nartifact export.\n\n`telecomjs`, `telecom-js`, `telecom_js`, `telecomjs-cn`, and `tokenhub` all\nselect the TelecomJS TokenHub route. Its authenticated `/models` catalog is\nkey-scoped and remains isolated from every other provider's live snapshot.\n\nFresh shared config writes to `~/.codewhale/config.toml`. Existing\n`~/.deepseek/config.toml` files are still read for compatibility.\n\n### Legacy Antigravity tombstone\n\nAntigravity is not a Codewhale provider and cannot be selected or run. Existing\nlegacy Antigravity provider state is recognized only as a non-runnable migration\ntombstone. Run `codewhale auth clear --provider antigravity` to forget only\nCodewhale-owned legacy configuration and consent metadata. This does not sign\nout of, revoke, read, or otherwise alter any official Google or Antigravity\nsession. For Gemini, select the supported `google` provider and supply\n`GEMINI_API_KEY`.\n\n### Wire Protocol Compatibility\n\nProvider selection is explicit. A model string prefix such as\n`deepseek-ai/...`, `deepseek/...`, `qwen/...`, or `arcee-ai/...` is a\nprovider-owned wire ID or catalog namespace hint under the selected provider.\nIt is not a provider switch and must not be treated as proof that the route is\nDeepSeek, OpenRouter, or any other provider.\n\nSet the route with `provider = \"\"`, `CODEWHALE_PROVIDER=`, or\n`codewhale --provider `. Set the request model with `CODEWHALE_MODEL`, a\nprovider-specific model env var, top-level `default_text_model`, or\n`[providers.
].model`. Set the endpoint with `CODEWHALE_BASE_URL`, a\nprovider-specific base URL env var, or `[providers.
].base_url`. Set auth\nwith `codewhale auth set --provider `, `[providers.
].api_key`, or\nthe listed provider env vars.\n\n| Provider ID | TOML table | Wire protocol | Auth env vars |\n| --- | --- | --- | --- |\n| `deepseek` | `[providers.deepseek]` | Model-aware: Responses default (`deepseek-flash`); Chat Completions (`deepseek-v4-pro`) | `DEEPSEEK_API_KEY` |\n| `deepseek-anthropic` | `[providers.deepseek_anthropic]` | Anthropic Messages | `DEEPSEEK_API_KEY` |\n| `nvidia-nim` | `[providers.nvidia_nim]` | OpenAI Chat Completions | `NVIDIA_API_KEY`, `NVIDIA_NIM_API_KEY` |\n| `openai` | `[providers.openai]` | OpenAI Chat Completions | `OPENAI_API_KEY` |\n| `atlascloud` | `[providers.atlascloud]` | OpenAI Chat Completions | `ATLASCLOUD_API_KEY` |\n| `wanjie-ark` | `[providers.wanjie_ark]` | OpenAI Chat Completions | `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, `WANJIE_MAAS_API_KEY` |\n| `volcengine` | `[providers.volcengine]` | OpenAI Chat Completions | `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, `ARK_API_KEY` |\n| `openrouter` | `[providers.openrouter]` | OpenAI Chat Completions | `OPENROUTER_API_KEY` |\n| `xiaomi-mimo` | `[providers.xiaomi_mimo]` | OpenAI Chat Completions | `XIAOMI_MIMO_TOKEN_PLAN_API_KEY`, `MIMO_TOKEN_PLAN_API_KEY`, `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, `MIMO_API_KEY` |\n| `novita` | `[providers.novita]` | OpenAI Chat Completions | `NOVITA_API_KEY` |\n| `fireworks` | `[providers.fireworks]` | OpenAI Chat Completions | `FIREWORKS_API_KEY` |\n| `siliconflow` | `[providers.siliconflow]` | OpenAI Chat Completions | `SILICONFLOW_API_KEY` |\n| `arcee` | `[providers.arcee]` | OpenAI Chat Completions | `ARCEE_API_KEY` |\n| `siliconflow-CN` | `[providers.siliconflow_cn]` | OpenAI Chat Completions | `SILICONFLOW_API_KEY` |\n| `moonshot` | `[providers.moonshot]` | OpenAI Chat Completions | `MOONSHOT_API_KEY`, `KIMI_API_KEY` |\n| `sglang` | `[providers.sglang]` | OpenAI Chat Completions | `SGLANG_API_KEY` |\n| `vllm` | `[providers.vllm]` | OpenAI Chat Completions | `VLLM_API_KEY` |\n| `ollama` | `[providers.ollama]` | Local OpenAI-compatible Chat Completions | `OLLAMA_API_KEY` (optional; only for an authenticated local route) |\n| `ollama-cloud` | `[providers.ollama_cloud]` | Hosted OpenAI-compatible Chat Completions | `OLLAMA_CLOUD_API_KEY`, `OLLAMA_API_KEY` |\n| `huggingface` | `[providers.huggingface]` | OpenAI Chat Completions | `HUGGINGFACE_API_KEY`, `HF_TOKEN` |\n| `modelscope` | `[providers.modelscope]` | OpenAI Chat Completions | `MODELSCOPE_API_KEY` |\n| `together` | `[providers.together]` | OpenAI Chat Completions | `TOGETHER_API_KEY` |\n| `qianfan` | `[providers.qianfan]` | OpenAI Chat Completions | `QIANFAN_API_KEY`, `BAIDU_QIANFAN_API_KEY` |\n| `openai-codex` | `[providers.openai_codex]` | OpenAI Responses | Official Sign in with ChatGPT (`codewhale auth chatgpt`) with a validated Codewhale-owned plan grant |\n| `anthropic` | `[providers.anthropic]` | Anthropic Messages | `ANTHROPIC_API_KEY` or `codewhale auth claude` |\n| `openmodel` | `[providers.openmodel]` | Anthropic Messages | `OPENMODEL_API_KEY` |\n| `zai` | `[providers.zai]` | OpenAI Chat Completions | `ZAI_API_KEY`, `Z_AI_API_KEY` |\n| `stepfun` | `[providers.stepfun]` | OpenAI Chat Completions | `STEPFUN_API_KEY`, `STEP_API_KEY` |\n| `minimax` | `[providers.minimax]` | OpenAI Chat Completions | `MINIMAX_API_KEY` |\n| `deepinfra` | `[providers.deepinfra]` | OpenAI Chat Completions | `DEEPINFRA_API_KEY`, `DEEPINFRA_TOKEN` |\n| `sakana` | `[providers.sakana]` | OpenAI Chat Completions | `FUGU_API_KEY`, `SAKANA_API_KEY` |\n| `longcat` | `[providers.longcat]` | OpenAI Chat Completions | `LONGCAT_API_KEY` |\n| `opencode-go` | `[providers.opencode_go]` | OpenAI Chat Completions | `OPENCODE_GO_API_KEY` |\n| `opencode-zen` | `[providers.opencode_zen]` | Model-aware: OpenAI Responses, Anthropic Messages, or OpenAI Chat Completions | `OPENCODE_ZEN_API_KEY`, `OPENCODE_API_KEY` |\n| `meta` | `[providers.meta]` | OpenAI Chat Completions | `META_MODEL_API_KEY`, `MODEL_API_KEY` |\n| `telecomjs` | `[providers.telecomjs]` | OpenAI Chat Completions | `TELECOMJS_API_KEY` |\n| `xai` | `[providers.xai]` | OpenAI Chat Completions | `XAI_API_KEY` |\n| `mistral` | `[providers.mistral]` | OpenAI Chat Completions | `MISTRAL_API_KEY` |\n| `google` | `[providers.google]` | OpenAI Chat Completions (official Gemini OpenAI-compat route; captures and replays thought signatures on tool calls) | `GOOGLE_API_KEY`, `GEMINI_API_KEY` |\n| `edenai` | `[providers.edenai]` | OpenAI Chat Completions | `EDENAI_API_KEY` |\n| `zenmux` | `[providers.zenmux]` | OpenAI Chat Completions | `ZENMUX_API_KEY` |\n| `csdn` | `[providers.csdn]` | OpenAI Chat Completions | `CSDN_API_KEY` |\n| `concentrate` | `[providers.concentrate]` | OpenAI Responses (`/v1/responses`) | `CONCENTRATE_API_KEY` |\n| `codewhale` | `[providers.codewhale]` | Model-aware: OpenAI Chat Completions (`/v1/chat/completions`) or Anthropic Messages (`/v1/messages`), chosen per model by the account catalog | `CODEWHALE_API_KEY` |\n| `modelstudio-token-plan` | `[providers.modelstudio_token_plan]` | OpenAI Chat Completions | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` |\n| `modelstudio-token-plan-anthropic` | `[providers.modelstudio_token_plan_anthropic]` | Anthropic Messages | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` |\n| `modelstudio-coding-plan` | `[providers.modelstudio_coding_plan]` | OpenAI Chat Completions | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` |\n| `modelstudio-coding-plan-anthropic` | `[providers.modelstudio_coding_plan_anthropic]` | Anthropic Messages | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` |\n\nDefault base URLs and models for each route are listed in the shipped provider\ntable below. The wire protocol values above are derived from\n`crates/config/src/provider.rs`: `ChatCompletions` is the default,\n`openai-codex` overrides to `Responses`; `deepseek-anthropic`, `anthropic`, and\n`openmodel` override to `AnthropicMessages`; `opencode-zen` resolves the\nprotocol from the selected model's curated offering; and `deepseek` is\nmodel-aware — the shipped default `deepseek-flash` (and legacy\n`deepseek-v4-flash`) rides the Responses endpoint while `deepseek-v4-pro`\nstays on Chat Completions.\n\n## Auth And Env Rules\n\nFor hosted providers, `codewhale auth set --provider ` saves an API key for\nthat provider. API-key environment variables are fallback inputs after saved\nconfig and keyring credentials; an explicit process-level `--api-key` still\nwins for that launch.\n\nFor base URL and model selection, prefer:\n\n- `CODEWHALE_BASE_URL` / `CODEWHALE_MODEL` for the active provider.\n- Provider-specific base URL/model env vars when listed below.\n- `DEEPSEEK_BASE_URL`, `DEEPSEEK_MODEL`, and `DEEPSEEK_DEFAULT_TEXT_MODEL` as\n legacy aliases.\n\nNon-local `http://` base URLs are rejected unless\n`DEEPSEEK_ALLOW_INSECURE_HTTP=1` is set. Loopback HTTP URLs are allowed for\nself-hosted runtimes.\n\n## Custom DeepSeek-Compatible Endpoints\n\nMost custom DeepSeek-compatible deployments can use an existing provider ID.\nDo not create `[providers.deepseek_custom]`; the provider table names are fixed.\nInstead, choose the closest shipped route and override its endpoint/model:\n\n- DeepSeek-compatible hosted API: keep `provider = \"deepseek\"` and set\n `[providers.deepseek].base_url` plus `[providers.deepseek].model`, or launch\n with `DEEPSEEK_BASE_URL` and `DEEPSEEK_MODEL`.\n- Generic OpenAI-compatible gateway: use `provider = \"openai\"` with\n `[providers.openai].base_url` plus `[providers.openai].model`, or launch with\n `OPENAI_BASE_URL` and `OPENAI_MODEL`.\n- Multiple named OpenAI-compatible gateways, or local routes you want to pin\n from an AgentProfile, can use a custom table such as\n `[providers.lm-studio] kind = \"openai-compatible\"` and select it with\n `provider = \"lm-studio\"` or a profile `provider = \"lm-studio\"`.\n- Local OpenAI-compatible runtimes: use `provider = \"vllm\"`, `\"sglang\"`, or\n `\"ollama\"` with the matching provider-specific base URL/model values.\n\nExample user config for a DeepSeek-compatible host:\n\n```toml\nprovider = \"deepseek\"\n\n[providers.deepseek]\napi_key = \"YOUR_API_KEY\"\nbase_url = \"https://your-provider.example/v1\"\nmodel = \"deepseek-ai/DeepSeek-V4-Pro\"\n```\n\nExample user config for a generic gateway:\n\n```toml\nprovider = \"openai\"\n\n[providers.openai]\napi_key = \"YOUR_GATEWAY_API_KEY\"\nbase_url = \"https://gateway.example/v1\"\nmodel = \"your-deepseek-compatible-model\"\n```\n\nAlibaba Cloud Model Studio (Bailian / DashScope) is a first-class provider as\nof v0.9.4 with two plan profiles: Token Plan (Personal / Team) and Coding Plan.\nBoth plans expose an OpenAI-compatible Chat Completions endpoint and an\nAnthropic-compatible Messages endpoint.\n\n**Token Plan** (Personal and Team share the same AP-Southeast endpoint):\n\n```toml\nprovider = \"modelstudio-token-plan\"\n\n[providers.modelstudio_token_plan]\napi_key = \"YOUR_MODELSTUDIO_API_KEY\"\n# base_url defaults to https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1\nmodel = \"qwen3.8-max\" # or qwen3.8-max-preview | qwen3.7-plus | qwen3.7-max |\n # qwen3.6-flash | deepseek-v4-pro | deepseek-v4-flash-0731 |\n # glm-5.2\n```\n\n**Coding Plan** (separate international endpoint):\n\n```toml\nprovider = \"modelstudio-coding-plan\"\n\n[providers.modelstudio_coding_plan]\napi_key = \"YOUR_MODELSTUDIO_API_KEY\"\n# base_url defaults to https://coding-intl.dashscope.aliyuncs.com/v1\nmodel = \"qwen3.8-max\"\n```\n\n**Anthropic-compatible dialect** — both plans also expose a native Anthropic\nMessages path. Select it with the `-anthropic` provider suffix:\n\n```toml\nprovider = \"modelstudio-token-plan-anthropic\"\n\n[providers.modelstudio_token_plan_anthropic]\napi_key = \"YOUR_MODELSTUDIO_API_KEY\"\n# base_url defaults to https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic\nmodel = \"qwen3.8-max\"\n```\n\nCreate or copy a Model Studio API key from the\n[Bailian console](https://bailian.console.aliyun.com/). The API key is shared\nacross all four provider IDs above; only the base URL and wire protocol differ.\n\n**Thinking / reasoning.** Reasoning surfaces in the TUI's Thinking view on both\ndialects, per Model Studio's\n[deep-thinking docs](https://www.alibabacloud.com/help/en/model-studio/deep-thinking).\n\nOn the OpenAI-compatible routes the top-level controls are **route- and\nmodel-specific**, and Codewhale fails closed: they are sent only when the\nconfigured `base_url` is an official Alibaba Chat Completions host\n(`*.maas.aliyuncs.com/compatible-mode/v1`, including workspace-scoped hosts, or\n`coding-intl.dashscope.aliyuncs.com/v1`). A custom `base_url` on the same\nprovider ID gets `thinking`, `enable_thinking`, `preserve_thinking`, and\n`reasoning_effort` stripped, so an arbitrary OpenAI-compatible gateway is never\nhanded Alibaba's dialect. On a verified host:\n\n- **Hybrid models** (`qwen3.7-*`, `qwen3.6-*`, `deepseek-v4*`, `glm-*`,\n `kimi-k2.6*`) get `enable_thinking`: `false` for `off`, `true` otherwise.\n- **Thinking-only models** — `qwen3.8-max` (catalogued `thinking: always_on`),\n `qwen3.8-max-preview` (effort/budget options, no toggle), and\n `kimi-k2.7-code` — get **no** enable/disable switch at all. Sending one is at\n best ignored.\n- `preserve_thinking` is sent for the models documented to accept it\n (`qwen3.7-max`/`-plus`, `qwen3.6-max-preview`/`-plus`/`-flash`, `kimi-k2.6*`,\n `kimi-k2.7-code`), so the next turn keeps the assistant's trace.\n- `reasoning_effort` is sent only for the two families with a documented ladder\n — `deepseek-v4*` and `glm-5`/`5.1`/`5.2` — mapped to `high` or `max`.\n\nReasoning streams back as `delta.reasoning_content`. It is replayed to the\nprovider on later turns only for the `preserve_thinking` models above and the\nthinking-only models; `deepseek-v3.1`, `deepseek-v3.2`, and `glm-*` history\nstays stripped pending live confirmation that DashScope accepts\n`reasoning_content` in input messages. (`deepseek-v4*` replays regardless — the\nDeepSeek thinking-mode contract requires it on every provider.)\n\nOn the Anthropic-compatible routes, thinking uses the documented\n`{\"type\":\"enabled\",\"budget_tokens\":N}` / `{\"type\":\"disabled\"}` shapes from the\n[Anthropic-compatible Messages API](https://www.alibabacloud.com/help/en/model-studio/anthropic-api-messages),\nwith `budget_tokens` derived from the effort level.\n\nDeepSeek (`deepseek-v4-pro`, `deepseek-v4-flash-0731`) and GLM (`glm-5.2`)\nmodels served by Model Studio are provider-scoped and do not collide with the\nfirst-party DeepSeek or Zhipu/Z.ai routes. Model Studio publishes no `glm-5.3`\nentry, so Codewhale does not offer one on this route.\nPay-as-you-go workspace-id templating is not yet in the built-in provider; use\na custom provider entry for that plan until a follow-up adds it.\n\nPrivate gateways with broken or intercepted certificates should use\n`SSL_CERT_FILE` with a trusted CA bundle. The legacy\n`insecure_skip_tls_verify = true` key is still parsed so `codewhale doctor` can\nreport stale configs, but provider clients reject it instead of skipping TLS\ncertificate verification.\n\nKeep `provider`, `api_key`, and `base_url` in user config or process\nenvironment. Project-local config overlays intentionally cannot set those keys,\nso a repository cannot silently redirect prompts or credentials to another\nendpoint.\n\n## Local Models (DS4, Ollama, vLLM, SGLang)\n\nSelf-hosted OpenAI-compatible runtimes are first-class routes and are keyless\nby default — set an API key only when your server requires one. Start your\nrunner, then point Codewhale at it with `--provider` / `/provider` or a config\ntable.\n\n| Runner | Default base URL | Default model | Base URL override |\n| --- | --- | --- | --- |\n| `ollama` | `http://localhost:11434/v1` | live tag from `GET /v1/models` (pre-refresh: `unknown`) | `OLLAMA_BASE_URL` |\n| `vllm` | `http://localhost:8000/v1` | `deepseek-ai/DeepSeek-V4-Pro` | `VLLM_BASE_URL` |\n| `sglang` | `http://localhost:30000/v1` | `deepseek-ai/DeepSeek-V4-Pro` | `SGLANG_BASE_URL` |\n\n### DS4 (DwarfStar)\n\n[DS4](https://github.com/antirez/ds4/tree/84cc882352757baf628a1776badf7cc54d584e28)\nserves DeepSeek V4 Flash and Pro locally\nthrough an OpenAI-compatible API. Start DS4, then open Codewhale's prefilled,\nkeyless setup form:\n\n```bash\n./ds4-server --ctx 100000 --kv-disk-dir /tmp/ds4-kv --kv-disk-space-mb 8192\ncodewhale\n# In Codewhale: /setup provider ds4\n```\n\nReview the prefilled route and press Enter to save it. The preset budgets a\n100,000-token context to match that starter command and defaults to the Flash\ncompatibility alias. Check the local route explicitly with\n`codewhale doctor --probe-local`.\n\nDS4 loads the actual GGUF when the server starts. Its `deepseek-v4-flash` and\n`deepseek-v4-pro` API ids are compatibility aliases; changing `/model` does\nnot swap the resident model. To run Pro, download the supported Pro weights\nand start `ds4-server -m ...` as described by DS4. Update\n`context_window` whenever the server's `--ctx` value changes.\n\nThe equivalent config is:\n\n```toml\nprovider = \"ds4\"\n\n[providers.ds4]\nkind = \"openai-compatible\"\nbase_url = \"http://127.0.0.1:8000/v1\"\nmodel = \"deepseek-v4-flash\"\nauth_mode = \"none\"\ncontext_window = 100000\n```\n\nCodewhale reuses its existing OpenAI-compatible transport and DeepSeek\nreasoning/tool-call shaping for DS4. It does not invent an API key, confuse an\nAPI alias with the loaded GGUF, or silently switch to a hosted DeepSeek route.\nThe pinned DS4 [agent-client contract](https://github.com/antirez/ds4/blob/84cc882352757baf628a1776badf7cc54d584e28/README.md#agent-client-usage)\ndocuments Chat Completions at `/v1`, DeepSeek thinking replay, streamed usage,\n`max_tokens`, and no strict-tool mode; Codewhale follows those exact route\nfacts instead of inheriting unsupported capabilities from a generic gateway.\nThe primary sources for model-facing behavior are DeepSeek's official\n[thinking-mode](https://api-docs.deepseek.com/guides/thinking_mode),\n[tool-call](https://api-docs.deepseek.com/guides/tool_calls), and\n[Chat Completion](https://api-docs.deepseek.com/api/create-chat-completion)\ncontracts. The pinned\n[DeepSeek Harness adapter](https://github.com/deepseek-ai/deepseek-harness/blob/47f943859bef60e4160492346772ded9b24f765a/packages/llm/llm-deepseek/README.md)\nis only a secondary implementation cross-check; it is not the API contract.\n\n### Ollama\n\n```bash\nollama serve # if not already running\nollama pull # e.g. deepseek-v4-flash, or any tag you prefer\ncodewhale --provider ollama --model \n```\n\nProvider-hinted model names are sent as-is, so `--model qwen3:8b` works with\nany tag Ollama has pulled.\n\n### Ollama Cloud\n\nOllama Cloud is a separate hosted provider. It uses the authenticated\nOpenAI-compatible `/v1/chat/completions` route and defaults to `gpt-oss:120b`:\n\n```toml\nprovider = \"ollama-cloud\"\n\n[providers.ollama_cloud]\nbase_url = \"https://ollama.com/v1\"\nmodel = \"gpt-oss:120b\"\n```\n\nCreate a key in [Ollama account settings](https://ollama.com/settings/keys),\nthen run `codewhale auth set --provider ollama-cloud`. For ambient auth,\n`OLLAMA_CLOUD_API_KEY` wins over Ollama's official `OLLAMA_API_KEY`.\n`OLLAMA_CLOUD_BASE_URL` and `OLLAMA_CLOUD_MODEL` override the Cloud defaults;\narbitrary provider-owned model IDs pass through unchanged. Local `ollama`\nremains a separate, keyless-by-default provider.\n\nCompatibility is read-only and in memory: a released config that selected\n`provider = \"ollama\"` with the exact normalized\n`[providers.ollama] base_url = \"https://ollama.com/v1\"` tuple is treated as\n`ollama-cloud` at runtime. Only that exact tuple may fall back to the legacy\n`ollama` secret slot. Codewhale does not rewrite the config, copy or delete a\nsecret, migrate neighboring paths, or make an explicit `ollama-cloud` route\nconsume the legacy slot.\n\n### vLLM\n\n```bash\nvllm serve --port 8000\n# or: python -m vllm.entrypoints.openai.api_server --model --port 8000\ncodewhale --provider vllm --model \n```\n\nvLLM's OpenAI-compatible server listens on port 8000 by default, matching\nCodewhale's `VLLM_BASE_URL`.\n\n### SGLang\n\n```bash\npython -m sglang.launch_server --model-path --port 30000\ncodewhale --provider sglang --model \n```\n\nSGLang's default port 30000 matches Codewhale's `SGLANG_BASE_URL`.\n\n### Pinning a local route in config\n\n```toml\nprovider = \"ollama\" # or \"vllm\" / \"sglang\"\n\n[providers.ollama]\nmodel = \"qwen3:8b\" # default is deepseek-v4-flash\n# base_url defaults to http://localhost:11434/v1\n```\n\nLocal models that print tool-call JSON without the wire markers: see\n[When a Local Model Prints Tool JSON](#when-a-local-model-prints-tool-json).\n\n## Credential Links\n\nProvider setup surfaces use the same typed credential metadata as onboarding,\n`/provider`, `/links`, setup receipts, and doctor output. A missing URL is\nintentional: local, OAuth-only, and user-defined routes show their supported\nconfiguration path instead of guessing a vendor page.\n\n| Provider ID | Credential or console link |\n| --- | --- |\n| `deepseek`, `deepseek-anthropic` | [DeepSeek API keys](https://platform.deepseek.com/api_keys) |\n| `nvidia-nim` | [NVIDIA NIM API keys](https://build.nvidia.com/settings/api-keys) |\n| `openai` | [OpenAI API keys](https://platform.openai.com/api-keys) |\n| `atlascloud` | [Atlas Cloud API keys](https://atlascloud.ai/docs/en/api-keys) |\n| `wanjie-ark` | [Wanjie MaaS APIKEY docs](https://docs.wanjiedata.com/maas/maas-openapi-v1.html) |\n| `volcengine` | [Volcengine Ark API keys](https://console.volcengine.com/ark/apiKey) |\n| `openrouter` | [OpenRouter keys](https://openrouter.ai/settings/keys) |\n| `xiaomi-mimo` | [Xiaomi MiMo Token Plan](https://platform.xiaomimimo.com/token-plan) |\n| `novita` | [Novita key management](https://novita.ai/en/settings/key-management) |\n| `fireworks` | [Fireworks API keys](https://fireworks.ai/api-keys) |\n| `siliconflow` | [SiliconFlow global API keys](https://cloud.siliconflow.com/account/ak) |\n| `siliconflow-CN` | [SiliconFlow China API keys](https://cloud.siliconflow.cn/account/ak) |\n| `arcee` | [Arcee API key guide](https://docs.arcee.ai/other/create-your-first-api-key) |\n| `moonshot` | [Kimi API platform keys](https://platform.kimi.ai/console/api-keys) or [Kimi Code membership console](https://www.kimi.com/code/console) |\n| `zai` | [Z.ai model API](https://z.ai/model-api) |\n| `stepfun` | [StepFun Open Platform](https://platform.stepfun.ai/) |\n| `minimax`, `minimax-anthropic` | [MiniMax interface keys](https://platform.minimax.io/user-center/basic-information/interface-key) |\n| `huggingface` | [Hugging Face tokens](https://huggingface.co/settings/tokens) |\n| `modelscope` | [ModelScope API Keys](https://modelscope.cn/my/settings/token) |\n| `deepinfra` | [DeepInfra API keys](https://deepinfra.com/dash/api_keys) |\n| `together` | [Together API keys](https://api.together.ai/settings/api-keys) |\n| `qianfan` | [Baidu Cloud access keys](https://console.bce.baidu.com/iam/#/iam/accesslist) |\n| `anthropic` | [Anthropic API keys](https://console.anthropic.com/settings/keys) |\n| `openmodel` | [OpenModel console](https://console.openmodel.ai/) ([authentication guide](https://docs.openmodel.ai/en/docs/getting-started/authentication)) |\n| `openai-codex` | Official Sign in with ChatGPT via `codewhale auth chatgpt` (eligible ChatGPT plan or credits, Codewhale-owned tokens). The `openai` API-key route has separate billing. Legacy imported Codex credentials do not authorize this route. |\n| `sglang`, `vllm` | Local OpenAI-compatible endpoints are keyless by default; configure a key only when the server requires one. |\n| `ollama` | Local Ollama is keyless by default; configure a key only when the local server requires one. |\n| `ollama-cloud` | Create an [Ollama API key](https://ollama.com/settings/keys), save it with `codewhale auth set --provider ollama-cloud`, or set `OLLAMA_CLOUD_API_KEY` / `OLLAMA_API_KEY` in that precedence order. |\n| `sakana` | [Sakana AI API keys](https://console.sakana.ai/api-keys) ([get started](https://console.sakana.ai/get-started)) |\n| `longcat` | [Meituan LongCat platform](https://longcat.chat/platform) |\n| `opencode-go` | [OpenCode Go](https://opencode.ai/docs/go/) |\n| `opencode-zen` | [OpenCode Zen](https://opencode.ai/docs/zen/) |\n| `meta` | [Meta Model API](https://developer.meta.com/ai/) |\n| `telecomjs` | [TelecomJS TokenHub](https://aigw.telecomjs.com/) |\n| `xai` | [xAI Console](https://console.x.ai/) for an API key, Codewhale-owned device login, or explicitly consented read-only Grok CLI credentials. |\n| `mistral` | [Mistral Console (la Plateforme)](https://console.mistral.ai/api-keys) |\n| `google` | [Google AI Studio](https://aistudio.google.com/apikey) — Codewhale uses the official Gemini OpenAI-compatible endpoint and never reads Google OAuth files. |\n| `edenai` | [Eden AI API keys](https://app.edenai.run/settings/api-keys) |\n| `zenmux` | [ZenMux API keys](https://zenmux.ai/platform/pay-as-you-go) |\n| `csdn` | [CSDN 星图 console](https://ai.csdn.net/workbench/api-key) — choose the Coding Plan key type for the `glm_for_coding` plan route; a general key bills metered. Docs: [Coding Plan](https://ai.csdn.net/coding-plan). |\n| `concentrate` | [Concentrate dashboard](https://concentrate.ai/) → API Keys → Create API Key (a Universal API key); docs: [API introduction](https://concentrate.ai/docs/api-reference/introduction). BYOK only — the key stays in the local secret store. |\n| `modelstudio-token-plan`, `modelstudio-token-plan-anthropic`, `modelstudio-coding-plan`, `modelstudio-coding-plan-anthropic` | [Alibaba Cloud Model Studio (Bailian console)](https://bailian.console.aliyun.com/) — create or copy a Model Studio API key. |\n| `codewhale` | [Codewhale account settings](https://app.codewhale.net/settings?section=api) — create an API key with the `models:infer` scope, or run `codewhale account api-keys create --name --use`. |\n| `custom` | Set the named provider's `base_url` and `api_key_env` or `api_key`; no canonical vendor credential page exists. |\n\nFor Kimi, the official [quickstart](https://platform.kimi.ai/docs/overview)\ndirects users to sign in, open **API Keys**, create and copy a key, and keep it\nsecret. Codewhale links straight to that console and accepts the copied key.\nIt never probes or impersonates `kimi_cli`/`kimi_code_cli`; first-class Kimi\nOAuth remains blocked on a vendor-registered Codewhale identity.\n\n### Subscription sign-in accounts (ChatGPT, xAI)\n\nCodewhale keeps one sign-in of its own per subscription provider. It is\nseparate from the browser, the ChatGPT or Grok apps, and the Codex or Grok\nCLIs, so those can be signed in to a different account. A successful login\nreplaces the previous owned grant; there is no picker for multiple saved\nChatGPT accounts.\n\n- **See which account is signed in.** `codewhale auth status --provider\n openai-codex` (or `--provider xai`) and `codewhale auth list` show an account\n label when the ID token contains an email. ChatGPT plan and workspace\n details appear only when those claims are present; a workspace label uses\n the first characters of its account ID. The label is display metadata, not\n proof of plan entitlement. A valid sign-in may have no email label. Status\n reads local credential state without contacting the issuer: an expired\n access token with a stored refresh token remains structurally usable, while\n missing or invalid storage, or an expired token without a refresh token,\n is reported unusable. The provider picker shows the label of the sign-in\n the route would use (for xAI this includes a consented Grok CLI import).\n Successful login reports the account when known and whether it replaced\n an earlier sign-in, in the TUI's selected language. No token is printed.\n- **Reauthorize ChatGPT.** Run `/auth chatgpt` inside Codewhale to update the\n running session, or `codewhale auth chatgpt` from a shell and restart open\n Codewhale sessions. Ordinary login reuses the selected account/workspace's\n issued client ID and checks that the returned account is the same. The\n browser opens with `prompt=login`; `CODEWHALE_CHATGPT_OAUTH_NO_PROMPT=1`\n omits that parameter if the issuer refuses it.\n- **Replace the ChatGPT account or workspace.** From a shell, run\n `CODEWHALE_CHATGPT_NEW_ACCOUNT=1 codewhale auth chatgpt`, then refresh its\n model catalog and restart open Codewhale sessions. If the browser holds the\n wrong account, sign out there or open the printed URL in a private window.\n Only a fully validated new grant replaces the active owned sign-in.\n- **Switch xAI accounts.** Run `/auth xai-device` inside Codewhale, or\n `codewhale auth xai-device` from a shell and restart open sessions. The\n device page approves with whichever account the browser holds; sign out\n there or use a private window to approve another account. The login names\n the account it replaced, or says it is the same account as before.\n- **Usage limits.** ChatGPT plan requests can fail with\n `subscription_sharing_usage_limit_exceeded` or\n `subscription_sharing_usage_unavailable`. These errors, and subscription\n quota errors from other providers, name the account that made the request\n when its label is known and offer recovery guidance. Codewhale does not\n retry these terminal errors or silently choose another billing route. An\n xAI route set to OAuth that fell back to an API key gets no sign-in\n guidance, since no sign-in was used.\n- **ChatGPT credential authority.** The official ChatGPT plan route uses\n only its selected, verified Codewhale-owned grant.\n `OPENAI_CODEX_ACCESS_TOKEN`, `CODEX_ACCESS_TOKEN`, and consented Codex CLI\n tokens cannot override or authorize this route.\n\n### External CLI credential consent\n\nCredential files owned by another CLI are disabled by default. Without an\nexplicit grant, provider discovery, setup, routing, `auth status`, and doctor\ndo not stat, read, refresh, contact an identity provider for, or rewrite Codex,\nGrok, Kimi, or future external credential files.\n\nCodewhale currently supports exact-path, provider-scoped **read-only** grants\nfor the Codex CLI and Grok CLI. Codex grants remain available for legacy\ncredential inspection only; they cannot authorize the official ChatGPT plan\nroute. Use `codewhale auth chatgpt` for that route.\n\n```bash\n# Legacy Codex credential inspection; does not authorize ChatGPT plan requests.\ncodex login\ncodewhale auth external-consent --provider openai-codex --mode read-only\n\ngrok login\ncodewhale auth external-consent --provider xai --mode read-only\n\ncodewhale auth status --provider openai-codex\ncodewhale auth external-revoke --provider openai-codex\n```\n\nPass `--path /absolute/path/to/auth.json` when the external CLI uses a custom\nlocation. Consent persists the provider, external owner, exact absolute path,\nand consent schema version. Later environment-variable changes do not redirect\nthat authority to a different file. Read-only grants never refresh, contact an\nidentity/discovery service, or rewrite the external file. Supported routes such\nas xAI may use the external token after explicit selection. The official ChatGPT\nplan route ignores imported Codex tokens and requires its own verified grant.\nAn expired external token fails with login guidance. Doctor reports structural\nconsent/config state without\nopening credential files and is always non-mutating.\n\n`managed` is reserved for a future provider-specific preservation adapter.\nv0.9.1 rejects it before file or network I/O because no reviewed adapter can\nyet preserve every unknown external schema field safely. Codewhale-started xAI\ndevice login instead atomically activates a Codewhale-owned generation named\n`$CODEWHALE_HOME/credentials/xai-auth-.json`, stores only that\nvalidated basename in config, and revokes any Grok-file grant. Superseded\ngenerations are cleaned only after the new config pointer commits.\nKimi remains API-key-only; external consent for Kimi is rejected.\n\nThe official DeepSeek Harness (`dsh`) is a third read-only credential owner:\n`codewhale auth external-consent --provider deepseek --mode read-only` grants\nexact-path read access to `DEEPSEEK_API_KEY` in `$DSH_HOME/.credentials.yaml`\n(or `~/.dsh/.credentials.yaml`), which Codewhale never writes, refreshes, or\nloads into the process environment. This is separate from the DSH *harness*\nintegration (`codewhale integrations dsh …`, see\n[INTEGRATIONS_DSH.md](INTEGRATIONS_DSH.md)), which never touches credentials\nin either direction: it pins Codewhale's route identity into a `--patch`\noverlay and lets DSH resolve its own keys.\n\n## Shipped Providers\n\n| Provider ID | TOML table | Auth env | Base URL env and default | Default or static models | Notes |\n| --- | --- | --- | --- | --- | --- |\n| `deepseek` | `[providers.deepseek]` | `DEEPSEEK_API_KEY` | `CODEWHALE_BASE_URL` / `DEEPSEEK_BASE_URL`; default `https://api.deepseek.com/beta` | `deepseek-flash` (shipped default; V4.1 Flash, unversioned id), `deepseek-v4-pro`, `deepseek-v4-flash`, experimental `deepseek-v4-flash-vision-exp`; vision aliases `flash-vision`, `deepseek-v4flashvisionexp`; compatibility aliases `deepseek-chat`, `deepseek-reasoner` | First-class default. The live Pro backend is labeled `DeepSeek-V4-Pro-0813`; the callable API ID remains `deepseek-v4-pro`. Beta URL enables strict tool mode, chat prefix completion, and FIM completion. The documented V4 routes can use provider-native web search through a separate bounded Responses request; compatible custom endpoints do not inherit that capability. Set `https://api.deepseek.com` or `/v1` explicitly to opt out of beta-only features. The shipped default `deepseek-flash` speaks the Responses API (DeepSeek's documented path for Codex-style integration, since the 2026-07-31 Flash production update); the Chat-only controls (the `thinking` toggle and strict-tool `/beta` routing) apply to Chat Completions routes, and `deepseek-v4-pro` stays on Chat Completions until its announced Responses rollout. Reasoning effort follows DeepSeek's documented requested-to-actual mapping: `minimal`/`low` land on `low`, `medium`/`xhigh` on `high`, and `max`/`ultra` on `max`; `off` disables thinking (`thinking: {\"type\":\"disabled\"}` on Chat, `reasoning.effort: \"none\"` on Responses). The experimental vision ID was observed in the authenticated `/models` roster on 2026-08-21 and is advertised as image-input capable on the direct Chat Completions route only. Its limits, reasoning, and tool-call flags provisionally inherit Flash; pricing remains unknown, and no funded image round trip was made during this release work. |\n| `deepseek-anthropic` | `[providers.deepseek_anthropic]` | `DEEPSEEK_API_KEY` | `DEEPSEEK_ANTHROPIC_BASE_URL`; default `https://api.deepseek.com/anthropic` | `deepseek-v4-pro`, `deepseek-v4-flash`; compatibility aliases `deepseek-chat`, `deepseek-reasoner` | Opt-in DeepSeek route for the Anthropic Messages wire protocol. Uses `/v1/messages`, `x-api-key`, and `anthropic-version: 2023-06-01`. Keep `provider = \"deepseek\"` for the default Chat Completions path. |\n| `nvidia-nim` | `[providers.nvidia_nim]` | `NVIDIA_API_KEY`, `NVIDIA_NIM_API_KEY` | `NVIDIA_NIM_BASE_URL`, `NIM_BASE_URL`, `NVIDIA_BASE_URL`; default `https://integrate.api.nvidia.com/v1` | `deepseek-ai/deepseek-v4-pro`, `deepseek-ai/deepseek-v4-flash` | Hosted DeepSeek V4 through NVIDIA NIM. `NVIDIA_NIM_MODEL` is accepted by the TUI config path. |\n| `openai` | `[providers.openai]` | `OPENAI_API_KEY` | `OPENAI_BASE_URL`; default `https://api.openai.com/v1` | `gpt-5.6` (default), `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` | Generic OpenAI-compatible route whose built-in endpoint and fallback catalog are native to OpenAI. The [GPT-5.6 family](https://developers.openai.com/api/docs/models/gpt-5.6-sol) uses OpenAI's documented 1.05M context, 128K max output, and reasoning levels. Custom gateways remain free to select an explicit gateway-owned model. `OPENAI_MODEL` is accepted. |\n| `atlascloud` | `[providers.atlascloud]` | `ATLASCLOUD_API_KEY` | `ATLASCLOUD_BASE_URL`; default `https://api.atlascloud.ai/v1` | Default `deepseek-ai/deepseek-v4-flash`; explicit `vendor/model-id` values pass through when AtlasCloud is selected | OpenAI-compatible hosted route. `ATLASCLOUD_MODEL` is accepted by the TUI config path, the static `ModelRegistry` keeps DeepSeek V4 fallback rows, and provider-hinted CLI model IDs are sent to AtlasCloud exactly as requested. Use Atlas Cloud's own catalog or Coding Plan page for the current provider-owned model list and pricing. |\n| `wanjie-ark` | `[providers.wanjie_ark]` | `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, `WANJIE_MAAS_API_KEY` | `WANJIE_ARK_BASE_URL`, `WANJIE_BASE_URL`, `WANJIE_MAAS_BASE_URL`; default `https://maas-openapi.wanjiedata.com/api/v1` | `deepseek-reasoner` | OpenAI-compatible hosted route. `WANJIE_ARK_MODEL`, `WANJIE_MODEL`, and `WANJIE_MAAS_MODEL` are accepted. |\n| `volcengine` | `[providers.volcengine]` | `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, `ARK_API_KEY` | `VOLCENGINE_BASE_URL`, `VOLCENGINE_ARK_BASE_URL`, `ARK_BASE_URL`; default `https://ark.cn-beijing.volces.com/api/coding/v3` | `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash` | Volcengine/Volcano Engine Ark OpenAI-compatible coding endpoint. `VOLCENGINE_MODEL` and `VOLCENGINE_ARK_MODEL` are accepted. |\n| `openrouter` | `[providers.openrouter]` | `OPENROUTER_API_KEY` | `OPENROUTER_BASE_URL`; default `https://openrouter.ai/api/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`; recent large IDs include `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`, `qwen/qwen3.6-flash`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`, `qwen/qwen3.6-plus`, `google/gemma-4-31b-it`, `z-ai/glm-5.1`, `z-ai/glm-5.2`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6` | Additive open-model routing layer. It does not replace DeepSeek; it lets users route supported model IDs through OpenRouter when they choose it. |\n| `orcarouter` | `[providers.orcarouter]` | `ORCAROUTER_API_KEY` | `ORCAROUTER_BASE_URL`; default `https://api.orcarouter.ai/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`; router alias `orcarouter/auto`; recent large IDs mirror the OpenRouter namespaced catalog | [OrcaRouter](https://www.orcarouter.ai) OpenAI-compatible aggregation gateway. Shares the namespaced `vendor/model` wire-model format and DeepSeek model set with OpenRouter, so the OpenRouter base-URL and model-normalization rules apply. `ORCAROUTER_MODEL` is accepted. Provider aliases: `orcarouter`, `orca_router`, `orca`. |\n| `xiaomi-mimo` | `[providers.xiaomi_mimo]` | `XIAOMI_MIMO_TOKEN_PLAN_API_KEY`, `MIMO_TOKEN_PLAN_API_KEY`, `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, `MIMO_API_KEY` | `XIAOMI_MIMO_BASE_URL`, `MIMO_BASE_URL`, `XIAOMI_MIMO_MODE`, `MIMO_MODE`; default `https://token-plan-sgp.xiaomimimo.com/v1` | Chat: `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, `mimo-v2.5`; speech/TTS: `mimo-v2.5-tts`, `mimo-v2.5-tts-voicedesign`, `mimo-v2.5-tts-voiceclone`, `mimo-v2-tts` | Xiaomi MiMo OpenAI-compatible chat completions route. `mimo-v2.5-pro` and `mimo-v2.5` can use the documented provider-native web-search plugin; ultraspeed, speech/TTS, and custom-compatible routes do not inherit it. Token Plan keys (`tp-...`) use `api-key` auth and the token-plan endpoint by default; pay-as-you-go mode uses standard API keys (`sk-...`) and `https://api.xiaomimimo.com/v1`. It sends `max_completion_tokens` and uses MiMo's `thinking` field for reasoning control. Token Plan cost/usage is credit/quota based; Codewhale shows it as unknown until Xiaomi exposes a reliable balance API. `codewhale speech` / `tts` uses the TTS models. |\n| `novita` | `[providers.novita]` | `NOVITA_API_KEY` | `NOVITA_BASE_URL`; default `https://api.novita.ai/openai/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | OpenAI-compatible hosted route for DeepSeek model IDs. Use config or `CODEWHALE_MODEL` / `DEEPSEEK_MODEL` for model overrides. |\n| `fireworks` | `[providers.fireworks]` | `FIREWORKS_API_KEY` | `FIREWORKS_BASE_URL`; default `https://api.fireworks.ai/inference/v1` | `accounts/fireworks/models/deepseek-v4-pro` | OpenAI-compatible hosted route. Use config or `CODEWHALE_MODEL` / `DEEPSEEK_MODEL` for model overrides. |\n| `siliconflow` | `[providers.siliconflow]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.com/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | OpenAI-compatible hosted route. Official docs use the `.com` endpoint. `SILICONFLOW_MODEL` is accepted. Reasoning aliases `deepseek-reasoner` and `deepseek-r1` map to Pro; `deepseek-chat` and `deepseek-v3` map to Flash. |\n| `siliconflow-CN` | `[providers.siliconflow_cn]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.cn/v1` | Uses the SiliconFlow model set | China regional SiliconFlow route. Falls back to `[providers.siliconflow]` for api_key / base_url / model when unset. Select it with `provider = \"siliconflow-CN\"` or `CODEWHALE_PROVIDER=siliconflow-CN`. |\n| `arcee` | `[providers.arcee]` | `ARCEE_API_KEY` | `ARCEE_BASE_URL`; default `https://api.arcee.ai/api/v1` | `trinity-large-thinking`, `trinity-large-preview` | Arcee AI direct OpenAI-compatible route, tracked as 256K-context BF16 serving. `ARCEE_MODEL` is accepted. OpenRouter's `arcee-ai/trinity-large-thinking` remains the OpenRouter namespaced model ID; direct Arcee uses the bare `trinity-large-thinking` ID. |\n| `moonshot` | `[providers.moonshot]` | `MOONSHOT_API_KEY`, `KIMI_API_KEY` | `MOONSHOT_BASE_URL`, `KIMI_BASE_URL`; default `https://api.moonshot.ai/v1` | Direct Moonshot: `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.7-code-highspeed`, `kimi-k2.6`; Kimi Code membership: `k3`, `kimi-for-coding`, `kimi-for-coding-highspeed` at `https://api.kimi.com/coding/v1` | Moonshot/Kimi route. Exact direct `kimi-k3` routes use the documented Formula web-search tool/fiber loop; direct `kimi-k2.6` retains the built-in `$web_search` contract, and exact Kimi Code membership routes use their structured `/search` service. Adjacent paths, K2.7 direct models, and cross-product model IDs do not inherit native search. `kimi` and `kimi-k2` aliases select `kimi-k2.7-code`; `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, and `KIMI_MODEL` are accepted. Kimi thinking streams through `reasoning_content`; Codewhale keeps it in Thinking cells and replays it for thinking/tool-call continuity. For direct K3, use exact `base_url = \"https://api.moonshot.ai/v1\"` and `model = \"kimi-k3\"`; it is always-thinking and receives top-level `reasoning_effort = \"low\" | \"high\" | \"max\"` (`off` normalizes to `low`), uses only `max_completion_tokens`, and omits `temperature`/`top_p` per the [K3 quickstart](https://platform.kimi.ai/docs/guide/kimi-k3-quickstart). For Kimi Code K3, use a key from the [Kimi Code console](https://www.kimi.com/code/console), exact `base_url = \"https://api.kimi.com/coding/v1\"`, and bare `model = \"k3\"`; `off` becomes enabled `low`, while normal dispatched `auto` selects and sends a concrete Codewhale tier. Only an omitted reasoning setting leaves the provider default in control. That membership route defaults safely to 262,144 context tokens; the [Kimi Code model-tier table](https://www.kimi.com/code/docs/en/kimi-code/models.html) grants Allegretto and higher plans up to 1M, which those plans may express as `context_window = 1048576`. `k3[1m]` is Claude Code-only and Codewhale rejects it. `kimi-for-coding` remains the valid K2.7 membership route, and `kimi-for-coding-highspeed` is its own high-speed roster entry (262,144 context); membership ids are rejected on the direct platform endpoint, and `kimi-k3` stays rejected on the membership endpoint. Billing is decided by the endpoint the route resolves to, judged once against the two exact product endpoints: direct Moonshot (`https://api.moonshot.ai/v1` or the default) bills metered with dollar estimates, the exact Kimi Code membership endpoint bills as Kimi Code quota and never shows dollar estimates, and anything else — a gateway host, a neighboring Kimi-hosted path — reports `cost: unknown` rather than borrowing either product. An imported Kimi Code token with no `base_url` in its table still resolves to the membership endpoint, so it bills as Kimi Code quota and never accrues dollars. A completed turn, parent or sub-agent, is billed from the immutable endpoint receipt its own client was built with, never from a later config re-read: `MOONSHOT_BASE_URL`/`KIMI_BASE_URL` are merged into the *active* provider's table only, and an in-turn provider switch can move the ambient config off the route that actually ran. Legacy `auth_mode = \"kimi_oauth\"` fails to API-key guidance without probing Kimi CLI files. Codewhale does not impersonate `kimi_cli` or `kimi_code_cli`. **China-region keys:** contributor field evidence (@vFONGv, PR #5229, verified on Windows 10) reports that a China-region Moonshot key must be paired with `base_url = \"https://api.moonshot.cn/v1\"`; left on the default international host (`https://api.moonshot.ai/v1`) it fails authentication. We have no China-region key to verify this ourselves, so it is recorded as a user report rather than a tested route. Note also that editing `base_url` alone does not take effect until `codewhale auth set` is re-run for that provider. |\n| `google` | `[providers.google]` | `GOOGLE_API_KEY`, `GEMINI_API_KEY` | `GOOGLE_BASE_URL`, `GEMINI_BASE_URL`; default `https://generativelanguage.googleapis.com/v1beta/openai/` | `gemini-3.1-pro-preview` (default); `/model` also lists `gemini-3-pro-preview`, `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-2.5-pro`, `gemini-2.5-flash` | Google Gemini as its own backend on the official OpenAI-compatible Chat Completions route. Thinking models capture `extra_content.google.thought_signature` on tool calls and replay it with the assistant tool-call messages; replaying a tool call whose signature was not captured fails closed with an actionable error instead of letting the tool loop break. `gemini-2.5-flash-lite` ships thinking off and degrades with a warning instead. Reasoning effort maps onto the documented `google.thinking_config.thinking_level` (`low`/`high`). The dialect binds to the exact official base URL: a `google` row pointed at another gateway gets plain OpenAI semantics and no signature requirements. Codewhale never reads Google OAuth files; only an AI Studio API key is used. Not live-tested against the real endpoint in this environment. |\n| `zai` | `[providers.zai]` | `ZAI_API_KEY`, `Z_AI_API_KEY`, `ZHIPU_API_KEY`, `GLM_API_KEY` | `ZAI_BASE_URL`, `Z_AI_BASE_URL`; default `https://api.z.ai/api/coding/paas/v4`; general APIs `https://api.z.ai/api/paas/v4` and `https://open.bigmodel.cn/api/paas/v4` | `GLM-5.3` default; `/model` also lists `GLM-5.3-Flash`, `GLM-5.2`, `GLM-5.1`, and `GLM-5-Turbo` | Z.AI GLM Coding Plan route. All three first-party Chat routes (both api.z.ai products and BigModel's general platform endpoint) share one request dialect, so reasoning controls — the GLM-5.2 thinking toggle, tiered effort, and the forced-thinking GLM-5.3 rewrite that sends `off` as `enabled` + `reasoning_effort: \"low\"` — apply on BigModel too; neighboring paths such as `/preview` stay fail-closed. The two general API products expose structured provider-native web search (`search-prime` globally, `search_std` in China); Coding Plan and compatible custom endpoints do not inherit it. `GLM-5.3` is the default and a first-class picker row (`model = \"GLM-5.3\"` or `ZAI_MODEL=GLM-5.3`); `GLM-5.3-Flash` is the 1M multimodal fast sibling (`model = \"GLM-5.3-Flash\"`). An explicit `GLM-5.2` selection keeps its own id. Limits and reasoning options for 5.3 are inherited from `GLM-5.2` until Z.ai publishes distinct 5.3 metadata; 5.3 carries no price. Flash ships the published $0.15/$0.50 list. A live call can still 429 with entitlement code 1311 on accounts that are not provisioned for 5.3. |\n| `stepfun` | `[providers.stepfun]` | `STEPFUN_API_KEY`, `STEP_API_KEY` | `STEPFUN_BASE_URL`, `STEP_BASE_URL`; default `https://api.stepfun.ai/v1`; Coding Plan endpoint `https://api.stepfun.ai/step_plan/v1` | `step-3.7-flash` | StepFun / StepFlash direct OpenAI-compatible route. `/provider` setup asks which billing route the key belongs to — pay-as-you-go or Step Plan — validates the key against the chosen endpoint, and writes the answer to `[providers.stepfun].base_url` only. A base URL that is neither recognized route is left alone and the question is skipped. You can also set `[providers.stepfun].base_url` or `STEP_BASE_URL` to the Coding Plan URL by hand. Offline accounting labels recognized routes as `stepfun-payg` or `stepfun-plan` without persisting the raw endpoint, and only the standard PAYG route receives token pricing. `STEPFUN_MODEL` and `STEP_MODEL` are accepted. |\n| `minimax` | `[providers.minimax]` | `MINIMAX_API_KEY` | `MINIMAX_BASE_URL`; default `https://api.minimax.io/v1`; China `https://api.minimaxi.com/v1` | `MiniMax-M3`, `MiniMax-M2.7`, `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `MiniMax-M2.1`, `MiniMax-M2.1-highspeed`, `MiniMax-M2` | MiniMax direct OpenAI-compatible route. Codewhale sends `reasoning_split = true` so MiniMax thinking arrives separately from answer text. Both MiniMax dialects sell pay-as-you-go and Token Plan over the same endpoints and the same key, so billing is classified from the credential *product*, never from the endpoint or from a default. `mode = \"token-plan\"` in `[providers.minimax]`/`[providers.minimax_anthropic]`, or a Token Plan key shaped `sk-cp…`, bills as MiniMax Token Plan quota with no dollar estimates; an explicit pay-as-you-go mode (`pay-as-you-go`/`payg`/`metered`) wins over key shape. The key's product prefix is only visible when the key is in config, bound by `api_key_env`, or exported as `MINIMAX_API_KEY` on an official endpoint — a key saved through `codewhale auth set` (secret store / OS keyring) is deliberately not read to classify billing. With no explicit mode and no visible product marker the route reports `cost: unknown` rather than assuming pay-as-you-go, so a Token Plan account is never charged invented dollars. Custom/gateway endpoints also fail closed with `cost: unknown`. Official M3 input modalities are text, image, and video; M2.7 is text-only. |\n| `minimax-anthropic` | `[providers.minimax_anthropic]` | `MINIMAX_API_KEY` | `MINIMAX_ANTHROPIC_BASE_URL`; default `https://api.minimax.io/anthropic`; China `https://api.minimaxi.com/anthropic` | `MiniMax-M3`, `MiniMax-M2.7` | MiniMax direct Anthropic-compatible Messages route. Keep the `/anthropic` suffix because Codewhale appends `/v1/messages`; the route uses `x-api-key`. M3 supports adaptive or disabled thinking. M2.7 always keeps thinking enabled. |\n| `sglang` | `[providers.sglang]` | Optional `SGLANG_API_KEY` | `SGLANG_BASE_URL`; default `http://localhost:30000/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Self-hosted OpenAI-compatible route. Localhost deployments commonly omit auth. `SGLANG_MODEL` is accepted. |\n| `vllm` | `[providers.vllm]` | Optional `VLLM_API_KEY` | `VLLM_BASE_URL`; default `http://localhost:8000/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Self-hosted vLLM OpenAI-compatible route. Localhost deployments commonly omit auth. `VLLM_MODEL` is accepted. |\n| `ollama` | `[providers.ollama]` | Local optional `OLLAMA_API_KEY` | `OLLAMA_BASE_URL`; default `http://localhost:11434/v1` | live tag from the local catalog; pre-refresh placeholder `unknown`; provider-hinted custom tags pass through | Local Ollama is keyless by default. `OLLAMA_MODEL` is accepted. The header must not paint a hosted id the local daemon did not list. |\n| `ollama-cloud` | `[providers.ollama_cloud]` | `OLLAMA_CLOUD_API_KEY`, then `OLLAMA_API_KEY` | `OLLAMA_CLOUD_BASE_URL`; default `https://ollama.com/v1` | `gpt-oss:120b`; arbitrary provider-owned IDs pass through | Hosted OpenAI-compatible `/v1/chat/completions` route. Save credentials under `ollama-cloud`; the exact released `ollama` + Cloud URL tuple has bounded read-only in-memory compatibility with its legacy table and secret slot. `OLLAMA_CLOUD_MODEL` is accepted. |\n| `huggingface` | `[providers.huggingface]` | `HUGGINGFACE_API_KEY`, `HF_TOKEN` | `HUGGINGFACE_BASE_URL`, `HF_BASE_URL`; default `https://router.huggingface.co/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Hugging Face Inference Providers OpenAI-compatible router route. Accepted aliases: `huggingface`, `hugging-face`, `hugging_face`, `hf`. Org-prefixed model IDs pass through. `HUGGINGFACE_MODEL` and `HF_MODEL` are accepted. Hub browsing/export are separate future features. |\n| `modelscope` | `[providers.modelscope]` | `MODELSCOPE_API_KEY` | `MODELSCOPE_BASE_URL`; default `https://api-inference.modelscope.cn/v1` | `Qwen/Qwen3.5-397B-A17B` (default), `Qwen/Qwen3.5-122B-A10B`, `Qwen/Qwen3.5-27B`, `Qwen/Qwen3.5-35B-A3B`, `Qwen/Qwen3.8-27B`, `Qwen/Qwen3.8-Flash-Next`, `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Pro-0813`, `deepseek-ai/DeepSeek-V4.1-Flash`, `ZhipuAI/GLM-4.7-Flash`, `ZhipuAI/GLM-5.2` | ModelScope OpenAI-compatible inference route. Org-prefixed model IDs pass through. `MODELSCOPE_MODEL` is accepted. |\n| `deepinfra` | `[providers.deepinfra]` | `DEEPINFRA_API_KEY`, `DEEPINFRA_TOKEN` | `DEEPINFRA_BASE_URL`; default `https://api.deepinfra.com/v1/openai` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | DeepInfra OpenAI-compatible route. Drop-in replacement for OpenAI SDK. |\n| `together` | `[providers.together]` | `TOGETHER_API_KEY` | `TOGETHER_BASE_URL`; default `https://api.together.xyz/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash`, `thinkingmachines/inkling` | Together AI OpenAI-compatible route. `TOGETHER_MODEL` is accepted. Model aliases `deepseek-v4-pro` and `deepseek-v4-flash` normalize to Together's org-prefixed IDs; `inkling` and `together-inkling` normalize to Together's published lowercase Inkling wire ID. Inkling uses the exact `none`/`minimal`/`low`/`medium`/`high`/`max` reasoning vocabulary from Thinking Machines' [official model repository](https://huggingface.co/thinkingmachines/Inkling). Together's [launch post](https://www.together.ai/blog/together-ai-brings-thinking-machines-labs-new-model-inkling-on-day-0) currently says Inkling is live with 1M context, while its [model detail page](https://www.together.ai/models/inkling) says coming soon with 256K context and publishes no price. Until Together's active `/models` endpoint and the Models.dev catalog resolve that conflict, Inkling is not seeded into Codewhale's offline picker and no route-specific context or cost is inferred. |\n| `qianfan` | `[providers.qianfan]` | `QIANFAN_API_KEY`, `BAIDU_QIANFAN_API_KEY` | `QIANFAN_BASE_URL`, `BAIDU_QIANFAN_BASE_URL`; default `https://api.baiduqianfan.ai/v1` | `ernie-4.0-turbo-8k`; provider-scoped custom Qianfan service/model IDs pass through | Baidu Qianfan OpenAI-compatible route. Requests use Bearer auth and Chat Completions payloads. `QIANFAN_MODEL` and `BAIDU_QIANFAN_MODEL` are accepted; aliases `baidu-qianfan`, `baidu_qianfan`, and `baidu` resolve to this provider. Tool/function calling is model-scoped in Qianfan docs, so Codewhale preserves the selected wire model and leaves live capability proof to follow-up route/capability work. |\n| `openai-codex` | `[providers.openai_codex]` | Official Sign in with ChatGPT (`codewhale auth chatgpt` / `/provider setup openai-codex`) | Official `https://api.openai.com/v1` | Selected account catalog; configured model IDs remain explicit selections | **Experimental.** Public Responses endpoint (`/v1/responses`) with a validated `chatgpt.tokens.use.direct` grant. Dynamic OSS registration issues a client ID for the selected account/workspace; Codewhale protects and renews its own tokens. The account catalog supplies selectable models; public model lists and legacy Codex tokens do not establish plan permission. No silent billing fallback. See [Sign in with ChatGPT](#sign-in-with-chatgpt) for setup, usage limits, and preview boundaries. |\n| `anthropic` | `[providers.anthropic]` | `ANTHROPIC_API_KEY` | `ANTHROPIC_BASE_URL`; default `https://api.anthropic.com` | `claude-opus-4-8`, `claude-sonnet-4-6` (default), `claude-haiku-4-5` | Native Anthropic Messages API route (`/v1/messages`, `x-api-key` + `anthropic-version: 2023-06-01`) — not OpenAI-compatible. Prompt caching via `cache_control` breakpoints, adaptive thinking + `output_config.effort`, signed thinking blocks replayed verbatim, cache telemetry normalized per #2961. `ANTHROPIC_MODEL` is accepted. |\n| `openmodel` | `[providers.openmodel]` | `OPENMODEL_API_KEY` | `OPENMODEL_BASE_URL`; default `https://api.openmodel.ai` | `deepseek-v4-flash`; provider-scoped custom model IDs pass through | OpenModel Anthropic-compatible Messages route. Uses `/v1/messages`, Bearer auth, and `anthropic-version: 2023-06-01`; OpenModel selects DeepSeek, DashScope, Xiaomi, Claude, and other routes by model id. `OPENMODEL_MODEL` is accepted. |\n| `sakana` | `[providers.sakana]` | `FUGU_API_KEY`, `SAKANA_API_KEY` | `SAKANA_BASE_URL`; default `https://api.sakana.ai/v1` | `fugu` (default), `fugu-ultra-20260615` | Sakana AI Fugu OpenAI-compatible route. Standard Chat Completions wire protocol; streaming supported. `fugu-ultra-20260615` is the heavy/reasoning variant. Env var aliases: `FUGU_API_KEY` (primary), `SAKANA_API_KEY`; provider aliases: `sakana-ai`, `sakana_ai`, `fugu`. |\n| `longcat` | `[providers.longcat]` | `LONGCAT_API_KEY` | `LONGCAT_BASE_URL`; default `https://api.longcat.chat/openai/v1` | `LongCat-2.0` (default) | Meituan LongCat curated model gateway. OpenAI-compatible Chat Completions wire protocol. Sign up at https://longcat.chat/platform for an API key. Provider aliases: `long-cat`, `meituan-longcat`, `meituan`. |\n| `opencode-go` | `[providers.opencode_go]` | `OPENCODE_GO_API_KEY` | `OPENCODE_GO_BASE_URL`; default `https://opencode.ai/zen/go/v1` | `deepseek-v4-pro` (default), `grok-4.5`, `glm-5.2`, `glm-5.1`, `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.6`, `deepseek-v4-flash`, `mimo-v2.5`, `mimo-v2.5-pro` | [OpenCode Go](https://opencode.ai/docs/go/) subscription route using OpenAI-compatible Chat Completions. `OPENCODE_GO_MODEL` is accepted. Codewhale uses bare wire IDs; familiar `opencode-go/` input aliases normalize to the bare ID. Go models documented only on the Anthropic `/messages` endpoint are deliberately not advertised by this route until Codewhale supports per-model wire selection. Billing surfaces show the Go allowance instead of token-price estimates. |\n| `opencode-zen` | `[providers.opencode_zen]` | `OPENCODE_ZEN_API_KEY`, fallback `OPENCODE_API_KEY` | `OPENCODE_ZEN_BASE_URL`; default `https://opencode.ai/zen/v1` | `gpt-5.6` (default); current GPT, Claude, Qwen, DeepSeek, MiniMax, GLM, Kimi, Grok, Muse Spark, and free-model IDs | [OpenCode Zen](https://opencode.ai/docs/zen/) model-aware gateway. `OPENCODE_ZEN_MODEL` is accepted, and official `opencode/` selectors normalize to bare wire IDs. Each model's wire comes from the curated snapshot, then from the AI SDK package its [Models.dev](https://models.dev) row names: GPT, Grok, and Muse Spark rows use `/responses`; Claude and most Qwen rows use `/messages`; DeepSeek, MiniMax, GLM, Kimi, `qwen3.8-max`, and the free rows use `/chat/completions`. Responses and Chat Completions authenticate with Bearer `Authorization`, while Anthropic Messages uses `x-api-key`; none of these routes use ChatGPT/Codex OAuth guidance or headers. Gemini fails closed because its model-specific Google wire protocol is not implemented; Models.dev rows marked `deprecated` and models no loaded catalog lists also fail closed. See [OpenCode Zen protocol catalog](#opencode-zen-protocol-catalog). |\n| `meta` | `[providers.meta]` | `META_MODEL_API_KEY`, `MODEL_API_KEY` | `META_MODEL_API_BASE_URL`, `MODEL_API_BASE_URL`; default `https://api.meta.ai/v1` | `muse-spark-1.2` (default) | [Meta Model API](https://developer.meta.com/ai/resources/blog/build-with-muse-spark/) public-preview route using OpenAI-compatible Chat Completions. Muse Spark 1.2 keeps its wire ID, tool support, 1M context, 32K output metadata, and `none` through `xhigh` reasoning effort. `META_MODEL_API_MODEL` and `MODEL_API_MODEL` are accepted. Provider aliases: `meta-ai`, `meta_model_api`, `muse`, `muse-spark`. |\n| `telecomjs` | `[providers.telecomjs]` | `TELECOMJS_API_KEY` | `TELECOMJS_BASE_URL`; default `https://aigw.telecomjs.com/v1` | `deepseek-v4-pro` conservative fallback; authenticated `/models` rows when a key is configured | TelecomJS TokenHub OpenAI-compatible Chat Completions route. Live catalogs are isolated by provider and key fingerprint, stale rows survive transient refresh failures, and unsupported reasoning request fields are omitted. `TELECOMJS_MODEL` is accepted. Provider aliases: `telecom-js`, `telecom_js`, `telecomjs-cn`, `tokenhub`. |\n| `mistral` | `[providers.mistral]` | `MISTRAL_API_KEY` | `MISTRAL_BASE_URL`; default `https://api.mistral.ai/v1` | `mistral-code-latest` (default; `codestral-latest` accepted as alias), `mistral-medium-latest` (aliases: `mistral-medium-3-5`), `mistral-small-latest` (aliases: `mistral-small-2603`), `mistral-large-latest` | Mistral AI (la Plateforme) OpenAI-compatible Chat route. On the documented first-party HTTPS `/v1` hosts, Medium and Small send adjustable `reasoning_effort` (`none` or `high` only), parse Mistral's polymorphic thinking/text blocks, and replay stored thinking in that same wire shape. Deprecated native Magistral IDs remain explicit-configuration compatibility routes: they are always-reasoning and never receive the adjustable effort field. Code and Large are non-reasoning. A custom `MISTRAL_BASE_URL` keeps generic Chat semantics unless it is one of the documented first-party hosts. `MISTRAL_MODEL` is accepted. Provider aliases: `mistral-ai`, `mistralai`, `la-plateforme`. |\n| `edenai` | `[providers.edenai]` | `EDENAI_API_KEY` | `EDENAI_BASE_URL`; default `https://api.edenai.run/v3`; EU `https://api.eu.edenai.run/v3` | `deepseek/deepseek-v4-pro` (default); live `/models` catalog of `provider/model` ids | Eden AI OpenAI-compatible aggregation gateway. Catalog rows remain provider-scoped; generic reasoning controls are omitted because supported fields depend on the selected upstream family. `EDENAI_MODEL` is accepted. The default `deepseek/deepseek-v4-pro` is listed on the global catalog only; on the EU endpoint set `EDENAI_MODEL` (or `model`) to a row from the EU `/models` list, for example `qwen/deepseek-v4-pro`. Provider aliases: `eden-ai`, `eden_ai`. |\n| `zenmux` | `[providers.zenmux]` | `ZENMUX_API_KEY` | `ZENMUX_BASE_URL`; default `https://zenmux.ai/api/v1` | `deepseek/deepseek-v4.1-flash` (default); live `/models` catalog of `provider/model` ids (keyless-readable) | ZenMux OpenAI-compatible aggregation gateway (~190 models). Catalog rows remain provider-scoped; generic reasoning controls are omitted because supported fields depend on the selected upstream family. `ZENMUX_MODEL` is accepted. Provider aliases: `zen-mux`, `zen_mux`. |\n| `csdn` | `[providers.csdn]` | `CSDN_API_KEY` | `CSDN_BASE_URL`; default `https://ai.csdn.net/api/model/v1` | `glm_for_coding` (default; the Coding Plan's dedicated model id); other marketplace model ids pass through | CSDN 星图 (Starmap) OpenAI-compatible hosted platform. Coding Plan keys and general marketplace keys share the one endpoint, so billing follows the credential product, never the URL alone: routing `glm_for_coding` — the shipped default — or setting `mode = \"coding_plan\"`/`\"plan\"`/`\"subscription\"` bills as CSDN Coding Plan quota with no dollar estimates; any other model, or an explicit `pay-as-you-go`/`metered` mode, bills metered; an unrecognized mode or an endpoint off `ai.csdn.net/api/model/v1` reports `cost: unknown`. `CSDN_MODEL` is accepted. Provider aliases: `csdn-ai`, `csdn_ai`, `csdn-coding-plan`, `csdn_coding_plan`, `starmap`. |\n| `concentrate` | `[providers.concentrate]` | `CONCENTRATE_API_KEY` | `CONCENTRATE_BASE_URL`; default `https://api.concentrate.ai/v1` | `deepseek-v4-pro` (default; a plain catalog id lets the gateway pick the upstream provider); `provider/model` ids such as `openai/gpt-5.6-sol` pin one upstream; `concentrate/auto` is the gateway's own router; unauthenticated live `/v1/models` catalog | Concentrate OpenAI Responses-compatible gateway (`POST /v1/responses`, bearer Universal API key). Opt-in and BYOK only: your key, your Concentrate bill, zero Codewhale fee, no managed default. Requests carry only documented fields (the system prompt rides as a `system` input item). Contract: [API introduction](https://concentrate.ai/docs/api-reference/introduction), [request parameters](https://concentrate.ai/docs/api-reference/endpoint/request-parameters), [streaming](https://concentrate.ai/docs/api-reference/endpoint/streaming), [errors](https://concentrate.ai/docs/api-reference/endpoint/errors). See [Concentrate Notes](#concentrate-notes). |\n| `codewhale` | `[providers.codewhale]` | `CODEWHALE_API_KEY` | `CODEWHALE_API_BASE`; default `https://api.codewhale.net/v1` | `deepseek/deepseek-v4-pro` (default), `anthropic/claude-sonnet-5`, `openai/gpt-5.6` are offline bootstrap rows only; the authenticated `GET /v1/models` listing of the account's connected providers is the catalog authority | Codewhale API: account-backed model access over the provider keys the customer connected to their Codewhale account. One `cwc_key_…` account API key with the `models:infer` scope authenticates every model. Model ids are `provider/model` exactly as the account catalog returns them, and each row states its protocol (`chat-completions` → `/v1/chat/completions`, `anthropic-messages` → `/v1/messages`, `responses` → `/v1/responses`); every protocol uses `Authorization: Bearer`, never `x-api-key`. `CODEWHALE_API_BASE` must be HTTPS except on loopback. Connect provider keys with `codewhale account keys set `. Provider aliases: `codewhale-api`, `cw-api`, `codewhale-cloud`. |\n| `xai` | `[providers.xai]` | `XAI_API_KEY`, Codewhale-owned device OAuth, or explicit read-only Grok CLI consent | `XAI_BASE_URL`; default `https://api.x.ai/v1` | `grok-4.6` (default), `grok-4.7`, `grok-4.5`, `grok-4.3`, `grok-build`, `grok-composer-2.5-fast`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning` | xAI/Grok OpenAI-compatible Chat Completions route. Grok 4.6 has a 500K context window, text/image input, function calls, structured output, server-side web search, and `low`/`medium`/`high`/`xhigh` reasoning (default `high`). [Grok 4.7](https://docs.x.ai/docs/models/grok-4.7) has the same 500K window, text/image input, function calls, structured output, reasoning ladder and $2.00 / $0.50 cached / $6.00 rates; xAI documents no web-search support for it yet, so Codewhale does not claim it. Grok reasoning cannot be disabled ([reasoning guide](https://docs.x.ai/docs/guides/reasoning)). Its standard rates double when the prompt reaches 200K tokens; the same 2x long-context rule applies to `grok-4.5` (500K context, $2.00 / $0.30 cached / $6.00) and `grok-4.3` (1M context, $1.25 / $0.20 cached / $2.50) per their [model pages](https://docs.x.ai/docs/models/grok-4.5). There is no documented `latest`/`fast` alias and no published numeric output limit. **API-key** (default): Bearer token from console.x.ai via `XAI_API_KEY` / keyring / `api_key`. **OAuth**: `codewhale auth xai-device` uses SSH-friendly device login and Codewhale-owned storage, which may refresh itself. Existing Grok CLI credentials require `codewhale auth external-consent --provider xai --mode read-only`; the granted external file is never refreshed or rewritten. OAuth may return HTTP 403 on some SuperGrok tiers — keep API-key as the reliable fallback. `XAI_MODEL` is accepted. Provider aliases: `x-ai`, `x_ai`, `grok`. |\n| `modelstudio-token-plan` | `[providers.modelstudio_token_plan]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | `MODELSTUDIO_TOKEN_PLAN_BASE_URL`; default `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` | `qwen3.8-max` (default), `qwen3.8-max-preview`, `qwen3.7-plus`, `qwen3.7-max`, `qwen3.6-flash`, `deepseek-v4-pro`, `deepseek-v4-flash-0731`, `glm-5.2` | Alibaba Cloud Model Studio Token Plan OpenAI-compatible Chat Completions route. Token Plan Personal and Team share this endpoint. `qwen3.8-max`, `qwen3.7-plus`, and `qwen3.7-max` can use provider-native web search through the Token Plan Responses Harness; the preview, Coding Plan, and Anthropic routes do not inherit that capability. All listed models are reasoning-capable text/coding models. DeepSeek and GLM entries are provider-scoped and do not collide with first-party routes. `MODELSTUDIO_TOKEN_PLAN_MODEL` is accepted. Provider aliases: `modelstudio-token-plan`, `alibaba-token-plan`, `dashscope-token-plan`. |\n| `modelstudio-token-plan-anthropic` | `[providers.modelstudio_token_plan_anthropic]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | default `https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic` | Same model catalog as `modelstudio-token-plan` | Token Plan Anthropic-compatible Messages route (`/apps/anthropic`). Same API key as the OpenAI dialect. Provider aliases: `modelstudio-token-plan-anthropic`, `alibaba-token-plan-anthropic`. |\n| `modelstudio-coding-plan` | `[providers.modelstudio_coding_plan]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | `MODELSTUDIO_CODING_PLAN_BASE_URL`; default `https://coding-intl.dashscope.aliyuncs.com/v1` | `qwen3.8-max` (default); same catalog as Token Plan | Alibaba Cloud Model Studio Coding Plan OpenAI-compatible Chat Completions route. `MODELSTUDIO_CODING_PLAN_MODEL` is accepted. Provider aliases: `modelstudio-coding-plan`, `alibaba-coding-plan`, `dashscope-coding-plan`. |\n| `modelstudio-coding-plan-anthropic` | `[providers.modelstudio_coding_plan_anthropic]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | default `https://coding-intl.dashscope.aliyuncs.com/apps/anthropic` | Same model catalog as `modelstudio-coding-plan` | Coding Plan Anthropic-compatible Messages route (`/apps/anthropic`). Provider aliases: `modelstudio-coding-plan-anthropic`, `alibaba-coding-plan-anthropic`. |\n\nStepFun's four coding models are available through both its standard API and\n[Step Plan](https://platform.stepfun.ai/docs/en/step-plan/integrations/reasoning-api).\nChoose the billing route in `/provider`, then the model in `/model`; existing\nStep 3.7 selections remain unchanged. [Step 5 Preview](https://platform.stepfun.ai/docs/en/guides/models/step-5-preview)\nhas a 1M context window. Step 5 and Step 3.7 expose low/medium/high reasoning;\nStep 3.5 Flash 2603 exposes low/high; base Step 3.5 uses provider-default reasoning.\n[Published API prices](https://platform.stepfun.ai/docs/en/guides/pricing/details)\napply only to verified PAYG routes. Step Plan displays subscription allowance.\nSpeech, music and image-generation models use separate interfaces and are not\npresented as coding models. Provider video capability metadata does not imply\nthat every Codewhale client can attach video.\n\n\n### OpenCode Zen protocol catalog\n\nZen Responses and Chat Completions requests authenticate with Bearer\n`Authorization`; Zen Anthropic Messages requests use `x-api-key`. None of these\nroutes add ChatGPT/Codex OAuth headers.\n\nEach Zen model's wire comes from two sources, and neither is guessed from a\nmodel-id family (`qwen3.8-flash` is Messages while `qwen3.8-max` is Chat):\n\n1. **The curated snapshot** compiled into Codewhale, verified against the\n [official endpoint table](https://opencode.ai/docs/zen/) and the `opencode`\n provider in [Models.dev](https://models.dev). It is the offline floor, and\n it wins when a catalog row names a different wire for the same id.\n2. **The Models.dev catalog.** Its `opencode` provider is Zen's published\n catalog: each model row names the AI SDK package OpenCode itself uses, which\n Codewhale maps exactly — `@ai-sdk/openai` to Responses, `@ai-sdk/anthropic`\n to Anthropic Messages, and `@ai-sdk/openai-compatible` (the provider\n default) to Chat Completions. A Zen model released after this build routes\n once the catalog lists it, without a Codewhale release. The interactive TUI\n and `codewhale exec` both load the persisted catalog; refresh it with\n `codewhale models --update`.\n\nThe curated snapshot:\n\n- Responses: `gpt-6-astra`, `gpt-6-sol`, `gpt-6-luna`, `gpt-5.6-sol`,\n `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5`, `gpt-5.5-pro`, `gpt-5.4`,\n `gpt-5.4-pro`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.3-codex`,\n `gpt-5.3-codex-spark`, `gpt-5.2`, `gpt-5.2-codex`, `gpt-5.1`,\n `gpt-5.1-codex`, `gpt-5.1-codex-max`, `gpt-5.1-codex-mini`, `gpt-5`,\n `gpt-5-codex`, `gpt-5-nano`, `grok-4.7`, `grok-4.6`, `grok-4.5`,\n `grok-build-0.1`, `muse-spark-1.3`, `muse-spark-1.3-contributor-free`,\n `muse-spark-1.2`, `muse-spark-1.2-contributor`,\n `muse-spark-1.2-contributor-free`.\n- Anthropic Messages: `claude-fable-5-1`, `claude-fable-5`,\n `claude-opus-5-5`, `claude-opus-5`, `claude-opus-4-8`, `claude-opus-4-7`,\n `claude-opus-4-6`, `claude-opus-4-5`, `claude-sonnet-5-5`,\n `claude-sonnet-5`, `claude-sonnet-4-6`, `claude-sonnet-4-5`,\n `claude-sonnet-4`, `claude-haiku-4-5`, `qwen3.8-flash`, `qwen3.7-max`,\n `qwen3.7-plus`, `qwen3.6-plus`, `qwen3.5-plus`.\n- Chat Completions: `deepseek-v4.1-flash`, `deepseek-v4-pro`,\n `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp`, `minimax-m3`,\n `minimax-m2.7`, `minimax-m2.5`, `glm-5.3-flash`, `glm-5.3`, `glm-5.2`,\n `glm-5.1`, `glm-5`, `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.6`, `kimi-k2.5`,\n `qwen3.8-max`, `big-pickle`, `space-bunny-free`,\n `longcat-2.5-preview-free`, `mimo-v2.6-flash-free`, `mimo-v2.5-free`,\n `ling-3.0-flash-fin-free`, `north-mini-code-free`,\n `nemotron-3-ultra-free`, `nemotron-3.5-lightning-free`,\n `deepseek-v4-flash-free`.\n\nThese fail closed locally, with the reason in the error, instead of reaching\nZen: Gemini (`@ai-sdk/google`, Google's model-specific protocol, which Codewhale\ndoes not speak); a catalog row naming any other package; a catalog row Models.dev\nmarks `deprecated`; and a model neither source lists. A miss never falls back to\nanother Zen wire shape, including when a custom Zen base URL is configured.\n\n### Concentrate Notes\n\nConcentrate (`concentrate`) is an OpenAI **Responses**-compatible gateway:\n`POST https://api.concentrate.ai/v1/responses` with `Authorization: Bearer\n`. It is opt-in and **BYOK only**.\n\n- **Model ids pass through.** A plain catalog id (`gpt-5.6-sol`,\n `deepseek-v4-pro`) lets the gateway choose the upstream provider;\n `provider/model` (`openai/gpt-5.6-sol`) pins the upstream provider inside the\n gateway; `concentrate/auto` sends the gateway's own `auto` router. Codewhale's\n bare `--model auto` remains the resolver sentinel (provider default), which is\n why the gateway router has its own explicit spelling. Once streaming begins\n the gateway commits to one provider ([auto routing](https://concentrate.ai/docs/api-reference/endpoint/auto-routing)).\n- **Wire.** Fixed Responses dialect. The request carries only documented\n fields — `model`, `input`, `stream`, `max_output_tokens`, `tools`,\n `tool_choice`, `parallel_tool_calls`, `reasoning.effort` — and the system\n prompt travels as a leading `system` input item because `instructions` is\n not in the gateway's [parameter reference](https://concentrate.ai/docs/api-reference/endpoint/request-parameters).\n Streaming follows the typed `response.*` SSE events and ends on\n `response.completed` without a `[DONE]` sentinel ([streaming](https://concentrate.ai/docs/api-reference/endpoint/streaming)).\n- **Errors.** `{ \"error\", \"message\", \"model\"?, \"retry_after\"? }` with 400\n (bad request / unknown model), 401 (invalid key), 402 (insufficient\n credits), 424 (upstream provider unavailable), 429 (rate limit) —\n surfaced verbatim and classified by message ([errors](https://concentrate.ai/docs/api-reference/endpoint/errors)).\n- **Catalog.** `GET /v1/models` needs no key\n ([list models](https://concentrate.ai/docs/api-reference/endpoint/list-models)); the live rows\n are provider-scoped and unclaimed (no pricing or capability claims).\n- **Commercial boundary.** Concentrate's [Terms of Service](https://concentrate.ai/legal/terms-of-service)\n forbid reselling, white-labeling, or providing the service on a\n service-bureau basis without written consent, and its\n [Acceptable Use Policy](https://concentrate.ai/legal/acceptable-use-policy)\n forbids sharing or sublicensing keys. Codewhale therefore ships **no**\n Codewhale-owned Concentrate key, no stored customer key, no default or\n managed routing, and no markup — activation of any hosted lane is gated on\n written consent, terms, and billing approval (tracked in the Codewhale ops\n evidence checklist `concentrate-gateway-20260829/CHECKLIST.md`). Public\n pricing states \"No platform markup on tokens\".\n- **Self-test without a key.** `scripts/concentrate-selftest.sh` boots a\n local stub that speaks the documented contract and drives the real\n `codewhale exec` path through it, asserting the URL, bearer header, model\n passthrough, streaming, and the completed-turn receipt. No network call\n leaves the machine and no account is required.\n\n### Hugging Face Provider vs MCP vs Hub\n\nCodewhale's `huggingface` provider ID is only the OpenAI-compatible chat\ninference route through Hugging Face Inference Providers. It is selected with\n`/provider huggingface`, `CODEWHALE_PROVIDER=huggingface`, or\n`provider = \"huggingface\"`.\n\nHugging Face MCP is a separate external-tool route. Configure it through the\nMCP config described in `docs/MCP.md`, preferably using the settings-generated\nsnippet from . In the TUI, `/hf mcp status`\nchecks whether the Hugging Face MCP server appears in the resolved MCP config,\n`/hf mcp setup` prints the settings workflow and a placeholder-only shape, and\n`/hf concepts` explains the provider/MCP/Hub distinction.\n\nHub publishing or repository management remains explicit user action through\nHub-native tooling such as `huggingface_hub` or git. The `/hf` helper does not\nupload to Hugging Face and does not perform direct Hugging Face Hub HTTP search.\n\n### Xiaomi MiMo Notes\n\n`xiaomi-mimo` defaults to `mimo-v2.5-pro` for long-context reasoning and coding\nwork. The chat picker also exposes `mimo-v2.5-pro-ultraspeed` and the latest\nOmni model `mimo-v2.5`. Xiaomi MiMo TTS is available through\n`codewhale --provider xiaomi-mimo speech \"text\" --model tts` (or the `tts`\nalias). In Act and Operate, the provider-specific `speech` / `tts` tools are\navailable through deferred discovery when the Xiaomi MiMo route is configured.\n\n`/provider xiaomi-mimo ultraspeed` and `/provider xiaomi-mimo pro-ultraspeed`\nboth select `mimo-v2.5-pro-ultraspeed`. Speech aliases such as `tts`,\n`voice-design`, and `voice-clone` are separate from normal chat defaults.\n\nToken Plan keys default to the Singapore endpoint\n`https://token-plan-sgp.xiaomimimo.com/v1`. If your MiMo account is provisioned\nfor the China region, set `base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"`\nexplicitly in `[providers.xiaomi_mimo]` or set `mode = \"token-plan-cn\"`. Europe\nToken Plan accounts can set\n`base_url = \"https://token-plan-ams.xiaomimimo.com/v1\"` or use\n`mode = \"token-plan-ams\"`; `mode = \"pay-as-you-go\"`\nselects the standard API endpoint and standard MiMo key family. Xiaomi Token\nPlan docs and console expose credit/quota semantics, but Codewhale does not\ncurrently have a documented balance endpoint to poll, so cost display remains\nunknown rather than reusing token-price estimates from another provider.\n\nVoice-design and voice-clone shorthands map to `mimo-v2.5-tts-voicedesign` and\n`mimo-v2.5-tts-voiceclone`. Xiaomi's current\n[image-understanding guide](https://platform.xiaomimimo.com/docs/en-US/usage-guide/multimodal-understanding/image-understanding)\nincludes `mimo-v2.5` for image input. Codewhale exposes image analysis through the\nseparate `[vision_model]` / `image_analyze` path; set that model to\n`mimo-v2.5` when using MiMo for vision.\n\n### OpenRouter-Compatible Base URLs\n\nOpenRouter-compatible gateways should usually stay on the `openrouter`\nprovider with a provider-scoped `base_url` override instead of moving through\nthe generic `openai` route. That keeps OpenRouter-style reasoning, streaming,\ncache usage, and namespaced wire model parsing attached to the selected route:\n\n```toml\nprovider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"sk-...\"\nbase_url = \"https://openrouter-compatible.example/v1\"\nmodel = \"deepseek/deepseek-v4-pro\"\n```\n\nCodewhale preserves the `deepseek/` wire-model prefix under the OpenRouter\nprovider scope; it does not infer a switch to the direct DeepSeek provider from\nthat model string. Cache fields such as `prompt_cache_hit_tokens`,\n`prompt_cache_miss_tokens`, and `prompt_tokens_details.cached_tokens` are\nparsed when the upstream gateway sends them. If a key/account type omits those\nfields, Codewhale treats them as absent for that response rather than as a\ndifferent provider route.\n\nOrcaRouter (`https://api.orcarouter.ai/v1`) is a dedicated named route\n([OrcaRouter](https://www.orcarouter.ai)) that speaks the same OpenAI\nChat Completions wire protocol and serves the same namespaced\n`vendor/model` catalog. It does not need an OpenRouter-compatible\n`base_url` override: select `provider = \"orcarouter\"` and its namespaced\nwire models (for example `deepseek/deepseek-v4-pro` or its own\n`orcarouter/auto` router) pass through verbatim, exactly as they do on the\nOpenRouter provider scope.\n\n#### OrcaRouter credentials: API key or OAuth 2.0 + PKCE\n\nOrcaRouter accepts two credential sources, and both write the same durable\n`sk-orca-…` key to the same `orcarouter` secret-store slot with\n`auth_mode = \"api_key\"`:\n\n- **API key** — `codewhale auth set --provider orcarouter`, `/provider`, or\n `ORCAROUTER_API_KEY`. Use this when you already have a key.\n- **Connect with OrcaRouter** — `codewhale auth orcarouter` (CLI) or\n `/auth orcarouter` (in-session). This runs an OAuth 2.0 authorization-code\n flow with PKCE (S256) on a loopback redirect. The browser is sent to\n `GET https://www.orcarouter.ai/auth` with `callback_url`, `code_challenge`,\n `code_challenge_method=S256`, `state`, `app_name`, and `scope`; the code is\n exchanged at `POST https://www.orcarouter.ai/api/v1/auth/keys` for a\n durable API key. There is no client secret and no pre-registered redirect\n URI; state is compared in constant time before the code is used.\n\nAuthentication and inference use different origins: the consent and exchange\nlive on `https://www.orcarouter.ai`; models and chat live on\n`https://api.orcarouter.ai/v1`. Neither is derived from the other, and\n`/v1/auth/keys` on the API origin is not the exchange route. Self-hosted\ndeployments can override each origin explicitly with `ORCA_AUTH_BASE_URL`\n(auth) and `ORCA_API_BASE_URL`/`ORCAROUTER_BASE_URL` (inference); the explicit\nvalue wins. Remote origins must be HTTPS; plain HTTP is accepted only on\nloopback.\n\nA PKCE-issued key is a durable API key, **not** a refresh token: Codewhale\nstores it, reuses it across restarts, and never sends a refresh grant. Revoke\nthe key on the OrcaRouter console (`/auth orcarouter-revoke` clears the local\ncopy) and sign in again to get a new one. A 401 from the relay marks that\ncredential generation as needing re-authentication rather than silently\nretrying.\n\nThe chat model list is discovered live from `GET https://api.orcarouter.ai/v1/models`\nwith the configured key. OrcaRouter publishes `supported_endpoint_types` on\nevery row, so the chat selector keeps only rows advertising `openai`,\n`anthropic`, `gemini`, or `openai-response`, and drops image-generation,\nvideo, and rerank rows instead of guessing from a model name. A row that\nstates `architecture.input_modalities` with `image` is image-input capable;\nrows that state no architecture are treated as unknown, not as text-only.\n\n### Recent OpenRouter Large Models\n\nOpenRouter completions and static registry rows include the April 2026 onward\nlarge models verified through OpenRouter's model metadata:\n`arcee-ai/trinity-large-thinking`, `qwen/qwen3.6-flash`,\n`qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`,\n`qwen/qwen3.6-plus`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`,\n`xiaomi/mimo-v2.5`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`,\n`z-ai/glm-5.1`, `z-ai/glm-5.2`, `z-ai/glm-5-turbo`, `tencent/hy3-preview`,\n`google/gemma-4-31b-it`, `google/gemma-4-26b-a4b-it`, and\n`nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`.\n`minimax/minimax-m3` was added from OpenRouter's May 31, 2026 listing as a 1M\ncontext multimodal model for coding, tool use, and long-horizon agentic work.\n`GLM-5.3` is now the default direct Z.AI Coding Plan model; `GLM-5.2` /\n`z-ai/glm-5.2` remain available (explicit selections keep their own id),\n`GLM-5.1` / `z-ai/glm-5.1` remain available as the smaller model,\n`GLM-5.3-Flash` / `z-ai/glm-5.3-flash` is the faster/explore sibling of\n`GLM-5.3`, and `GLM-5-Turbo` / `z-ai/glm-5-turbo` remains the faster sibling\nof `GLM-5.2`.\n`GLM-5.3` / `z-ai/glm-5.3` and `GLM-5.3-Flash` / `z-ai/glm-5.3-flash` are\nfirst-class picker ids on the Z.ai and OpenRouter routes (`/model` after\n`/provider zai`, or `model = \"GLM-5.3-Flash\"`).\nLimits and reasoning options for 5.3 are inherited from\n`GLM-5.2` until Z.ai publishes distinct 5.3 metadata, and 5.3 carries no\nprice. Flash ships the published $0.15/$0.50 list. A live call can still\n429 with entitlement code 1311 on accounts that are not provisioned for 5.3.\n\n## Static Model Registry\n\nThis table records legacy selector compatibility. Current `codewhale model\nresolve`, `codewhale model list`, the picker and Runtime API use the shared\nprovider lake and route resolver. `models --update` refreshes the existing\ncatalog cache; later routes see new metadata, while dispatched requests retain\ntheir admitted limits and price receipt.\n\nThe reviewed `selections` in `crates/config/assets/catalog_corrections.json`\nremain compatibility metadata for legacy callers. They do not establish account\navailability or executable route permission. Public discovery can contain\nmetadata-only models; an authenticated provider roster establishes a different\nfact. A same-named custom endpoint keeps its own declared limits and prices.\n\n`codewhale model resolve MODEL --provider PROVIDER` reports the exact wire ID,\ncanonical ID when declared, limits and implemented capabilities. Runtime\n`GET /v1/providers/PROVIDER/models` adds source, fetch/expiry metadata, published\nprice structure, reasoning options, configured effective limits and the price\nstatus. `account_access: unverified` means these facts have not established the\nselected account's access. Context responses distinguish an active engine\nwindow from the static route's resolution source, including any fallback.\nSee [Catalog refresh](CATALOG_REFRESH.md) for freshness and estimate boundaries.\n\n| Provider | Static registry entries | Tool calls | Registry reasoning flag |\n| --- | --- | --- | --- |\n| `deepseek` | `deepseek-v4-pro`, `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp` | yes | yes |\n| `nvidia-nim` | `deepseek-ai/deepseek-v4-pro`, `deepseek-ai/deepseek-v4-flash` | yes | yes |\n| `openai` | `deepseek-v4-pro`, `deepseek-v4-flash`, `gpt-5.6`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` | yes | yes |\n| `atlascloud` | `deepseek-ai/deepseek-v4-flash`, `deepseek-ai/deepseek-v4-pro` | yes | yes |\n| `wanjie-ark` | `deepseek-reasoner` | yes | yes |\n| `volcengine` | `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash` | yes | yes |\n| `openrouter` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`, `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `minimax/minimax-m2.7`, `xiaomi/mimo-v2.5-pro`, `xiaomi/mimo-v2.5`, `qwen/qwen3.6-flash`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`, `qwen/qwen3.6-plus`, `qwen/qwen3.7-max`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`, `z-ai/glm-5.1`, `z-ai/glm-5.2`, `z-ai/glm-5.3`, `z-ai/glm-5.3-flash`, `z-ai/glm-5-turbo`, `tencent/hy3-preview`, `google/gemma-4-31b-it`, `google/gemma-4-26b-a4b-it`, `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`, `nvidia/nemotron-3-ultra-550b-a55b` | yes | yes |\n| `orcarouter` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`, `orcarouter/auto` | yes | yes |\n| `xiaomi-mimo` | `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, `mimo-v2.5`; speech/TTS IDs are selected through `codewhale speech` / `tts` | yes | yes for chat models; no for speech/TTS models |\n| `novita` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | yes | yes |\n| `fireworks` | `accounts/fireworks/models/deepseek-v4-pro` | yes | yes |\n| `siliconflow` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes |\n| `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` |\n| `moonshot` | `kimi-k2.7-code`, `kimi-k2.6` | yes | yes |\n| `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 |\n| `stepfun` | `step-3.7-flash` (default), `step-5-preview`, `step-3.5-flash`, `step-3.5-flash-2603` | yes | yes |\n| `minimax` | `MiniMax-M3`, `MiniMax-M2.7`, `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `MiniMax-M2.1`, `MiniMax-M2.1-highspeed`, `MiniMax-M2` | yes | yes |\n| `minimax-anthropic` | `MiniMax-M3`, `MiniMax-M2.7` | yes | yes |\n| `sglang` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes |\n| `vllm` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes |\n| `ollama` | live local tag; custom tags pass through when provider hint is `ollama` | yes | no |\n| `ollama-cloud` | `gpt-oss:120b`; arbitrary provider-owned model IDs pass through | yes | yes |\n| `huggingface` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | no |\n| `modelscope` | `Qwen/Qwen3.5-397B-A17B`, `Qwen/Qwen3.5-122B-A10B`, `Qwen/Qwen3.5-27B`, `Qwen/Qwen3.5-35B-A3B`, `Qwen/Qwen3.8-27B`, `Qwen/Qwen3.8-Flash-Next`, `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Pro-0813`, `deepseek-ai/DeepSeek-V4.1-Flash`, `ZhipuAI/GLM-4.7-Flash`, `ZhipuAI/GLM-5.2` | yes | no |\n| `deepinfra` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes |\n| `together` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash`, `thinkingmachines/inkling` | yes | yes |\n| `openai-codex` | `gpt-5.5` | yes | yes |\n| `anthropic` | `claude-opus-5`, `claude-opus-4-8`, `claude-sonnet-5`, `claude-sonnet-4-6`, `claude-fable-5`, `claude-haiku-4-5` | yes | yes except `claude-haiku-4-5` |\n| `openmodel` | `deepseek-v4-flash`; provider-scoped custom model IDs pass through | yes | model-dependent |\n| `sakana` | `fugu`, `fugu-ultra-20260615` | yes | yes for `fugu-ultra-20260615` |\n| `longcat` | `LongCat-2.0` | yes | yes |\n| `opencode-go` | `deepseek-v4-pro`, `grok-4.5`, `glm-5.2`, `glm-5.1`, `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.6`, `deepseek-v4-flash`, `mimo-v2.5`, `mimo-v2.5-pro` | yes | yes |\n| `meta` | `muse-spark-1.2` | yes | yes |\n| `xai` | `grok-4.6`, `grok-4.7`, `grok-4.5`, `grok-4.3`, `grok-build`, `grok-composer-2.5-fast`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning` | yes | yes for `grok-4.6`, `grok-4.7`, `grok-4.5`, `grok-4.3`, `grok-build`, and `grok-4.20-0309-reasoning` |\n| `google` | `gemini-3.1-pro-preview`, `gemini-3-pro-preview`, `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-2.5-pro`, `gemini-2.5-flash` | yes | yes except `gemini-3.5-flash-lite` |\n| `mistral` | `mistral-code-latest`, `mistral-medium-latest`, `mistral-small-latest`, `mistral-large-latest` | yes | yes for Medium and Small (`reasoning_effort` `none` or `high` on exact first-party routes); deprecated native Magistral remains an always-on explicit compatibility ID; no for Code and Large |\n| `modelstudio-token-plan`, `modelstudio-coding-plan` | `qwen3.8-max`, `qwen3.8-max-preview`, `qwen3.7-plus`, `qwen3.7-max`, `qwen3.6-flash`, `deepseek-v4-pro`, `deepseek-v4-flash-0731`, `glm-5.2` | yes | yes |\n| `csdn` | `glm_for_coding`; other marketplace model IDs pass through | yes | yes |\n\nAtlasCloud keeps the same default model as the config layer and adds\nprovider-scoped aliases for the Pro and Flash rows. Other AtlasCloud model IDs\nshould still be selected through `ATLASCLOUD_MODEL`, config, or live model\nlisting when available.\n\n## Capability Metadata\n\n`codewhale doctor --json` exposes the `capability` object. It is static\nmetadata, not a live API probe. Current fields are:\n\n`resolved_provider`, `resolved_model`, `context_window`, `max_output`,\n`thinking_supported`, `cache_telemetry_supported`, and `request_payload_mode`.\n\nWhen configuration cannot be loaded or validated, `doctor --json` exits\nnonzero and prints a bounded, secret-redacted JSON error envelope with\n`status = \"error\"` and `error.kind = \"config_validation\"` instead of emitting\nmisleading route or capability metadata.\n\nMost shipped providers use the Chat Completions request payload mode. Native\nMessages routes, including `minimax-anthropic`, use `/v1/messages`, and\n`openai-codex` uses Responses.\n\nFor OpenAI-compatible gateways or self-hosted runtimes whose real window\ndiffers from the static table, set `[providers.] context_window = N`.\nThe configured value becomes the route-effective context window for prompts,\ncontext-pressure checks, compaction, and output-cap budgeting.\n\n`max_output` is optional and truthful: it is `null` (and omitted from the\ncapability struct on the wire) when the route publishes no output maximum we\ncan stand behind — the Kimi Code membership `kimi-for-coding` family is the\ncanonical example, since the membership catalog owns their limits. An unknown\noutput ceiling is never backfilled with a placeholder, and it applies **no**\ncompatibility clamp to a turn's requested `max_tokens`; only a concrete\nroute/offering maximum narrows the request. A model the catalogue simply has no\nrow for is a different fact — absence is not permission, so an uncatalogued id\nkeeps a conservative ceiling. The \"Max output metadata\" column below reads\n`unknown` wherever no documented maximum exists.\n\nThe exact Kimi Code membership roster contains `k3`, `k3-256k`,\n`kimi-for-coding`, and `kimi-for-coding-highspeed`. The two K3 ids share the\nsame reasoning and fixed-sampling contract; `k3-256k` stays at 262,144 tokens,\nwhile bare `k3` can use an entitled 1M override.\n\n| Provider/model class | Context window | Max output metadata | Thinking support | Cache telemetry | FIM endpoint |\n| --- | --- | --- | --- | --- | --- |\n| DeepSeek V4 (`deepseek-v4-pro`, `deepseek-v4-flash`) | 1,000,000 | 384,000 | yes | yes | DeepSeek beta only, on Chat Completions routes; not on the default Flash route, which speaks Responses |\n| DeepSeek V4 Flash Vision experimental (`deepseek-v4-flash-vision-exp`) | 1,000,000 inherited from Flash | 384,000 inherited from Flash | yes, inherited | yes, inherited | not claimed; Chat Completions route only |\n| DeepSeek compatibility aliases (`deepseek-chat`, `deepseek-reasoner`) | 1,000,000 | 384,000 | yes | yes | DeepSeek beta only |\n| NVIDIA NIM V4 registry models | 1,000,000 | 384,000 | yes | yes | not documented in code |\n| Volcengine Ark V4 model IDs | 1,000,000 | 384,000 | yes | yes | not documented in code |\n| OpenRouter, Novita, Fireworks, SiliconFlow, SGLang, and vLLM V4 model IDs | 1,000,000 | 384,000 | yes | no | not documented in code |\n| Xiaomi MiMo `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, `mimo-v2.5` | 1,000,000 | 131,072 | yes | no | not documented in code |\n| OpenRouter Qwen 3.6 Flash / Plus | 1,000,000 | 65,536 | yes | no | not documented in code |\n| OpenRouter Qwen 3.6 35B / 27B | 262,144 | 262,140 | yes | no | not documented in code |\n| OpenRouter Qwen 3.6 Max Preview | 262,144 | 65,536 | yes | no | not documented in code |\n| OpenAI API `gpt-5.5` | 1,050,000 | 128,000 | yes | no | not documented in code |\n| OpenAI API `gpt-5.6`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` | 1,050,000 | 128,000 | yes | no | not documented in code |\n| Anthropic API `claude-opus-5`, `claude-opus-4-8`, `claude-sonnet-5`, `claude-sonnet-4-6`, `claude-fable-5` | 1,000,000 | 128,000 | yes | yes | not documented in code |\n| Google Gemini API `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-3.1-pro-preview`, `gemini-2.5-pro`, `gemini-2.5-flash` | 1,048,576 | 65,536 | model-dependent | no | not documented in code |\n| Meta Model API `muse-spark-1.2` | 1,000,000 | 32,000 | yes | no | not documented in code |\n| ChatGPT plan route (`openai-codex`) | conservatively budgeted; account listing does not state a window | no output cap sent in preview | model dependent | no | public `/v1/responses`; no context limit is inferred from account eligibility |\n| OpenModel default/custom model IDs | 200,000 fallback unless model metadata or config overrides it | 64,000 fallback | model-dependent | no | route uses Messages payload at `/v1/messages` |\n| Wanjie Ark `reasoner` / `r1` model IDs | 128,000 | unknown (no documented maximum) | yes | no | not documented in code |\n| Direct Arcee API `trinity-large-thinking` | 262,144 | 262,144 | yes | no | not documented in code |\n| Direct Arcee API `trinity-large-preview` | 262,144 | unknown (no documented maximum) | no in doctor capability metadata | no | not documented in code |\n| Direct Moonshot `kimi-k3` | 1,048,576 | 1,048,576 documented maximum; 131,072 provider default | yes | no | exact route uses `max_completion_tokens` and omits fixed sampling fields ([K3 quickstart](https://platform.kimi.ai/docs/guide/kimi-k3-quickstart)) |\n| Kimi Code membership `k3` | 262,144 safe baseline; 1,048,576 with an explicit entitled-plan override | 131,072 conservative default ceiling; membership maximum is not published | yes | no | exact `https://api.kimi.com/coding/v1` route |\n| Kimi Code membership `k3-256k` | 262,144 fixed | 131,072 conservative default ceiling; membership maximum is not published | yes | no | exact `https://api.kimi.com/coding/v1` route |\n| Direct Moonshot/Kimi K2.7/K2.6 (`kimi-k2.7-code`, `kimi-k2.7-code-highspeed`, `kimi-k2.6`) | 262,144 | 32,768 | yes | no | provider-reported bundled catalog |\n| Kimi Code membership `kimi-for-coding`, `kimi-for-coding-highspeed` | 262,144 | unknown — the membership catalog owns these limits and no client-side ceiling is claimed | yes | no | exact `https://api.kimi.com/coding/v1` route |\n| Direct Z.AI `GLM-5.3` (default) | 1,000,000 | 131,072 | yes | no | live on the GLM Coding Plan; limits inherited from `GLM-5.2` until Z.ai publishes distinct 5.3 numbers; no USD price |\n| Direct Z.AI `GLM-5.3-Flash` | 1,000,000 | 131,072 | yes | no | natively multimodal; $0.15/$0.50 list (2026-08-26); faster/explore sibling of `GLM-5.3` |\n| Direct Z.AI `GLM-5.2` | 1,000,000 | 131,072 | yes | no | not documented in code |\n| Direct Z.AI `GLM-5.1` | 202,752 | 131,072 | yes | no | not documented in code |\n| Direct Z.AI `GLM-5-Turbo` | 202,752 | 131,072 | yes | no | faster/explore sub-agent sibling |\n| Direct MiniMax `MiniMax-M3` | 1,000,000 | 524,288 | yes | no | not documented in code |\n| Direct MiniMax M2.x models | 204,800 | unknown until MiniMax output metadata is promoted | yes | no | not documented in code |\n| MiniMax Messages route (`MiniMax-M3`, `MiniMax-M2.7`) | model-specific values above | model-specific values above | yes | no | route uses `/anthropic/v1/messages` |\n| Generic `openai` and AtlasCloud | 128,000 | unknown (no documented maximum) | no in doctor capability metadata | no | not documented in code |\n| Ollama | 8,192 | unknown (no documented maximum) | no | no | not documented in code |\n| Hugging Face Inference Providers V4 model IDs | 131,072 | unknown (no documented maximum) | yes | no | not documented in code |\n| Other recognized DeepSeek model IDs | 128,000 unless the model name carries an explicit `Nk` hint | unknown (no documented maximum) | no unless V4/reasoner logic matches | DeepSeek/NIM only | DeepSeek beta only |\n\nMiniMax M3 uses input-length and service tiers. Codewhale omits\n`service_tier`, so requests use the standard tier and cost estimates select the\ncorrect standard rate from total input usage. Priority rates are listed to keep\nthe official tier structure visible. Prices are USD per million tokens.\n\n| Model / service tier | Input length | Input | Output | Cache read | Cache write |\n| --- | --- | ---: | ---: | ---: | ---: |\n| `MiniMax-M3` standard | up to 512,000 input tokens | $0.30 | $1.20 | $0.06 | not published |\n| `MiniMax-M3` standard | over 512,000 input tokens | $0.60 | $2.40 | $0.12 | not published |\n| `MiniMax-M3` priority | up to 512,000 input tokens | $0.45 | $1.80 | $0.09 | not published |\n| `MiniMax-M3` priority | over 512,000 input tokens | $0.90 | $3.60 | $0.18 | not published |\n| `MiniMax-M2.7` standard | all supported inputs | $0.30 | $1.20 | $0.06 | $0.375 |\n\nThese values come from the [MiniMax pay-as-you-go pricing\nguide](https://platform.minimax.io/docs/guides/pricing-paygo). M3 thinking is\nadaptive or disabled; the OpenAI-compatible API defaults to adaptive and the\nAnthropic-compatible API defaults to disabled. M2.7 thinking cannot be\ndisabled. Codewhale sends explicit controls when the user selects a reasoning\nmode.\n\nTool-call support is tracked separately by the static `ModelRegistry` and by\nthe endpoint's ability to accept OpenAI-compatible `tools` payloads. A custom\nOpenAI-compatible or local endpoint can still reject tool calls even if\nCodewhale can send the schema.\n\n### Hugging Face Inference Providers Notes\n\nThe shipped Hugging Face route targets the OpenAI-compatible Inference Providers\nrouter at `https://router.huggingface.co/v1`. Configure auth with\n`HUGGINGFACE_API_KEY` first, or `HF_TOKEN` as a fallback. Configure the endpoint\nwith `HUGGINGFACE_BASE_URL` first, or `HF_BASE_URL` as a fallback; configure the\nmodel with `HUGGINGFACE_MODEL` first, or `HF_MODEL` as a fallback.\n\nThis route does not imply Hub browsing, model-card metadata, dataset access,\nJobs, uploads, or export. Those remain explicit Model Lab work items so\nprovider auth and artifact movement stay separate.\n\n### When a Local Model Prints Tool JSON\n\nCodewhale only executes tools when the provider returns Chat Completions\n`tool_calls` or streamed `delta.tool_calls`. If a local model prints text such\nas `{\"name\":\"File\",\"arguments\":{\"action\":\"search_content\",...}}` in the\nassistant message, that is ordinary model output, not an executable tool\nrequest.\n\nFor OpenAI-compatible or local runtimes, check:\n\n- The endpoint accepts the `tools` array in `/v1/chat/completions` requests.\n- The selected model or chat template is configured for function/tool calls.\n- The server returns `tool_calls` in the response rather than plain JSON text.\n- The compatibility layer does not strip tools before forwarding the request.\n- If in doubt, test a small `File` `read` or `search_content` action against a\n known tool-calling model before debugging Codewhale's tool registry.\n\nChanging `provider`, `base_url`, or `model` can select a route that supports the\nOpenAI-compatible payload shape, but Codewhale cannot convert arbitrary JSON\ntext into a trusted tool call after the model has emitted it as prose.\n\nDeepSeek will retire `deepseek-chat` and `deepseek-reasoner` on 2026-07-24 at\n15:59 UTC. Codewhale migrates either name to `deepseek-v4-flash` before a\nrequest reaches DeepSeek's first-party OpenAI or Anthropic endpoint. If no\nreasoning tier was configured, `deepseek-chat` also migrates to `off` and\n`deepseek-reasoner` to `high`, preserving their former non-thinking / thinking\nintent; an explicit `reasoning_effort` remains authoritative. The mapping is\ndeliberately not global: Wanjie Ark, aggregators, self-hosted runtimes, and\ncustom endpoints continue to own their model ids.\n\n## Reasoning Effort\n\n`/reasoning ` (and the `reasoning_effort` config key) is translated to\neach provider's wire dialect by the client before the request is sent. `off`\ndisables thinking where the route supports it. Both exact K3 routes map `off`\nto their lowest supported tier, `low`, and the model is never switched to\nsatisfy `off` — but they do so for different reasons:\n\n- **Kimi Code membership K3** (exact `https://api.kimi.com/coding/v1` with\n `model = \"k3\"` or `model = \"k3-256k\"`) — the membership roster declares K3 always-thinking, so `off`\n cannot be honored without changing what the model is. The clamp preserves the\n fixed K3 identity.\n- **Direct Moonshot K3** (exact `https://api.moonshot.ai/v1` with\n `model = \"kimi-k3\"`) — this clamp is *defensive*, not a documented contract.\n The direct platform publishes no `off` state for K3, and Codewhale will not\n assert a fixed-thinking guarantee it cannot verify for a given key's\n entitlement, so the requested `off` is normalized to the lowest tier with the\n live entitlement left unknown.\n\nNormal dispatched\n`auto` uses Codewhale's auto-reasoning selector and sends a concrete tier;\nonly an omitted reasoning setting leaves the provider default in control.\nProviders marked \"omitted\" receive no reasoning fields at all for that tier.\n\n| Provider | `off` | `low`/`medium`/`high` | `max`/`xhigh` |\n| --- | --- | --- | --- |\n| `deepseek`, `deepseek-cn`, `siliconflow`, `siliconflow-CN`, `sglang`, `volcengine`, `atlascloud` | `thinking: {type: disabled}` | `reasoning_effort: \"high\"` + `thinking: {type: enabled}` | `reasoning_effort: \"max\"` + `thinking: {type: enabled}` |\n| `openrouter`, `novita`, other `together` models | `thinking: {type: disabled}` | `reasoning_effort` pass-through + `thinking: {type: enabled}` | `reasoning_effort: \"xhigh\"` + `thinking: {type: enabled}` |\n| `together` + `thinkingmachines/inkling` | `reasoning_effort: \"none\"` | exact `minimal`/`low`/`medium`/`high` `reasoning_effort` | `reasoning_effort: \"max\"` |\n| Direct Moonshot `kimi-k3` at exact `https://api.moonshot.ai/v1` | top-level `reasoning_effort: \"low\"` (effective normalization) | top-level `reasoning_effort: \"low\"` / `\"high\"` (`medium` becomes `high`) | top-level `reasoning_effort: \"max\"` |\n| Kimi Code membership `k3`, `k3-256k` at exact `https://api.kimi.com/coding/v1` | `thinking: {type: enabled, effort: \"low\"}` (effective normalization) | `thinking: {type: enabled, effort: \"low\" | \"high\"}` | `thinking: {type: enabled, effort: \"max\"}` |\n| Other `moonshot` routes | `thinking: {type: disabled}` | `thinking: {type: enabled}` | `thinking: {type: enabled}` |\n| `ollama` | `think: false` | `think: true` | `think: true` |\n| `ollama-cloud` | `reasoning_effort: \"none\"` | exact `low`/`medium`/`high` `reasoning_effort` | `reasoning_effort: \"max\"` |\n| `xiaomi-mimo` | `thinking: {type: disabled}` | `thinking: {type: enabled}` | `thinking: {type: enabled}` |\n| First-party `minimax` `MiniMax-M3` | `reasoning_split: true` + `thinking: {type: disabled}` | `reasoning_split: true` + `thinking: {type: adaptive}`; effective tier granularity unavailable | `reasoning_split: true` + `thinking: {type: adaptive}`; effective tier granularity unavailable |\n| First-party Z.ai `GLM-5.2` | `thinking: {type: disabled}`; no `reasoning_effort` | enabled thinking; only effective `high` adds `reasoning_effort: \"high\"` | enabled thinking + `reasoning_effort: \"max\"` |\n| First-party Z.ai `GLM-5.3` | `thinking: {type: disabled}`; no `reasoning_effort` | enabled thinking; only effective `high` adds `reasoning_effort: \"high\"` | enabled thinking + `reasoning_effort: \"max\"` |\n| First-party Z.ai `GLM-5.3-Flash` | `thinking: {type: disabled}`; no `reasoning_effort` | enabled thinking; only effective `high` adds `reasoning_effort: \"high\"` | enabled thinking + `reasoning_effort: \"max\"` |\n| First-party Z.ai `GLM-5-Turbo` | `thinking: {type: disabled}` | enabled thinking; effort granularity unavailable | enabled thinking; effort granularity unavailable |\n| Compatible gateways configured as `zai` | omitted; effective unavailable | omitted; effective unavailable | omitted; effective unavailable |\n| `nvidia-nim` | `chat_template_kwargs.thinking: false` | `chat_template_kwargs`: `thinking: true` + `reasoning_effort: \"high\"` | `chat_template_kwargs`: `thinking: true` + `reasoning_effort: \"max\"` |\n| `vllm` | `chat_template_kwargs.enable_thinking: false` | `chat_template_kwargs.enable_thinking: true` + `reasoning_effort` low/medium/high | `chat_template_kwargs.enable_thinking: true` + `reasoning_effort: \"high\"` (vLLM has no max tier) |\n| `arcee`, `huggingface` | omitted | `reasoning_effort` pass-through | `reasoning_effort: \"high\"` |\n| `fireworks` | omitted | `reasoning_effort: \"high\"` | `reasoning_effort: \"max\"` |\n| `openai`, `wanjie-ark`, `telecomjs` | omitted | omitted | omitted |\n| `openmodel` | Anthropic Messages adapter handles thinking/output configuration | Anthropic Messages adapter handles thinking/output configuration | Anthropic Messages adapter handles thinking/output configuration |\n| `openai-codex` | Responses API `reasoning` field (handled by the Responses bridge) | Responses API `reasoning` field | Responses API `reasoning` field |\n\nAtlasCloud serves DeepSeek models, so it speaks the DeepSeek reasoning dialect,\nincluding the `max` tier (#3024).\n\nOn the exact MiniMax OpenAI-compatible Chat endpoints, `MiniMax-M3` uses\n`max_completion_tokens`. Other MiniMax models and compatible gateways retain\n`max_tokens`; the MiniMax Anthropic endpoints use the separate Messages\nadapter.\n\n## Drift Check\n\nRun this before changing provider IDs, provider TOML tables, static model\nregistry rows, or provider default strings:\n\n```bash\npython3 scripts/check-provider-registry.py\n```\n\nThe check fails when:\n\n- `docs/PROVIDERS.md` omits a canonical `ProviderKind::as_str()` ID.\n- Descriptor presentation IDs or released wire tags are duplicated or drift\n from intrinsic kinds, or a second provider enum/ordinal bridge is introduced.\n- The shipped-provider table omits or adds a `[providers.*]` TOML table.\n- The static model registry table drifts from providers used by\n `crates/agent/src/lib.rs`.\n- A provider default model or base URL constant in `crates/tui/src/config.rs`\n is no longer mentioned here.\n\n## Provider Gotchas\n\nThree failures that look like something else (measured by a provider operator\non live traffic):\n\n- **A cold model looks broken.** The first call to a model the provider has not\n loaded can take 90 to 150 seconds, then drop to a second or less once warm.\n Raise `[stream].open_timeout_secs` (default 45 seconds, at most 300) before reading a timeout as \"model unavailable\".\n- **Listed is not callable.** A catalog entry can be visible and still fail\n every call. The same error text on every attempt (for example a \"resources\n busy\" 502) is not transient: a genuinely busy upstream returns successes in\n between. Probe a model before trusting it, and stop retrying one that fails\n identically.\n- **A truncated answer can report `stop`.** A reasoning model can spend the\n whole `max_tokens` allowance on reasoning. When the provider reports\n `completion_tokens` exactly equal to the `max_tokens` the request asked for\n and the finish reason is `stop`, Codewhale treats the turn as cut at the\n ceiling rather than finished (`stop_contradicts_output_ceiling` in\n `codewhale-models`); retrying the same prompt stops at the same count, so\n raise the ceiling or shorten the task instead. No inference is made when the\n provider reports no usage.\n\n## Planned, Not Shipped Yet\n\nThese items belong to the v0.8.48+ provider-abstraction milestone or related\nprovider docs work, but they are not native shipped behavior in this checkout:\n\n- A unified `Provider` trait in `codewhale-agent` that owns env precedence,\n secret resolution, base URL normalization, auth-header construction, and\n provider metadata. Those responsibilities are still split across\n `crates/config`, `crates/secrets`, and `crates/tui/src/client.rs`.\n- Hugging Face model passport metadata in the picker, including license, base\n model, context length, chat template, tool-call support, reasoning support,\n and gated/private status.\n\n## Sign in with Claude\n\nThis is an experimental 0.10.2 candidate flow; live sign-in, inference and\nprovider authorization are not release-qualified.\n\nRun `codewhale auth claude` (or `/auth claude` in a running session), open the\nprinted Claude sign-in URL, and paste the complete `code#state` callback.\n`/provider setup anthropic` also offers API-key and Claude Pro / Max choices.\nThen select `anthropic` or its `claude` alias. API keys continue to use separate\nAnthropic API billing; selecting subscription sign-in never falls back to one.\n\nCodewhale stores the grant in its private credentials directory, commits the\nprovider's credential pointer atomically, and refreshes it before requests on a\nworker. Subscription tokens go only to `https://api.anthropic.com`; a custom\nendpoint is rejected. Each request keeps the existing Messages adapter, tool\nprotocol, cache prefix, and Codewhale inference-client identity. The login\nregistration is a separate fact: this candidate uses Claude Code’s OAuth\nclient ID, not a Codewhale-issued registration. No Claude Code credential\nfile or keychain entry is read or modified.\n\n`codewhale auth claude-revoke` (or `/auth claude-revoke`) removes the owned local\ngrant. End remote access separately in your Claude account settings. The\nsubscription route then needs another login; select API-key billing explicitly\nto use an API key. Login and inference follow Anthropic's provider-side\navailability; local checks do not\nestablish that an account is eligible or that live subscription inference works.\n\nReference refreshed on 2026-10-13: upstream Pi is now `earendil-works/pi`.\nIts [v1.2.0 OAuth adapter](https://github.com/earendil-works/pi/blob/06a466935536f8c9bcacb2d5efc0fc021be1bdff/packages/ai/src/auth/oauth/anthropic.ts)\nsupports loopback and copy-code flows and uses `platform.claude.com` token and\ncopy-code endpoints; this candidate still uses `console.anthropic.com`.\nPi also uses [Claude Code inference identity](https://github.com/earendil-works/pi/blob/06a466935536f8c9bcacb2d5efc0fc021be1bdff/packages/ai/src/api/anthropic-messages.ts),\nwhich Codewhale does not adopt. Pi's implementation is a compatibility\nreference, not proof of account eligibility, billing behavior or permission\nto offer the same flow. See Anthropic's current\n[authentication guidance](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use)\nand [Agent SDK plan guidance](https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan).\n" }, { "path": "docs/MODEL_LAB.md", From cf7199b52ecb9147e4670ae7874924ffc30a6a67 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Tue, 13 Oct 2026 01:21:45 -0700 Subject: [PATCH 12/26] fix(tui): restore Windows wheel capture after external editing F4 disabled VT mouse modes but left the Windows console mouse flag set. Crossterm resume then wrote the already-set flag, so ConPTY did not emit another enable transition. Release the Windows capture state before the existing direct VT resets; recovery can now turn it on again. Keep the PTY resets for mintty. No new product tests or code comments. Thanks @SparkofSpike for distinguishing terminal scrollback from transcript scrolling. Refs #6652. Windows 11 ARM / Windows Terminal 1.24.12741.0, ordinary Hunter session: checksum-verified published v0.10.1 reproduces working transcript wheel, F4 to Notepad, failed wheel after return while PageUp works, then restored wheel after /inline followed by /fullscreen. The editor probe measured inputMode 159 (0x9f), mouse=true, raw=false. The affected external-editor source is byte-identical between v0.10.1 and the pre-fix candidate. This proves the old defect and recovery path, not the patched executable or the reporter's separate hours-long slowdown. Patched Windows build and interactive acceptance remain pending; hosted Windows CI is the gate. macOS focused existing editor suite: Summary [0.587s] 13 tests run: 13 passed, 15298 skipped (filtered). No failures; cargo fmt --all -- --check and git diff --check passed. npm test: 1312 passed, 0 failed, 7 skipped: node suites 101/0, 19/0, 413/0 (7 skipped), 7/0; Vitest 772 passed. npm run check:web passed, including latest-release, facts and build. --- CHANGELOG.md | 5 +++++ crates/tui/CHANGELOG.md | 5 +++++ crates/tui/src/tui/external_editor.rs | 8 +++++--- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54a1ab05b3..f61fef2c25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.10.2] - 2026-10-13 +- Windows mouse-wheel scrolling is restored after returning from the external + editor. The handoff now releases both Windows console mouse capture and + terminal mouse modes. Thanks @SparkofSpike for distinguishing terminal + scrollback from transcript scrolling in #6652; the separate hours-long + scrolling slowdown still needs reproduction. - Shell classification accepts PowerShell’s call operator, quoted ampersands and combined output redirection without losing destructive commands in nested shell payloads or after command separators. Thanks @SparkofSpike diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index f02ffcc5ba..76df25e759 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.10.2] - 2026-10-13 +- Windows mouse-wheel scrolling is restored after returning from the external + editor. The handoff now releases both Windows console mouse capture and + terminal mouse modes. Thanks @SparkofSpike for distinguishing terminal + scrollback from transcript scrolling in #6652; the separate hours-long + scrolling slowdown still needs reproduction. - Shell classification accepts PowerShell’s call operator, quoted ampersands and combined output redirection without losing destructive commands in nested shell payloads or after command separators. Thanks @SparkofSpike diff --git a/crates/tui/src/tui/external_editor.rs b/crates/tui/src/tui/external_editor.rs index 1fe8c31630..197049892c 100644 --- a/crates/tui/src/tui/external_editor.rs +++ b/crates/tui/src/tui/external_editor.rs @@ -326,9 +326,11 @@ fn suspend_tui_child_modes( fn disable_mouse_capture_for_child(writer: &mut W) { // Crossterm's mouse-capture command takes a WinAPI path on Windows and - // does not emit bytes into PTY-style terminals such as mintty. External - // editors inherit the PTY state, so send the xterm reset sequences - // directly here. + // does not emit bytes into PTY-style terminals such as mintty. Release + // the WinAPI state before resetting the inherited PTY state so resume + // can re-enable capture through a real console-mode transition. + #[cfg(windows)] + let _ = execute!(writer, crossterm::event::DisableMouseCapture); const DISABLE_MOUSE_CAPTURE: &[u8] = b"\x1b[?1006l\x1b[?1015l\x1b[?1003l\x1b[?1002l\x1b[?1000l"; if let Err(err) = writer.write_all(DISABLE_MOUSE_CAPTURE) { tracing::debug!(?err, "DisableMouseCapture direct reset ignored"); From bee861137e68438f556695bd780796fe57566188 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Tue, 13 Oct 2026 05:42:50 -0700 Subject: [PATCH 13/26] fix(auth): keep Claude PKCE private and hand off terminal input Use independent OAuth state and the existing constant-time callback check. Both TUI entry points queue one event-loop login, pause the sole input pump, respect queued cancellation, restore terminal modes and resume input before activating the admitted login. Preserve existing client registration and experimental status; this is not provider permission or a valid grant receipt. Actual POSIX CLI/slash/picker malformed-input checks:3 passed, 0 failed. Both TUI cases restore raw input, retain an empty composer, then render new input. Browser opening disabled; no valid credential/token/provider exchange. Windows console handoff and live grant remain unverified for this change. Refs #6932 Validation ran once against the complete frozen batch on cf7199b52e; exact file/binary hashes are retained in FINAL_BATCH_FROZEN_SOURCE_2.json and LOCAL_CLI_BUILD_RECEIPT.json. Unchanged evidence is reused per slice. Actual nextest summary: 170 tests run: 170 passed, 15157 skipped (outside selected filter); 0 failed. npm totals: 1312 passed, 0 failed, 7 skipped; Vitest actual line: Tests 772 passed (772). check:web, fmt and CI-flag all-target/all-feature TUI Clippy PASS. No new product tests/comments. Final-head hosted Windows/full CI and release packaging remain required. --- crates/tui/src/oauth.rs | 5 ++-- crates/tui/src/tui/app.rs | 1 + crates/tui/src/tui/app/init.rs | 1 + crates/tui/src/tui/ui/apply.rs | 2 +- crates/tui/src/tui/ui/event_loop.rs | 44 +++++++++++++++++++++++++---- crates/tui/src/tui/ui/handlers.rs | 8 +----- 6 files changed, 46 insertions(+), 15 deletions(-) diff --git a/crates/tui/src/oauth.rs b/crates/tui/src/oauth.rs index d4588ba8f7..8d673f6367 100644 --- a/crates/tui/src/oauth.rs +++ b/crates/tui/src/oauth.rs @@ -1428,13 +1428,14 @@ async fn claude_login() -> Result { tokio::task::spawn_blocking(move || { let params = &CLAUDE_OAUTH_PARAMS; let pkce = generate_pkce(); + let expected_state = generate_state(); let redirect = "https://console.anthropic.com/oauth/code/callback"; let mut url = reqwest::Url::parse("https://claude.ai/oauth/authorize")?; url.query_pairs_mut().extend_pairs([ ("code", "true"), ("client_id", params.default_client_id), ("response_type", "code"), ("redirect_uri", redirect), ("scope", params.default_scopes), ("code_challenge", pkce.challenge.as_str()), - ("code_challenge_method", "S256"), ("state", pkce.verifier.as_str()), + ("code_challenge_method", "S256"), ("state", expected_state.as_str()), ]); writeln!(output, "Open this URL to sign in with Claude:\n{url}\nPaste the authorization code (code#state):")?; output.flush()?; @@ -1446,7 +1447,7 @@ async fn claude_login() -> Result { anyhow::ensure!(input.len() <= 8192, "Claude authorization code is too long"); let (code, state) = input.trim().split_once('#').context("Expected Claude authorization code#state")?; anyhow::ensure!(!code.is_empty() && !code.chars().any(char::is_control) - && codewhale_core::secret_eq::constant_time_eq(state.as_bytes(), pkce.verifier.as_bytes()), + && codewhale_core::secret_eq::constant_time_eq(state.as_bytes(), expected_state.as_bytes()), "Claude authorization state did not match the pending login"); let (status, body) = ReqwestOAuthFormClient.post_form( &form_token_url(params, params.default_issuer), &[ diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 72670e5047..ef1e6aa85e 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1931,6 +1931,7 @@ pub struct App { pub launch: LaunchState, /// Mouse-selected launch action, consumed by the async UI loop. pub pending_launch_action: Option, + pub(crate) pending_claude_login: Option, /// Mouse click on the live composer's `[↵]` send target. The async UI loop /// consumes it through the same submit dispatcher as Enter. pub pending_composer_submit: Option, diff --git a/crates/tui/src/tui/app/init.rs b/crates/tui/src/tui/app/init.rs index 5a82930c8f..65c6aacdf9 100644 --- a/crates/tui/src/tui/app/init.rs +++ b/crates/tui/src/tui/app/init.rs @@ -925,6 +925,7 @@ impl App { focus_texture, launch, pending_launch_action: None, + pending_claude_login: None, pending_composer_submit: None, pending_hotbar_slot: None, synchronized_output_enabled, diff --git a/crates/tui/src/tui/ui/apply.rs b/crates/tui/src/tui/ui/apply.rs index a97f395b1d..cc5d94a239 100644 --- a/crates/tui/src/tui/ui/apply.rs +++ b/crates/tui/src/tui/ui/apply.rs @@ -2477,7 +2477,7 @@ async fn apply_command_result_inner( run_xai_device_login_from_tui(terminal, app, engine_handle, config).await?; } AppAction::StartClaudeLogin => { - let _ = run_claude_login_from_tui(terminal, app, engine_handle, config).await?; + app.pending_claude_login = Some(false); } AppAction::StartClaudeRevoke => { let path = app.config_path.clone(); diff --git a/crates/tui/src/tui/ui/event_loop.rs b/crates/tui/src/tui/ui/event_loop.rs index c9fd1fbdb6..5cfaee6df1 100644 --- a/crates/tui/src/tui/ui/event_loop.rs +++ b/crates/tui/src/tui/ui/event_loop.rs @@ -2327,6 +2327,27 @@ pub(crate) async fn run_event_loop( // #1830/#2317: service any already-arrived terminal keys before a // potentially long engine batch so composer/modal input stays live. collect_pending_terminal_events(&terminal_input, &mut pending_terminal_events)?; + if !event_broker.is_paused() + && let Some(from_picker) = app.pending_claude_login.take() + { + let switched = run_claude_login_from_tui( + terminal, + app, + &mut engine_handle, + config, + &terminal_input, + &mut pending_terminal_events, + ) + .await?; + if from_picker { + complete_provider_picker_onboarding_if_switched( + app, + ProviderKind::Anthropic, + switched, + ); + } + force_terminal_repaint = true; + } app.maybe_poll_plugin_catalog_idle(); if drain_remote_control_events(app, config, &engine_handle).await? { @@ -7517,21 +7538,34 @@ pub(crate) async fn run_claude_login_from_tui( app: &mut App, engine_handle: &mut EngineHandle, config: &mut Config, + terminal_input: &TerminalInputPump, + pending_terminal_events: &mut VecDeque, ) -> Result { - pause_terminal( + terminal_input.pause_for_child_terminal().await?; + let handoff = prepare_terminal_input_handoff(terminal_input, pending_terminal_events); + if !matches!(handoff, Ok(true)) { + terminal_input.resume_after_child_terminal(); + return handoff.map(|_| false).map_err(Into::into); + } + let result = match pause_terminal( terminal, app.use_alt_screen(), app.use_mouse_capture, app.use_bracketed_paste, - )?; - let result = crate::oauth::login(crate::oauth::OAuthProvider::Claude).await; - resume_terminal( + ) { + Ok(()) => crate::oauth::login(crate::oauth::OAuthProvider::Claude).await, + Err(error) => Err(error), + }; + let restored = resume_terminal( terminal, app.use_alt_screen(), app.use_mouse_capture, app.use_bracketed_paste, app.synchronized_output_enabled, - )?; + ); + terminal_input.resume_after_child_terminal(); + app.needs_redraw = true; + restored?; let switched = match result { Ok(pending) => { apply_codewhale_owned_login( diff --git a/crates/tui/src/tui/ui/handlers.rs b/crates/tui/src/tui/ui/handlers.rs index 35c0f79ebc..b663283aae 100644 --- a/crates/tui/src/tui/ui/handlers.rs +++ b/crates/tui/src/tui/ui/handlers.rs @@ -3037,13 +3037,7 @@ pub(crate) async fn handle_view_events( refresh_config_view_if_open(app, "provider"); } ViewEvent::ProviderPickerClaudeOAuthRequested => { - let switched = - run_claude_login_from_tui(terminal, app, engine_handle, config).await?; - complete_provider_picker_onboarding_if_switched( - app, - ProviderKind::Anthropic, - switched, - ); + app.pending_claude_login = Some(true); } ViewEvent::ProviderPickerXaiOAuthRequested => { let switched = From 2b3decc006dca8b2c7e97a85abf4c16f0aea7364 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Tue, 13 Oct 2026 05:42:53 -0700 Subject: [PATCH 14/26] fix(client): distinguish bounded rate limits from exhausted quota An accepted body retry delay at or below120 seconds permits retrying the ambiguous quota/plan/billing429. Separate raw body evidence survives error sanitization for both HTTP callers. Explicit account/billing/credit and subscription quota codes remain terminal; a header alone cannot reclassify. Preserve header precedence for the actual delay. Remove the unused migrated helper and adapt seven existing test call signatures without changing asserts. Actual normal CLI synthetic loopback:7 cases passed, 0 failed. The temporary limit used2 byte-identical requests258 ms apart and succeeded; six durable quota cases each used1request and failed with empty output/success=false. The fixture scorer was corrected to inspect JSON output rather than the success marker echoed in prompt; original output/receipts retained, no execution repeated. Cache-isolated existing tests passed in170-test batch; that ambiguous retry case and live Gemini/Windows were not manually run. Thanks @Statter for the concrete quota-worded Gemini report. Refs #6923 Validation ran once against the complete frozen batch on cf7199b52e; exact file/binary hashes are retained in FINAL_BATCH_FROZEN_SOURCE_2.json and LOCAL_CLI_BUILD_RECEIPT.json. Unchanged evidence is reused per slice. Actual nextest summary: 170 tests run: 170 passed, 15157 skipped (outside selected filter); 0 failed. npm totals: 1312 passed, 0 failed, 7 skipped; Vitest actual line: Tests 772 passed (772). check:web, fmt and CI-flag all-target/all-feature TUI Clippy PASS. No new product tests/comments. Final-head hosted Windows/full CI and release packaging remain required. --- crates/tui/src/client.rs | 28 +++++++++---- crates/tui/src/client/test_cases_02.rs | 13 +++--- crates/tui/src/llm_client/mod.rs | 56 +++++++++++--------------- 3 files changed, 52 insertions(+), 45 deletions(-) diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 55f7aab60a..74632ae462 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -2043,6 +2043,7 @@ impl CodewhaleClient { status: u16, body: &str, retry_after: Option, + body_retry_after: Option, ) -> LlmError { let route = self.admitted_identity.key.as_str(); let error = match status { @@ -2060,7 +2061,12 @@ impl CodewhaleClient { .with_fix(auth_fix_hint(route, &self.api_key_source)), ), ), - _ => LlmError::from_http_response_with_retry_after(status, body, retry_after), + _ => LlmError::from_http_response_with_retry_hints( + status, + body, + retry_after, + body_retry_after, + ), }; match error { LlmError::ModelError(message) => LlmError::ModelError(format!( @@ -4149,10 +4155,14 @@ impl CodewhaleClient { } let header_retry_after = extract_retry_after(response.headers()); let raw = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await; - let retry_after = - header_retry_after.or_else(|| retry_delay_from_error_body(&raw)); + let body_retry_after = retry_delay_from_error_body(&raw); let body = self.disclosed_http_error_body(disclosure, status.as_u16(), &raw); - Err(self.http_error_with_route_context(status.as_u16(), &body, retry_after)) + Err(self.http_error_with_route_context( + status.as_u16(), + &body, + header_retry_after, + body_retry_after, + )) } }, Some(Box::new(move |err, attempt, delay| { @@ -4268,11 +4278,15 @@ impl CodewhaleClient { } let header_retry_after = extract_retry_after(response.headers()); let raw = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await; - let retry_after = - header_retry_after.or_else(|| retry_delay_from_error_body(&raw)); + let body_retry_after = retry_delay_from_error_body(&raw); let body = self.disclosed_http_error_body(disclosure, status.as_u16(), &raw); - Err(self.http_error_with_route_context(status.as_u16(), &body, retry_after)) + Err(self.http_error_with_route_context( + status.as_u16(), + &body, + header_retry_after, + body_retry_after, + )) } }, Some(Box::new(|err, attempt, delay| { diff --git a/crates/tui/src/client/test_cases_02.rs b/crates/tui/src/client/test_cases_02.rs index 6ca20f7735..c0c9f0eea8 100644 --- a/crates/tui/src/client/test_cases_02.rs +++ b/crates/tui/src/client/test_cases_02.rs @@ -865,7 +865,7 @@ }; let client = CodewhaleClient::new(&config).expect("openrouter client"); let auth = client - .http_error_with_route_context(401, "Invalid API key", None) + .http_error_with_route_context(401, "Invalid API key", None, None) .to_string(); assert!(auth.contains("provider: openrouter"), "{auth}"); assert!(auth.contains("openrouter.ai"), "{auth}"); @@ -877,7 +877,7 @@ assert!(!auth.contains("or-rejected-key-1234567890"), "{auth}"); let model = client - .http_error_with_route_context(404, "model not found: nope", None) + .http_error_with_route_context(404, "model not found: nope", None, None) .to_string(); assert!(model.contains("provider route: openrouter"), "{model}"); assert!(model.contains("host: openrouter.ai"), "{model}"); @@ -903,7 +903,7 @@ assert_eq!(client.subscription_limit_guidance, None); let body = r#"{"error":{"type":"usage_limit_reached","message":"The usage limit has been reached"}}"#; - let plain = client.http_error_with_route_context(429, body, None); + let plain = client.http_error_with_route_context(429, body, None, None); assert!(matches!(plain, LlmError::QuotaExhausted(_)), "{plain:?}"); assert!(!plain.to_string().contains("codewhale auth"), "{plain}"); @@ -912,7 +912,7 @@ Some("a@example.com (plus)"), )); let guided = client - .http_error_with_route_context(429, body, None) + .http_error_with_route_context(429, body, None, None) .to_string(); assert!( guided.contains("The usage limit has been reached"), @@ -928,7 +928,7 @@ ); assert!(!guided.contains("or-quota-key-1234567890"), "{guided}"); // Ordinary rate limits stay retryable and unannotated. - let rate = client.http_error_with_route_context(429, "Too Many Requests", None); + let rate = client.http_error_with_route_context(429, "Too Many Requests", None, None); assert!(rate.is_retryable()); assert!(!rate.to_string().contains("codewhale auth"), "{rate}"); } @@ -962,7 +962,7 @@ let body = r#"{"error":{"code":"credit_balance_exhausted","message":"credit balance exhausted"}}"#; let error = client - .http_error_with_route_context(402, body, None) + .http_error_with_route_context(402, body, None, None) .to_string(); assert!(!error.contains("codewhale auth"), "{error}"); } @@ -1046,6 +1046,7 @@ 429, r#"{"error":{"code":"subscription_sharing_usage_limit_exceeded"}}"#, None, + None, ); assert!(matches!(error, LlmError::QuotaExhausted(_))); assert!(!error.is_retryable()); diff --git a/crates/tui/src/llm_client/mod.rs b/crates/tui/src/llm_client/mod.rs index 31272b5909..d47fc44b94 100644 --- a/crates/tui/src/llm_client/mod.rs +++ b/crates/tui/src/llm_client/mod.rs @@ -468,15 +468,33 @@ impl LlmError { /// - Status code (429 = rate limit, 401/403 = auth, 499/5xx = transient upstream error) /// - Response body keywords (`context_length`, `content_policy`, safety, etc.) pub fn from_http_response(status: u16, body: &str) -> Self { - if let Some(error) = explicit_quota_code(body) - .or_else(|| explicit_quota_code_marker(body)) + Self::from_http_response_with_retry_hints( + status, + body, + None, + retry_delay_from_error_body(body), + ) + } + + pub(crate) fn from_http_response_with_retry_hints( + status: u16, + body: &str, + retry_after: Option, + body_retry_after: Option, + ) -> Self { + let body_retry_after = body_retry_after.filter(|delay| *delay <= PROVIDER_RETRY_HINT_MAX); + let quota_code = explicit_quota_code(body).or_else(|| explicit_quota_code_marker(body)); + if let Some(error) = quota_code .as_deref() .and_then(Self::from_subscription_sharing_error_code) { return error; } // xAI refuses an exhausted account with a 403, not a 402/429. - if matches!(status, 400 | 402 | 403 | 429) && has_explicit_quota_evidence(body) { + if matches!(status, 400 | 402 | 403 | 429) + && (quota_code.is_some() + || has_explicit_quota_phrase(body, status == 429 && body_retry_after.is_some())) + { return LlmError::QuotaExhausted(QuotaExhaustionError::from_http_message( body.to_string(), )); @@ -485,7 +503,7 @@ impl LlmError { match status { 429 => LlmError::RateLimited { message: body.to_string(), - retry_after: None, + retry_after: retry_after.or(body_retry_after), }, 401 => Self::authentication_error(body), 403 => { @@ -631,23 +649,6 @@ impl LlmError { } } - /// Constructs an `LlmError` from HTTP status code, body, and optional Retry-After header. - pub fn from_http_response_with_retry_after( - status: u16, - body: &str, - retry_after: Option, - ) -> Self { - let mut error = Self::from_http_response(status, body); - if let LlmError::RateLimited { - retry_after: ref mut ra, - .. - } = error - { - *ra = retry_after; - } - error - } - /// Constructs an `LlmError` from a reqwest error. pub fn from_reqwest(err: &reqwest::Error) -> Self { if err.is_timeout() { @@ -786,15 +787,6 @@ pub(crate) fn is_context_length_message(lower: &str) -> bool { .any(|phrase| lower.contains(phrase)) } -/// Quota exhaustion is a durable account state, not a generic rate-limit -/// synonym. Accept only explicit provider evidence at the HTTP/parser boundary; -/// callers holding a stringified error must never promote it to this type. -fn has_explicit_quota_evidence(body: &str) -> bool { - explicit_quota_code(body).is_some() - || explicit_quota_code_marker(body).is_some() - || has_explicit_quota_phrase(body) -} - fn explicit_quota_code(body: &str) -> Option { let value: Value = serde_json::from_str(body).ok()?; [ @@ -848,7 +840,7 @@ fn explicit_quota_code_marker(body: &str) -> Option { is_explicit_quota_code(code).then(|| code.to_string()) } -fn has_explicit_quota_phrase(body: &str) -> bool { +fn has_explicit_quota_phrase(body: &str, bounded_rate_limit_hint: bool) -> bool { let lower = body.to_ascii_lowercase(); let current_quota_exhausted = lower.contains("exceeded your current quota") || lower.contains("current quota has been exceeded"); @@ -889,7 +881,7 @@ fn has_explicit_quota_phrase(body: &str) -> bool { || lower.contains("credit balance exhausted") || lower.contains("credit balance is exhausted") || durable_scope_exhausted - || (current_quota_exhausted && plan_and_billing_guidance) + || (current_quota_exhausted && plan_and_billing_guidance && !bounded_rate_limit_hint) } fn extract_json_error_message(body: &str) -> Option { From 4dfe852c6e6aed2cfff41f9b7f6f4f7dca25217f Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Tue, 13 Oct 2026 05:42:55 -0700 Subject: [PATCH 15/26] fix(session): preserve compaction errors and accurate oversized refusal Propagate compaction I/O error kind/detail instead of swallowing it. If a source remains oversized, describe the measured compaction outcome without claiming its active conversation alone must exceed the limit. Actual bounded Runtime history fixture passed:9 MiB source, blocked archive 409 with accurate wording and all 4 journal entries retained, then restored archive yielded1512 byte source plus 3 archived entries with exact lossless union and1 message resume.0 model POSTs. Initial long-path fixture failed before runtime startup at the macOS socket-length guard; corrected isolated SSD path exercised the actual bounded route. No private histories or Windows packaged recovery run. Thanks @7jrxt42BxFZo4iAnN4CX for the archive report. Refs #6955 Validation ran once against the complete frozen batch on cf7199b52e; exact file/binary hashes are retained in FINAL_BATCH_FROZEN_SOURCE_2.json and LOCAL_CLI_BUILD_RECEIPT.json. Unchanged evidence is reused per slice. Actual nextest summary: 170 tests run: 170 passed, 15157 skipped (outside selected filter); 0 failed. npm totals: 1312 passed, 0 failed, 7 skipped; Vitest actual line: Tests 772 passed (772). check:web, fmt and CI-flag all-target/all-feature TUI Clippy PASS. No new product tests/comments. Final-head hosted Windows/full CI and release packaging remain required. --- crates/tui/src/session_manager.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/tui/src/session_manager.rs b/crates/tui/src/session_manager.rs index a613eae541..63772d613d 100644 --- a/crates/tui/src/session_manager.rs +++ b/crates/tui/src/session_manager.rs @@ -3426,14 +3426,21 @@ impl SessionManager { let mut file = fs::File::open(&path)?; if file.metadata()?.len() > limit as u64 { let before = file.metadata()?.len(); - let compacted = self.compact_oversized_session(id).is_ok(); + self.compact_oversized_session(id).map_err(|error| { + io::Error::new( + error.kind(), + format!( + "session exceeds its full-history transport limit and compaction failed: {error}; source retained" + ), + ) + })?; file = fs::File::open(&path)?; let after = file.metadata()?.len(); - if !compacted || after > limit as u64 { + if after > limit as u64 { return Err(io::Error::new( io::ErrorKind::InvalidData, format!( - "session is {:.1} MiB and the full-history transport limit is {:.1} MiB; archiving its off-branch journal entries left it at {:.1} MiB, so its active conversation alone is over the limit; source retained", + "session is {:.1} MiB and the full-history transport limit is {:.1} MiB; off-branch compaction left it at {:.1} MiB; source retained", before as f64 / 1_048_576.0, limit as f64 / 1_048_576.0, after as f64 / 1_048_576.0, From 174a32b770e55441980078c0443986004552b3f5 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Tue, 13 Oct 2026 05:42:57 -0700 Subject: [PATCH 16/26] ci(release): reject unqualified Windows executable imports Bind static import qualification to each actual PE machine and preserve raw dump/hash/error receipts on failure. Check CLI aliases, optional delivered host, NSIS setup/payload executables and actual embedded System/nsDialogs DLLs before artifact upload. Unknown pairs/ordinals, malformed or empty tables, nonzero delay tables and mismatched profiles fail closed. x64 historical positive:411 qualified imports; v0.10.1 negative:454 examined, rejected DeriveCapabilitySidsFromName as expected. ARM debug453/453resolved on actual Win11 build 26300; no older ARM minimum inferred. Historical NSIS setup/System/nsDialogs163/19/54static imports pass; x86 ordinal17 is bound to SDK/export InitCommonControls identity. These are static contract receipts, not a complete Windows API allowlist or final1607 startup/install proof. Generated uninstaller, installed VC closure and exact final package/native owner acceptance remain required. Native build budget210 minutes. Existing workflow contract:155 paths,11 event cases x 12 steps,13 hermetic checks,7 targets/34 assets PASS. actionlint with actual CI flags PASS. Python parsed; independent complete frozen import/source review PASS. Hosted release guard has not run yet. Thanks @LmeSzinc for the 1607 import surface diagnosis that motivated this release gate. Refs #6962 Refs #6953 Validation ran once against the complete frozen batch on cf7199b52e; exact file/binary hashes are retained in FINAL_BATCH_FROZEN_SOURCE_2.json and LOCAL_CLI_BUILD_RECEIPT.json. Unchanged evidence is reused per slice. Actual nextest summary: 170 tests run: 170 passed, 15157 skipped (outside selected filter); 0 failed. npm totals: 1312 passed, 0 failed, 7 skipped; Vitest actual line: Tests 772 passed (772). check:web, fmt and CI-flag all-target/all-feature TUI Clippy PASS. No new product tests/comments. Final-head hosted Windows/full CI and release packaging remain required. --- .github/workflows/release-artifacts.yml | 50 +- scripts/release/check-windows-imports.py | 145 ++ scripts/release/windows-import-floor.json | 2518 +++++++++++++++++++++ 3 files changed, 2712 insertions(+), 1 deletion(-) create mode 100644 scripts/release/check-windows-imports.py create mode 100644 scripts/release/windows-import-floor.json diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index ac3222b281..b843274b07 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -86,7 +86,7 @@ jobs: name: Build ${{ matrix.platform }} # Both cold macOS artifact builds on 0.10.1 exhausted 90 minutes inside # compilation. Keep the full CI build budget; packaging stays capped below. - timeout-minutes: 165 + timeout-minutes: 210 # FreeBSD is a source-build target validated via `cargo check --target x86_64-unknown-freebsd -p codewhale-cli --locked` # (see packaging/freebsd/README.md and docs/INSTALL.md#freebsd). The 7×1 prebuilt matrix stays 7 targets; # FreeBSD has no prebuilt asset, no npm binary, and no matrix bloat — it builds from source. @@ -326,6 +326,27 @@ jobs: extension="" if [[ "${TARGET_PLATFORM}" == windows-* ]]; then extension=.exe; fi node scripts/release/stage-compiled-host.mjs --bun "${bun_binary}" --compiled-image "${proof_dir}/codewhale-extension-host${extension}" --native-receipt "${proof_dir}/native-receipt.json" --runtime-closure "${RUNTIME_CLOSURE_DIR}/${TARGET_PLATFORM}.json" --target "${TARGET_PLATFORM}" --version "${RELEASE_VERSION}" --source-sha "${GITHUB_SHA}" --output-dir "compiled-host-${TARGET_PLATFORM}" + - name: Check every staged Windows executable import + if: startsWith(matrix.platform, 'windows-') + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $binaries = @('${{ matrix.cli_artifact }}', '${{ matrix.shim_artifact }}', '${{ matrix.compat_tui_artifact }}') + $hostDirectory = 'compiled-host-${{ matrix.platform }}' + if (Test-Path $hostDirectory) { + $binaries += @(Get-ChildItem -Path $hostDirectory -Filter *.exe -Recurse -File | ForEach-Object { $_.FullName }) + } + python scripts/release/check-windows-imports.py --expected-machine '${{ matrix.platform }}' --output '${{ runner.temp }}/windows-imports-${{ matrix.platform }}' @binaries + if ($LASTEXITCODE -ne 0) { throw 'Windows static import qualification failed' } + - name: Retain Windows import receipts + if: always() && startsWith(matrix.platform, 'windows-') + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: windows-imports-${{ matrix.platform }}-${{ github.sha }} + path: ${{ runner.temp }}/windows-imports-${{ matrix.platform }}/ + if-no-files-found: warn + retention-days: ${{ inputs.retention_days }} + overwrite: true - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 if: inputs.compiled_host_delivery && contains(format(',{0},', inputs.compiled_host_targets), format(',{0},', matrix.platform)) with: @@ -453,6 +474,33 @@ jobs: if (!(Test-Path "scripts\installer\CodeWhaleSetup.exe")) { throw "CodeWhaleSetup.exe was not produced" } + - name: Check Windows installer and embedded executables + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $evidenceDirectory = '${{ runner.temp }}/windows-installer-imports' + $embeddedDirectory = Join-Path $evidenceDirectory 'embedded' + New-Item -Path $evidenceDirectory -ItemType Directory -Force | Out-Null + & 7z l 'scripts/installer/CodeWhaleSetup.exe' | Out-File (Join-Path $evidenceDirectory 'archive-listing.txt') -Encoding utf8 + if ($LASTEXITCODE -ne 0) { throw 'NSIS embedded payload inventory failed' } + & 7z x 'scripts/installer/CodeWhaleSetup.exe' "-o$embeddedDirectory" '$PLUGINSDIR/*.dll' | Out-File (Join-Path $evidenceDirectory 'archive-extraction.txt') -Encoding utf8 + if ($LASTEXITCODE -ne 0) { throw 'NSIS embedded plugin extraction failed' } + $plugins = @(Get-ChildItem -Path $embeddedDirectory -Filter *.dll -Recurse -File) + if ($plugins.Name -notcontains 'System.dll' -or $plugins.Name -notcontains 'nsDialogs.dll') { throw 'Expected MUI2 System and nsDialogs plugin payloads' } + $binaries = @(Get-ChildItem -Path scripts/installer -Filter *.exe -File | ForEach-Object { $_.FullName }) + if ($binaries.Count -lt 3) { throw 'Expected installer, CLI and shim executables' } + $binaries += @($plugins | ForEach-Object { $_.FullName }) + python scripts/release/check-windows-imports.py --output $evidenceDirectory @binaries + if ($LASTEXITCODE -ne 0) { throw 'Windows installer static import qualification failed' } + - name: Retain Windows installer import receipts + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: windows-installer-imports-${{ github.sha }} + path: ${{ runner.temp }}/windows-installer-imports/ + if-no-files-found: warn + retention-days: ${{ inputs.retention_days }} + overwrite: true - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: CodeWhaleSetup.exe diff --git a/scripts/release/check-windows-imports.py b/scripts/release/check-windows-imports.py new file mode 100644 index 0000000000..aa729a046e --- /dev/null +++ b/scripts/release/check-windows-imports.py @@ -0,0 +1,145 @@ +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import struct +import subprocess +import sys + + +MACHINES = {0x014C: "windows-x86", 0x8664: "windows-x64", 0xAA64: "windows-arm64"} + + +def pe_identity(data): + if len(data) < 64 or data[:2] != b"MZ": + raise ValueError("expected a Windows PE executable") + offset = struct.unpack_from(" len(data) or data[offset:offset + 4] != b"PE\0\0": + raise ValueError("invalid PE signature or COFF header") + machine, _, _, _, _, optional_size, characteristics = struct.unpack_from(" len(data): + raise ValueError("truncated PE optional header") + magic = struct.unpack_from(" optional_size: + raise ValueError("truncated PE data-directory count") + count = struct.unpack_from(" optional_size: + raise ValueError("truncated PE data-directory table") + delay_rva, delay_size = (0, 0) + if count > 13: + delay_rva, delay_size = struct.unpack_from(" Date: Tue, 13 Oct 2026 05:42:58 -0700 Subject: [PATCH 17/26] ci: allow cold builds to finish the complete suite The cf7199b52e macOS job spent29m10s building CLI and125m18s building tests, then hit165 minutes with15,204 completed PASS and no FAIL/TIMEOUT results; there was no complete suite summary. Set finite native210 minute budgets consistently and omit both dev/test debug metadata on the macOS test lane so build profiles share reusable inputs. Assertions, complete tests, test deadlines and retry settings are unchanged. No empty rerun commit. Existing workflow/npm contract gate passes; actual actionlint CI flags pass. No claim that the timed-out candidate job passed or the new head CI ran. Validation ran once against the complete frozen batch on cf7199b52e; exact file/binary hashes are retained in FINAL_BATCH_FROZEN_SOURCE_2.json and LOCAL_CLI_BUILD_RECEIPT.json. Unchanged evidence is reused per slice. Actual nextest summary: 170 tests run: 170 passed, 15157 skipped (outside selected filter); 0 failed. npm totals: 1312 passed, 0 failed, 7 skipped; Vitest actual line: Tests 772 passed (772). check:web, fmt and CI-flag all-target/all-feature TUI Clippy PASS. No new product tests/comments. Final-head hosted Windows/full CI and release packaging remain required. --- .github/workflows/ci.yml | 14 +++++++------- .github/workflows/nightly.yml | 2 +- .github/workflows/release-parity.yml | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30dcb66e74..0655abd164 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -795,9 +795,9 @@ jobs: # (fork PRs #6431, #6417), too close to the old 90-minute limit. # On 2026-10-04 a PR leg spent 111 minutes compiling with no cache and # was cancelled at 120 with 12,356 tests passed and none failed. The - # limit stays a literal (release-workflows.test.js, #5496); 165 bounds - # hangs for every leg until the CI speed redesign lands. - timeout-minutes: 165 + # limit stays a literal (release-workflows.test.js, #5496); 210 bounds + # cold compilation and the complete suite without disabling test deadlines. + timeout-minutes: 210 # macOS legs go to the self-hosted Mac ONLY when all three hold: the # change is heavy, the event is trusted (not a fork PR), and the # CW_SELF_HOSTED_MAC repo variable is 'true'. That variable is the kill @@ -867,12 +867,12 @@ jobs: run: ./scripts/installer/installer-path-regression.tests.ps1 -AllowUserPathMutation - uses: dtolnay/rust-toolchain@stable if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') - - name: Omit macOS test debug metadata + - name: Omit macOS build debug metadata if: needs.changes.outputs.heavy == 'true' && matrix.os == 'macos-latest' shell: bash # Match the existing full-workspace CNB gate. Keep debug assertions and - # every test; only disposable test debug tables change on this lane. - run: echo "CARGO_PROFILE_TEST_DEBUG=0" >> "$GITHUB_ENV" + # every test; only disposable build debug tables change on this lane. + run: printf '%s\n' 'CARGO_PROFILE_DEV_DEBUG=0' 'CARGO_PROFILE_TEST_DEBUG=0' >> "$GITHUB_ENV" - uses: mozilla-actions/sccache-action@v0.0.11 id: sccache continue-on-error: true @@ -1256,7 +1256,7 @@ jobs: if: needs.changes.outputs.heavy == 'true' && github.event_name != 'pull_request' && ((github.event_name == 'workflow_dispatch' && inputs.workspace_test_mode == 'shared-process-twice') || !(needs.changes.outputs.trusted == 'true' && vars.CW_SELF_HOSTED_MAC == 'true')) # Cold hosted builds consumed 73 minutes before eval could start (F run # 37497498829). Match Test's compile allowance; RSS/eval limits stay intact. - timeout-minutes: 165 + timeout-minutes: 210 runs-on: macos-latest steps: - uses: actions/checkout@v7 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 3430d62cff..4330ca0e6b 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -27,7 +27,7 @@ jobs: build: name: Build ${{ matrix.platform }} # Native builds use the same finite cold-build allowance as release/CI. - timeout-minutes: 165 + timeout-minutes: 210 strategy: fail-fast: false matrix: diff --git a/.github/workflows/release-parity.yml b/.github/workflows/release-parity.yml index b42641983e..6b89d426fc 100644 --- a/.github/workflows/release-parity.yml +++ b/.github/workflows/release-parity.yml @@ -23,7 +23,7 @@ jobs: name: Workspace parity # Cold check, Clippy, executable and test builds exceeded the old 45m # limit before the suite started. Retain the full CI test-job budget. - timeout-minutes: 165 + timeout-minutes: 210 runs-on: ubuntu-latest steps: # Every caller's resolve job already proved GITHUB_SHA equals the From 9bfb3809d7d1c2ddf4eb5cfbacf4f10277ad1de0 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Tue, 13 Oct 2026 05:43:00 -0700 Subject: [PATCH 18/26] docs(release): align Windows contracts and remaining candidate limits Separate x64 compatibility target1607, ConPTY API1809 and current Terminal 2004 requirements. State actual ARM26300 evidence without inventing a minimum ARM build. Preserve final1607 install/startup, generated uninstaller/VC closure, package/native-owner and live Claude grant/registration gates explicitly. Document actual first-turn file_search/load_skill schema in English/Chinese, correct optional Gemini/compaction notes and refresh generated projections. Dated version/changelog sync, workflow contract, source budgets and web facts pass. No claim of final package or oldest-OS acceptance. Specific reporter credits preserved; source fixes still target unreleased0.10.2. Refs #6962 Refs #6932 Refs #6923 Refs #6955 Validation ran once against the complete frozen batch on cf7199b52e; exact file/binary hashes are retained in FINAL_BATCH_FROZEN_SOURCE_2.json and LOCAL_CLI_BUILD_RECEIPT.json. Unchanged evidence is reused per slice. Actual nextest summary: 170 tests run: 170 passed, 15157 skipped (outside selected filter); 0 failed. npm totals: 1312 passed, 0 failed, 7 skipped; Vitest actual line: Tests 772 passed (772). check:web, fmt and CI-flag all-target/all-feature TUI Clippy PASS. No new product tests/comments. Final-head hosted Windows/full CI and release packaging remain required. --- CHANGELOG.md | 21 ++++++++--- crates/tui/CHANGELOG.md | 21 ++++++++--- docs/CLASSROOM_INSTALL.md | 9 ++++- docs/ENVIRONMENTS.md | 60 ++++++++++++++++++++++++++++++ docs/INSTALL.md | 3 ++ docs/MODES.md | 2 +- docs/RELEASE_CHECKLIST.md | 14 ++++++- docs/RELEASE_RUNBOOK.md | 10 +++++ docs/TOOL_SURFACE.md | 2 + docs/zh_hans/ENVIRONMENTS.md | 45 ++++++++++++++++++++++ docs/zh_hans/INSTALL.md | 3 ++ docs/zh_hans/MODES.md | 2 +- docs/zh_hans/TOOL_SURFACE.md | 2 + web/data/agent-docs.generated.json | 24 ++++++------ 14 files changed, 188 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f61fef2c25..039e5a06f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -395,8 +395,16 @@ launcher guidance. a recorded session message outside the system-prompt layer. - The pet shows sub-agents while they run, and a finished one no longer stays "working". Half-block pet sprites are drawn crisply instead of blurred. -- A rate-limit 429 that gives its retry delay in the body instead of a - `Retry-After` header is retried after that delay, up to 120 s (Refs #6923). +- A rate-limit 429 with a body retry delay of up to 120 s retries the interrupted + model request. This includes ambiguous current-quota wording with billing + guidance when the body supplies a bounded delay; explicit exhausted account, + billing and subscription limits remain terminal (Refs #6923, thanks @Statter). +- Claude sign-in keeps its PKCE verifier out of the sign-in URL and gives its + pasted authorization code one terminal input reader. Failed input restores + the TUI; live subscription sign-in remains experimental (Refs #6932). +- Oversized-session refusals preserve compaction errors and no longer assume + that the active conversation alone caused the remaining size limit + (Refs #6955, thanks @7jrxt42BxFZo4iAnN4CX). - A Codewhale home reached through a symlink or Windows junction keeps saving artifacts and compacting (#6947). - A workflow gate that blocks its own role is rejected when the plan is @@ -527,10 +535,11 @@ launcher guidance. ### Known limits -- A 429 whose body says the current quota is exceeded and points to plan and - billing details is treated as exhausted quota and ends the turn, even when it - also names a short retry delay. Gemini's free-tier rate-limit responses use - that wording, so they are not yet retried +- Ambiguous current-quota 429s without a body retry delay of at most 120 s + still end the turn; a header alone does not change that classification. + Explicit exhausted account, billing and subscription limits remain terminal + regardless of a retry hint. Gemini limits without an accepted body delay are + not automatically retried ([#6923](https://github.com/codewhale-hq/Codewhale/issues/6923)). - Idle CPU has not been measured on FreeBSD or Linux for this release ([#6728](https://github.com/codewhale-hq/Codewhale/issues/6728)). diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 76df25e759..3040ba49e2 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -395,8 +395,16 @@ launcher guidance. a recorded session message outside the system-prompt layer. - The pet shows sub-agents while they run, and a finished one no longer stays "working". Half-block pet sprites are drawn crisply instead of blurred. -- A rate-limit 429 that gives its retry delay in the body instead of a - `Retry-After` header is retried after that delay, up to 120 s (Refs #6923). +- A rate-limit 429 with a body retry delay of up to 120 s retries the interrupted + model request. This includes ambiguous current-quota wording with billing + guidance when the body supplies a bounded delay; explicit exhausted account, + billing and subscription limits remain terminal (Refs #6923, thanks @Statter). +- Claude sign-in keeps its PKCE verifier out of the sign-in URL and gives its + pasted authorization code one terminal input reader. Failed input restores + the TUI; live subscription sign-in remains experimental (Refs #6932). +- Oversized-session refusals preserve compaction errors and no longer assume + that the active conversation alone caused the remaining size limit + (Refs #6955, thanks @7jrxt42BxFZo4iAnN4CX). - A Codewhale home reached through a symlink or Windows junction keeps saving artifacts and compacting (#6947). - A workflow gate that blocks its own role is rejected when the plan is @@ -527,10 +535,11 @@ launcher guidance. ### Known limits -- A 429 whose body says the current quota is exceeded and points to plan and - billing details is treated as exhausted quota and ends the turn, even when it - also names a short retry delay. Gemini's free-tier rate-limit responses use - that wording, so they are not yet retried +- Ambiguous current-quota 429s without a body retry delay of at most 120 s + still end the turn; a header alone does not change that classification. + Explicit exhausted account, billing and subscription limits remain terminal + regardless of a retry hint. Gemini limits without an accepted body delay are + not automatically retried ([#6923](https://github.com/codewhale-hq/Codewhale/issues/6923)). - Idle CPU has not been measured on FreeBSD or Linux for this release ([#6728](https://github.com/codewhale-hq/Codewhale/issues/6728)). diff --git a/docs/CLASSROOM_INSTALL.md b/docs/CLASSROOM_INSTALL.md index b174742179..59a64c01ae 100644 --- a/docs/CLASSROOM_INSTALL.md +++ b/docs/CLASSROOM_INSTALL.md @@ -4,7 +4,7 @@ A step-by-step checklist for IT admins deploying Codewhale on lab or classroom machines running Windows. > **Audience**: IT staff, teaching assistants, lab managers. -> **Prereq**: Each target machine runs Windows 10 (1809+) or Windows 11. +> **Prereq**: For current Windows Terminal, use Windows 10 2004 (build 19041+) or Windows 11. Older x64 deployments follow the [Windows compatibility target and qualification record](ENVIRONMENTS.md#windows-support-and-release-qualification); the CLI target is 1607, with actual final-release startup/install acceptance still required. --- @@ -12,7 +12,7 @@ machines running Windows. | # | Task | Done? | |---|------|-------| -| 1 | Confirm Windows version: `winver` → 10 build 17763+ or 11 | ☐ | +| 1 | Confirm OS, architecture and terminal against the [Windows support table](ENVIRONMENTS.md#windows-support-and-release-qualification). Current Windows Terminal requires build 19041+ or Windows 11. | ☐ | | 2 | Ensure the user account is a **standard user** (not a local admin). The installer does not require elevation. | ☐ | | 3 | Verify outbound HTTPS (port 443) is open to `api.openai.com` (or whichever LLM provider the course uses). | ☐ | | 4 | Obtain the installer: download `CodeWhaleSetup.exe` from a v0.8.50+ [release](https://github.com/codewhale-hq/CodeWhale/releases/latest) or from your department mirror. | ☐ | @@ -23,6 +23,11 @@ machines running Windows. ## Installation +The NSIS installer contains x64 runtime binaries. For native ARM64 machines, use +the matching archive/npm route in [INSTALL.md](INSTALL.md#windows-nsis-installer). +Import qualification and ARM64 candidate checks do not replace a clean lab +installation or the final 1607 x64 release receipt. + ### Option A — Silent install (recommended for imaging / SCCM / Intune) ```powershell diff --git a/docs/ENVIRONMENTS.md b/docs/ENVIRONMENTS.md index 9f16160b6d..f818f19312 100644 --- a/docs/ENVIRONMENTS.md +++ b/docs/ENVIRONMENTS.md @@ -62,6 +62,66 @@ diverging from monotonic elapsed by more than the suspend threshold — reports `System sleep detected; connection lost — retrying request`, and re-issues the request instead of failing the turn (#2990). +## Windows support and release qualification + +Windows compatibility depends on the runtime architecture and terminal. The +native x64 CLI/TUI compatibility target is Windows 10 1607 (build 14393) or +later in the legacy Windows console. A release's actual executable and installer +must still pass startup and installation on that target; an import-table pass +alone does not prove it. Required Visual C++ runtime DLLs must also be present. + +| Surface | Compatibility target or platform requirement | Qualification boundary | +| --- | --- | --- | +| Native x64 CLI/TUI | Windows 10 x64 1607, build 14393 | Final release bytes need an actual oldest-OS startup receipt | +| ConPTY API | Windows 10 1809, build 17763; Windows Server 2019 | This is Microsoft's API minimum; Codewhale's terminal-session routes remain Unix-only and return 501 on Windows | +| Current Windows Terminal | Windows 10 2004, build 19041, or later | This terminal has a newer prerequisite than the ConPTY API; use the legacy console for the x64 compatibility target | +| Native ARM64 CLI/TUI | Windows 11 ARM64 is the target | Candidate evidence exists on build 26300; no minimum Windows 11 build or Windows 10 ARM64 support is inferred from that run | +| NSIS installer | x86 setup/plugins under WOW64, installing x64 runtime bytes | Native ARM64 installation uses its archive/npm assets; generated Uninstall.exe and actual install behavior need separate receipts | + +[Microsoft's ConPTY requirements](https://learn.microsoft.com/en-us/windows/console/createpseudoconsole) +and [current Terminal requirements](https://github.com/microsoft/terminal/blob/main/README.md) +are separate. Windows 11 supports x64 emulation on ARM64; an emulated x64 run +does not qualify native x64 execution or Windows 10 1607. + +The release import guard uses separate per-machine profiles in +[`windows-import-floor.json`](../scripts/release/windows-import-floor.json). +Unknown import pairs, unlisted ordinals and delay-loaded dependencies stop the +gate. Its PASS covers the reviewed static import contract; it does not certify +all runtime features, the installed dependency closure or a minimum OS build. +The Native extension host still requires its runtime capability APIs and +isolation probes; where DeriveCapabilitySidsFromName is absent it reports +Unsupported rather than weakening its sandbox. This feature limit is distinct +from CLI startup compatibility. + +### Recorded 0.10.2 candidate evidence + +The 2026-10-13 qualification snapshot is source +[`cf7199b52e`](https://github.com/codewhale-hq/Codewhale/commit/cf7199b52ecb9147e4670ae7874924ffc30a6a67). +A default-feature debug ARM64 build on Windows 11 build 26300 produced executable +SHA256 `ca51f91562a0569ee43855715856e1cfee74e8a834bd6206183a3175373fdb2d`. +All 453 imported symbols resolved in a native ARM64 process using system-only +DLL lookup. The ordinary-user candidate checks passed 89 classifier tests and +one owner-crash recovery test, plus scoped editor wheel, Pinyin and undo checks. +This is neither a dist-profile release artifact nor installer acceptance, and +it establishes no oldest supported Windows 11 build. + +The x64 profile comes from @LmeSzinc's +[#6953 export investigation](https://github.com/codewhale-hq/Codewhale/issues/6953) +on build 14393.447: all 411 v0.10.0 imports and 453 of 454 v0.10.1 imports resolved. +The missing DeriveCapabilitySidsFromName pair is excluded; the conservative +allowed union has 456 pairs. The x86 NSIS profile has 189 named Microsoft API +contracts predating 1607 and one explicitly identified ordinal: COMCTL32 #17 is +InitCommonControls in the x86 Windows SDK 10.0.26100.0 import library and stock +SysWOW64 DLL. Its [documented minimum is Vista](https://learn.microsoft.com/en-us/windows/win32/api/commctrl/nf-commctrl-initcommoncontrols). +The manifest retains official source URLs, image hashes, counts and the ordinal +identity; these qualify static contracts without claiming a 1607 installer run. + +At this snapshot, startup/installation of the final release bytes on an actual +1607 machine and the candidate's generated uninstaller were still unqualified. +The final frozen source, release import dumps, archive/native/npm installation, +packaged/native-app owner recovery and oldest-OS receipts belong in the release +acceptance packet. See [the release checklist](RELEASE_CHECKLIST.md). + ## Windows PowerShell execution policy The shell tool runs PowerShell with `-ExecutionPolicy Bypass`. That sets only diff --git a/docs/INSTALL.md b/docs/INSTALL.md index c36388f1b2..118cffeeda 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -1301,6 +1301,9 @@ Known limits of the winget route: #### Windows NSIS Installer +For the OS/terminal compatibility targets and current evidence, see +[Windows support and release qualification](ENVIRONMENTS.md#windows-support-and-release-qualification). + A standalone NSIS-based installer is available starting with v0.8.50 for Windows users who prefer a traditional double-click setup (no npm, no Scoop, no Cargo required). diff --git a/docs/MODES.md b/docs/MODES.md index dc1b8e2ee2..c290e926f0 100644 --- a/docs/MODES.md +++ b/docs/MODES.md @@ -35,7 +35,7 @@ Run `/mode` to open the mode picker, or switch directly with `/mode work`, `/mode plan`, or `/mode operate`. - **Plan**: design-first prompting. The stable primitive names remain familiar, but the runtime centrally refuses file mutation and shell execution. Read-only inspection and policy-allowed research, including deferred Web search/fetch, remain available. When a Plan turn successfully finishes a nonempty response, including a prose-only plan, the TUI asks how to continue: Work (Ask), Work (Auto-Review), or keep planning. Work sends the exact approved response as a normal follow-up and preserves that turn's existing To-dos, or adds one pending plan item without replacing unrelated work. -- **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `bash`, `agent`, `workflow`, and `todo_write`, plus `create_goal`, `get_goal`, and `update_goal` so goal controls are available without discovery. The agent calls `create_goal` when a request is a durable objective; the host never infers a goal from wording, and the contract must not tell the model otherwise. Approval, sandbox, repository law, and managed policy decide what may execute. +- **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `file_search`, `bash`, `agent`, `workflow`, `todo_write`, and `load_skill`, plus `create_goal`, `get_goal`, and `update_goal` so goal controls are available without discovery. The agent calls `create_goal` when a request is a durable objective; the host never infers a goal from wording, and the contract must not tell the model otherwise. Approval, sandbox, repository law, and managed policy decide what may execute. - **Operate**: Work at full strength, the "ultra" mode. Same tools and authority as Work; the difference is how they are used. Each substantive request becomes a goal (`create_goal`), the plan stays visible (`todo_write`), multi-part work runs through `workflow` and parallel `agent` workers, non-trivial changes get an independent reviewer before they are called done, long commands keep running in the background, and recurring or watch-type work is offered as an `automation` (created after approval). It keeps going until the goal is verified, blocked on you, or paused. `Act` and `/mode act` remain compatibility aliases for Work. Saved settings diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index f98cd7328b..eb55e0a5d0 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -154,6 +154,15 @@ known limitations and publication approval. These proofs are separate. against those assets and runs its delegated entrypoints. Keep run URLs and required evidence before the Actions artifacts expire; these are not a public release. +- [ ] Windows static import receipts cover x64/ARM64 CLI, shim and compatibility + assets, any optional host, and the NSIS setup plus its actual embedded PE + plugins. Verify the selected architecture profile and its conservative + limits in [ENVIRONMENTS.md](ENVIRONMENTS.md#windows-support-and-release-qualification). + Preserve the generated candidate Uninstall.exe import/SHA receipt. Static + import/profile PASS does not replace final release startup/install on the + promised oldest x64 OS, native ARM64 package acceptance, or packaged/native + app owner-crash recovery. Record or explicitly resolve these remaining + runtime gates before tagging. - [ ] Complete the version-specific acceptance packet. Cross-building Android does not prove a real Termux/device session. Local install, actual provider calls and customer acceptance need separate receipts; provider spend still @@ -247,8 +256,9 @@ known limitations and publication approval. These proofs are separate. workflows can overwrite notes and accidentally remove contributor credit. - [ ] Note any deferred items in the next release's tracking issue. - [ ] Close any issues that this release fixed. -- [ ] Update existing tracking with evidence; agents do not post GitHub - issue/PR comments. Handle the existing `sync-release-record` workflow's +- [ ] Update existing tracking with evidence and follow the current contributor + communication rules in [AGENTS.md](../AGENTS.md). Handle the existing + `sync-release-record` workflow's metadata proposal after publication: it currently opens a bot PR, not a required preparation branch. A source record does not prove deployment. - [ ] Report canonical path, branch, SHA, upstream state and dirty/stranded diff --git a/docs/RELEASE_RUNBOOK.md b/docs/RELEASE_RUNBOOK.md index 06df406935..ae3b67d640 100644 --- a/docs/RELEASE_RUNBOOK.md +++ b/docs/RELEASE_RUNBOOK.md @@ -215,6 +215,16 @@ short-lived GitHub Actions artifact only. Retain required asset/evidence copies before artifact expiry; record cross-build, install and real-device/provider results separately in the existing packet. +Windows import evidence uses independent profiles for the native x64 +compatibility target, observed ARM64 environment and x86 NSIS/WOW64 contracts; +see [ENVIRONMENTS.md](ENVIRONMENTS.md#windows-support-and-release-qualification). +Keep every staged Windows executable and the actual embedded NSIS PE plugin +dumps, hashes and failures. The generated Uninstall.exe needs its own scoped +installation receipt. Static import qualification does not prove final release +startup on Windows 10 1607, installed runtime dependencies or native ARM64 +package/desktop behavior. Preserve these runtime receipts separately before +claiming the release is ready. + This candidate workflow does not create a tag or GitHub Release, publish a crate or npm package, push a container, update Homebrew, deploy anything, or write repository contents. Its green result is evidence, not publication diff --git a/docs/TOOL_SURFACE.md b/docs/TOOL_SURFACE.md index 74acd52e3a..a72c6f6c58 100644 --- a/docs/TOOL_SURFACE.md +++ b/docs/TOOL_SURFACE.md @@ -48,6 +48,7 @@ The direct schemas deliberately stay small: | `read` | `path`, optional `offset`, optional `limit` | Read a bounded file window with explicit continuation or truncation notices. | | `write` | `path`, `content` | Create or replace a file. | | `edit` | `path`, `edits` | Apply one or more unambiguous text replacements against one original snapshot. | +| `file_search` | `query`, optional `path`, `limit`, `extensions`, `exclude` | Find files by name, path fragment or glob and return bounded, ranked matches. Respects ignore files; `exclude` replaces default exclusions. | | `bash` | `command`, optional `timeout`, optional `description` | Run one cancellable foreground shell command and return a bounded tail. The optional description is display-only. | | `agent` | delegated task and optional scope/context controls | Start or inspect focused child work. | | `workflow` | plan/script/source_path plus run controls | Coordinate multi-agent phases with dependencies and completion checks. | @@ -55,6 +56,7 @@ The direct schemas deliberately stay small: | `create_goal` | objective plus optional budget | Start the session goal the turn works toward. | | `get_goal` | none | Read the active goal and its progress. | | `update_goal` | terminal status | Mark the goal complete or blocked. | +| `load_skill` | optional `name`, `query` | Load a named skill's `SKILL.md` body and companion-file list; omit `name` or use `name="list"` to list eligible skills, or use `query` to search names and descriptions. | | `tool_search` | `query`, optional matching controls | Discover policy-allowed deferred tools and add selected schemas to this conversation's toolbox. | Mode is an authority decision, not a synonym system. Plan, Work, and Operate diff --git a/docs/zh_hans/ENVIRONMENTS.md b/docs/zh_hans/ENVIRONMENTS.md index 23273c56cd..6fc268aee0 100644 --- a/docs/zh_hans/ENVIRONMENTS.md +++ b/docs/zh_hans/ENVIRONMENTS.md @@ -57,6 +57,51 @@ CODEWHALE_PROVIDER=vllm VLLM_BASE_URL=http://127.0.0.1:8000/v1 VLLM_MODEL= \ 超过挂起阈值——上报 `System sleep detected; connection lost — retrying request`, 并重新发起请求,而不是让回合失败(#2990)。 +## Windows 支持与发布验收 + +原生 x64 CLI/TUI 的兼容性目标是 Windows 10 1607(内部版本 14393)及以上, +在传统 Windows 控制台中运行。静态导入检查通过,不等于最终发布文件已经在这台 +旧系统上启动或完成安装;必需的 Visual C++ 运行库 DLL 也必须存在。 + +| 使用场景 | 目标或平台前提 | 验收边界 | +| --- | --- | --- | +| 原生 x64 CLI/TUI | Windows 10 x64 1607,内部版本 14393 | 最终发布文件仍需真实旧系统启动回执 | +| ConPTY API | Windows 10 1809,内部版本 17763;Windows Server 2019 | 这是微软的 API 前提;Codewhale 终端会话接口仍仅限 Unix,Windows 返回 501 | +| 当前 Windows Terminal | Windows 10 2004,内部版本 19041,或更高版本 | 它的前提比 ConPTY API 更新;x64 旧系统兼容性目标使用传统控制台 | +| 原生 ARM64 CLI/TUI | 目标是 Windows 11 ARM64 | 候选版本在内部版本 26300 上有验证;不能由此推断最低 Windows 11 版本或 Windows 10 ARM64 支持 | +| NSIS 安装器 | x86 安装程序和插件在 WOW64 下运行,安装 x64 程序 | ARM64 使用原生压缩包/npm;生成的 Uninstall.exe 与实际安装行为需独立验收 | + +[ConPTY API 前提](https://learn.microsoft.com/en-us/windows/console/createpseudoconsole) +和[当前 Windows Terminal 前提](https://github.com/microsoft/terminal/blob/main/README.md) +应分别说明。Windows 11 ARM64 上的 x64 模拟运行不能验证原生 x64 或 Windows 10 1607。 + +[`发布导入基线`](../../scripts/release/windows-import-floor.json)按 PE 架构分别记录。 +未知导入、未列入基线的序号导入和延迟加载依赖会阻止发布门槛通过。PASS 只表示已 +核对的静态导入契约,不认证整个运行时、安装依赖或最低 OS 版本。Native 扩展宿主 +仍需要对应的运行时能力 API 和隔离检查;缺少 DeriveCapabilitySidsFromName 时明确 +返回 Unsupported,不会降低沙箱隔离。此限制与 CLI 启动兼容性不同。 + +2026-10-13 的候选源代码为 +[`cf7199b52e`](https://github.com/codewhale-hq/Codewhale/commit/cf7199b52ecb9147e4670ae7874924ffc30a6a67)。 +在 Windows 11 ARM64 内部版本 26300 上,默认功能 debug 程序的 SHA256 为 +`ca51f91562a0569ee43855715856e1cfee74e8a834bd6206183a3175373fdb2d`;原生 ARM64 +进程只从系统目录查询 DLL,453 个导入全部解析成功。普通用户环境中 89 个命令分类 +测试和 1 个崩溃恢复测试通过,并完成了限定范围的编辑器滚轮、拼音与撤销检查。 +它不是 dist 发布程序或安装验收,也不能据此指定 Windows 11 的最低内部版本。 + +x64 基线来自 @LmeSzinc 在 +[#6953](https://github.com/codewhale-hq/Codewhale/issues/6953)中对 14393.447 的调查: +v0.10.0 的 411 个导入全部成功,v0.10.1 的 454 个中有 453 个成功。缺失的 +DeriveCapabilitySidsFromName 不在允许列表中;两次调查的保守并集为 456 对导入。 +x86 NSIS 基线有 189 个微软文档标明早于 1607 的命名 API,以及明确核对的 +COMCTL32 #17(InitCommonControls):x86 SDK 10.0.26100.0 导入库与系统 SysWOW64 +DLL 的身份一致,[该 API 的文档前提是 Vista](https://learn.microsoft.com/en-us/windows/win32/api/commctrl/nf-commctrl-initcommoncontrols)。 +基线文件包含对应的官方 URL、文件哈希、数量与序号身份;这不等于在 1607 上安装成功。 + +此快照中,最终发布文件在真实 1607 上启动/安装及候选程序生成的卸载器仍未验收。 +最终冻结源码、发布导入记录、各安装途径、打包/原生应用崩溃恢复和最低 OS 回执应 +进入发布验收记录,见[英文发布检查单](../RELEASE_CHECKLIST.md)。 + ## Windows PowerShell 执行策略 shell 工具以 `-ExecutionPolicy Bypass` 运行 PowerShell。它只设置所启动子进程 diff --git a/docs/zh_hans/INSTALL.md b/docs/zh_hans/INSTALL.md index db416c2388..6ae7fbaba3 100644 --- a/docs/zh_hans/INSTALL.md +++ b/docs/zh_hans/INSTALL.md @@ -991,6 +991,9 @@ winget 方式的已知限制: #### Windows NSIS 安装器 +OS、终端兼容性目标与当前验收记录见 +[Windows 支持与发布验收](ENVIRONMENTS.md#windows-支持与发布验收)。 + 从 v0.8.50 起,为喜欢传统双击安装的 Windows 用户提供了独立的、基于 NSIS 的安装器(无需 npm、Scoop 或 Cargo)。 NSIS 安装器目前包含 Windows x64 二进制。Windows ARM64 用户应通过在原生 ARM64 Node.js 下运行的 npm 安装,或从同一个发布下载 `codewhale-windows-arm64.zip`;这两条路径使用的都是原生 ARM64 二进制。 diff --git a/docs/zh_hans/MODES.md b/docs/zh_hans/MODES.md index 2a5762c49c..2b56d07235 100644 --- a/docs/zh_hans/MODES.md +++ b/docs/zh_hans/MODES.md @@ -20,7 +20,7 @@ Workflow 通过同一个子智能体运行时执行命名步骤;Fleet 管理 按 `Tab` 补全输入框(composer)菜单,或在输入框为空时循环切换可见模式:**Plan → Work → Operate → Plan**。`Tab` 从不发送或排队输入框中的文本;用 `Enter` 发送或排队。按 `Shift+Tab` 循环切换权限姿态(Ask → Auto-Review → Full Access)。按 `Ctrl+T` 循环切换思考强度。运行 `/mode` 打开模式选择器,或直接用 `/mode work`、`/mode plan`、`/mode operate` 切换。 - **Plan**:设计优先的提示方式。原语(primitive)名称保持不变,仍是大家熟悉的那些,但运行时会集中拒绝文件修改和 shell 执行。只读检查与策略允许的研究(包括延迟加载的 Web 搜索/抓取)仍然可用。当一个 Plan 回合成功结束并给出非空回复(包括纯文字计划)时,TUI 会询问如何继续:Work(Ask)、Work(Auto-Review),或继续规划。选择 Work 会把已批准的原文作为普通后续消息发送,并保留该回合已有的 To-dos,或追加一个待办的计划项而不替换无关的工作。 -- **Work**(内部为 `agent`):普通的多步执行。第一回合的工具箱包含 `read`、`write`、`edit`、`bash`、`agent`、`workflow` 和 `todo_write`,以及无需先搜索即可使用的目标控制工具 `create_goal`、`get_goal` 和 `update_goal`。当请求属于一个持久的目标时,智能体会调用 `create_goal`;宿主从不根据措辞去推断目标,契约也不得告诉模型相反的内容。审批、沙箱、仓库规则(repository law)和托管策略决定什么可以执行。 +- **Work**(内部为 `agent`):普通的多步执行。第一回合的工具箱包含 `read`、`write`、`edit`、`file_search`、`bash`、`agent`、`workflow`、`todo_write` 和 `load_skill`,以及无需先搜索即可使用的目标控制工具 `create_goal`、`get_goal` 和 `update_goal`。当请求属于一个持久的目标时,智能体会调用 `create_goal`;宿主从不根据措辞去推断目标,契约也不得告诉模型相反的内容。审批、沙箱、仓库规则(repository law)和托管策略决定什么可以执行。 - **Operate**:以全力运行的 Work,即“ultra”模式。工具与权限和 Work 相同,区别在于如何使用它们。每个实质性请求都会成为一个目标(`create_goal`),计划保持可见(`todo_write`),多部分工作通过 `workflow` 和并行的 `agent` 工作者执行,非平凡的改动在宣布完成前先交给独立的审查者,长时间运行的命令继续在后台运行,循环或监视类工作则作为 `automation` 提出(获批后创建)。它会一直进行,直到目标被验证、被你阻塞或被暂停。 `Act` 和 `/mode act` 仍然是 Work 的兼容别名。保存的设置仍然规范化为内部值 `agent`。 diff --git a/docs/zh_hans/TOOL_SURFACE.md b/docs/zh_hans/TOOL_SURFACE.md index 83478369b9..1b98f12be6 100644 --- a/docs/zh_hans/TOOL_SURFACE.md +++ b/docs/zh_hans/TOOL_SURFACE.md @@ -49,6 +49,7 @@ | `read` | `path`、可选的 `offset`、可选的 `limit` | 读取一个有界的文件窗口,并给出明确的续读或截断提示。 | | `write` | `path`、`content` | 创建或替换文件。 | | `edit` | `path`、`edits` | 针对同一份原始快照应用一处或多处无歧义的文本替换。 | +| `file_search` | `query`,以及可选的 `path`、`limit`、`extensions`、`exclude` | 按文件名、路径片段或 glob 查找文件,返回有界的排序结果。遵循忽略文件;`exclude` 会替换默认排除规则。 | | `bash` | `command`、可选的 `timeout` | 运行一条可取消的前台 shell 命令,并返回有界的尾部输出。 | | `agent` | 被委派的任务以及可选的作用域/上下文控制项 | 启动或查看专注的子智能体任务。 | | `workflow` | plan/script/source_path 加上运行控制项 | 用依赖关系和完成检查来协调多智能体阶段。 | @@ -56,6 +57,7 @@ | `create_goal` | 目标文本以及可选预算 | 启动本回合所追求的会话目标(goal)。 | | `get_goal` | 无 | 读取当前生效的目标及其进度。 | | `update_goal` | 终态状态 | 把目标标记为完成或受阻。 | +| `load_skill` | 可选的 `name`、`query` | 加载命名技能的 `SKILL.md` 正文及配套文件列表;省略 `name` 或使用 `name="list"` 列出可调用技能,也可用 `query` 搜索名称与描述。 | | `tool_search` | `query`、可选的匹配控制项 | 发现策略允许的延迟工具,并把选中的 schema 加入本次对话的工具箱。 | 模式是一项权威决定,而不是同义词体系。Plan、Work 和 Operate 使用同一套原语身份。 diff --git a/web/data/agent-docs.generated.json b/web/data/agent-docs.generated.json index 9e4ede274e..ecd7d49956 100644 --- a/web/data/agent-docs.generated.json +++ b/web/data/agent-docs.generated.json @@ -5,8 +5,8 @@ "sources": [ { "path": "docs/INSTALL.md", - "sha256": "a3898b5b7c4abef90cfc8f9a5eb8d11c7773eac93f3dd6975c86e31621511810", - "content": "# Installing Codewhale\n\n> 阅读简体中文版:[zh_hans/INSTALL.md](zh_hans/INSTALL.md)\n\nCodewhale is an open-source coding agent that runs in your terminal. You give\nit a task (\"fix the failing test\", \"add a CLI flag\"). It reads your repository,\nedits files and runs commands. In the default **Ask** posture it applies file\nedits inside the workspace immediately (and shows you the diff), but asks before\nrunning shell commands, so commit or stash anything you care about first. It\nworks with many model providers. **DeepSeek** is the default.\n\nThe command is `codewhale`. `codew` is a shorter alias for the same program.\n\nThe installation paths were first checked with **v0.10.0** (released 2026-09-22) on a\nfresh **Ubuntu 24.04 x86_64** machine. The dated\n[install receipts](https://github.com/codewhale-hq/Codewhale/blob/37ecdfcc49bc68a9b0d058b97c3946e62c34bd31/docs/install-report/v0.10.0-2026-09-23/RECEIPTS.md) record the commands and output checked at that time. Steps that\ncould not be run on that machine are marked **(untested on this VM: reason)**.\nmacOS, Windows and Android are out of scope, apart from a few notes. A second\npass re-ran the installer, manual-download, archive and npm paths, the no-key\nchecks and zsh completion on **macOS 26.1 (Apple silicon)**; see\n[macOS notes](#macos-notes). Steps that need a model call were not re-run\nthere.\n\nInstall commands that use `latest` resolve to the latest **published** GitHub\nRelease or package. Between releases, `main` may already describe the next\nversion (for example a v0.10.2 source candidate before its tag). A\nprebuilt candidate is available through the official installer only after its\ntag, checksums and release assets exist. Contributors can build `main` now.\n\n## One account for your provider keys\n\n[Register](https://app.codewhale.net/register) or [sign in](https://app.codewhale.net/login)\nto keep your model providers' API keys in one place. Add, replace or remove\nkeys in your account's [Providers settings](https://app.codewhale.net/providers),\nor use the terminal commands below. On another device, sign in and select the\nCodewhale model route to use the same account keys without pasting them again.\n\n```bash\ncodewhale login\ncodewhale account keys set deepseek # asks for the key without echoing it\ncodewhale account keys list # shows status, never the key itself\ncodewhale --provider codewhale # use your account's model route\n```\n\nThe account setup here describes the 0.10.1 source candidate. Signing in does\nnot upload existing local keys. To copy one deliberately, use\n`codewhale account keys set deepseek --from-local`. Account keys stay on the\nservice; they are used for account-routed requests, not downloaded into each\ndevice's local key store. You can keep using local keys or a local model\nwithout an account. See [account key settings](CONFIGURATION.md#account-provider-keys)\nfor updating keys and switching between these options.\n\n## Test and contribute to current source\n\n`latest` selects the latest published release. To test unreleased repairs,\nbuild canonical GitHub `main`, or CNB `main` after verifying its commit matches.\nThe source version number alone does not establish a published package.\n\n```bash\ngit clone --branch main https://github.com/codewhale-hq/CodeWhale.git\ncd CodeWhale\ncargo build --release --locked -p codewhale-cli\ntarget/release/codewhale --version\n```\n\nSee the Cargo section for build prerequisites; use the repository's stable Rust\nselection (declared minimum 1.89). Windows uses `target/release/codewhale.exe`\nand needs MSVC. A development build is separate from a signed release package.\nFor mainland-China downloads and source validation, see [CNB mirror](CNB_MIRROR.md).\nThe [Tencent Lighthouse bootstrap](../scripts/tencent-lighthouse/README.md) is\nbilingual. An npm registry mirror serves the wrapper; native binary downloads\nstill need an available release asset source.\n\n---\n\n## 60-second quickstart (Linux or macOS)\n\n```bash\n# 1. Install. Downloads two checksum-verified binaries into ~/.local/bin (no sudo).\ncurl -fsSL https://codewhale.net/install.sh | sh\n\n# 2. Make sure ~/.local/bin is on your PATH, now and in future terminals.\necho 'export PATH=\"$HOME/.local/bin:$PATH\"' >> ~/.bashrc # zsh: use ~/.zshrc\nexport PATH=\"$HOME/.local/bin:$PATH\"\ncodewhale --version # -> codewhale 0.10.0 (1be1a703b975)\n\n# 3. Give it a DeepSeek API key (from https://platform.deepseek.com/api_keys).\ncodewhale auth set --provider deepseek # prompts for the key; nothing is echoed\ncodewhale auth status --provider deepseek # \"active source: secret store\"\n\n# 4. Run your first task inside a git repository.\ncd ~/your-project\ncodewhale\n```\n\nIn the TUI, type something concrete:\n\n```text\ncreate a Python file primes.py that prints the first 10 primes, run it, and show me the output\n```\n\nCodewhale writes the file, shows you a diff, then asks **APPROVAL: bash\npython3 primes.py – Do you want to proceed?** Press `y` to allow it once. It\nruns the command and reports the output. Press `Ctrl-D` (with an empty input\nbox) to quit. It prints the command to resume the session later.\n\n> **If nothing happens after you send your first message,** you have no key\n> configured. v0.10.0 doesn't warn you in that case. Press **F3**. If DeepSeek\n> shows `missing key`, press Enter, paste the key, then pick a model and\n> confirm.\n\n---\n\n## Contents\n\n1. [Before you start](#1-before-you-start)\n2. [Install: recommended installer](#2-recommended-installer-curl--sh)\n3. [Install: manual download from GitHub Releases](#3-manual-download-from-github-releases)\n4. [Install: npm](#4-npm)\n5. [Install: Cargo / build from source](#5-cargo-and-building-from-source)\n6. [Install: Homebrew (Linux) and Nix](#6-homebrew-on-linux-and-nix)\n7. [Updating and rolling back](#7-updating-and-rolling-back)\n8. [API keys and providers](#8-api-keys-and-providers)\n9. [Shell completions](#9-shell-completions)\n10. [Running it: TUI, headless, resume](#10-running-it)\n11. [Terminal notes (Ghostty and others)](#11-terminal-notes)\n12. [Uninstalling and what Codewhale leaves behind](#12-uninstalling)\n13. [Troubleshooting](#13-troubleshooting)\n14. [Appendix: other platforms (not re-tested in this revision)](#appendix-other-platforms-not-re-tested-in-this-revision)\n\n---\n\n## 1. Before you start\n\n| You need | Why |\n|---|---|\n| Linux x86_64 or arm64, or macOS | Prebuilt binaries exist for these. The Linux binaries are **static** (musl), so they have no glibc or libdbus dependency and run on any distro. |\n| `curl` (or `wget`) and `sha256sum` (or `shasum`) | The installer uses them to download and verify. |\n| A model provider key, e.g. [DeepSeek](https://platform.deepseek.com/api_keys) | Codewhale does nothing useful without a model. |\n| `git` (recommended) | Codewhale works best inside a git repository. |\n| Optional: Python 3, Node.js 20+ | If present, Codewhale enables its Python and JS execution tools (`codewhale doctor` lists them). |\n\n**Which install path?**\n\n* **Most people:** the [recommended installer](#2-recommended-installer-curl--sh).\n It's the fastest (about 6 s here), verifies checksums, and supports\n `codewhale update`.\n* **Air-gapped or security-reviewed machines:**\n [manual download](#3-manual-download-from-github-releases).\n* **You already manage CLI tools with npm:** [npm](#4-npm).\n* **No prebuilt binary for your platform, or you want to build it yourself:**\n [Cargo](#5-cargo-and-building-from-source).\n\nPick **one**. Several installs on one machine end up fighting over PATH (see\n[Troubleshooting](#13-troubleshooting)).\n\n**Privacy note:** Codewhale sends aggregate usage counts (PostHog) **by\ndefault**. To turn this off permanently:\n`codewhale config set telemetry false`, or export `CODEWHALE_TELEMETRY=0`,\nwhich always wins. The TUI also checks GitHub for updates at startup\n(`[update] check_for_updates` in `~/.codewhale/config.toml`).\n\n---\n\n\n\n## 2. Recommended installer (`curl | sh`)\n\n**Prerequisites:** curl, `sha256sum` (Linux) or the built-in `shasum` (macOS),\na writable home directory. No sudo, no Node, no Rust.\n\n```bash\ncurl -fsSL https://codewhale.net/install.sh | sh\n```\n\nWhat it does (verified):\n\n* It detects your platform (`linux-x64`, `linux-arm64`, `macos-x64`,\n `macos-arm64`). It refuses Android/Termux and riscv64 with a clear message.\n* It downloads `codewhale-`, `codew-` and\n `codewhale-artifacts-sha256.txt` from the latest GitHub Release, and verifies\n both binaries against the manifest. If either doesn't match, it stops before\n installing anything (`codewhale install: checksum mismatch for …`).\n* It installs `~/.local/bin/codewhale` and `~/.local/bin/codew`: two identical\n 78 MB files.\n* It **never uses sudo and never edits your shell profile.** It refuses to\n install into system or package-manager directories (`/usr/bin`,\n `~/.cargo/bin`, Homebrew, `node_modules`, `/nix/store`…) and refuses to\n overwrite a *different* existing `codewhale`.\n\nExpected output:\n\n```\nInstalling Codewhale for linux-x64\nRelease assets: https://github.com/codewhale-hq/CodeWhale/releases/latest/download\nInstall dir: /home/you/.local/bin\nChecksums verified\nInstalled checksummed release commands:\n /home/you/.local/bin/codewhale\n /home/you/.local/bin/codew\n…\nPATH selects no codewhale command; this install is /home/you/.local/bin/codewhale\n…\nPut /home/you/.local/bin first on PATH in future shells (run once; this installer does not edit shell profiles):\n echo 'export PATH=\"$HOME/.local/bin:$PATH\"' >> ~/.bashrc\nThen run: . ~/.bashrc (or open a new terminal)\n…\n```\n\n### macOS notes\n\nRe-checked on macOS 26.1, Apple silicon (`macos-arm64`), with a fresh `HOME`:\n\n* The installer printed `Installing Codewhale for macos-arm64`, verified\n checksums with the system tools, and installed `codewhale` and `codew`\n (64 MiB each, Mach-O arm64) in 4.3 s. Both report\n `codewhale 0.10.0 (1be1a703b975)`. They ran without a Gatekeeper prompt.\n* When Node isn't on `PATH`, it also prints `Computer Use is included and needs\n Node.js 20 or newer on PATH.` The core TUI works without Node, but Computer Use\n and the JavaScript execution tool (`js_execution`) stay unavailable until Node\n is on `PATH`.\n* `codewhale doctor` behaves as on Linux (exit 0, `All checks complete!` with no\n key, file-based secret store under `~/.codewhale/secrets/`), except that it\n reports `✓ sandbox available: macos-seatbelt`.\n\n### Put it on your PATH\n\nIf the installer says `PATH selects no codewhale command`, `~/.local/bin` isn't\non your PATH **in this shell**. The `codewhale.net/install.sh` installer then\nprints the matching line from the block below for your `$SHELL` (zsh, bash,\nfish, or a POSIX `sh`; for any other shell, or a directory name with quotes,\n`$`, backticks or backslashes, it tells you to add the directory yourself). It\nnever edits a shell profile itself. The `install.sh` inside a release archive\nprints only the current-shell `export` line. On Ubuntu and Debian, `~/.profile` adds\n`~/.local/bin`, but only if the directory existed when you *logged in*. So:\n\n* a new SSH or login shell picks it up automatically;\n* a new terminal **window** on a desktop (GNOME Terminal, Ghostty, …) usually\n doesn't, until you log out and back in. I hit\n `bash: codewhale: command not found` in Ghostty right after installing.\n\nFix it once:\n\n```bash\necho 'export PATH=\"$HOME/.local/bin:$PATH\"' >> ~/.bashrc # bash (macOS login bash: ~/.bash_profile, or ~/.profile if only that exists)\n# echo 'export PATH=\"$HOME/.local/bin:$PATH\"' >> ~/.zshrc # zsh\n# fish_add_path ~/.local/bin # fish (untested on this VM)\nexport PATH=\"$HOME/.local/bin:$PATH\"; hash -r\ncommand -v codewhale codew\n```\n\n### Options\n\n```bash\n# Choose the directory (must be absolute; created if missing)\ncurl -fsSL https://codewhale.net/install.sh | CODEWHALE_INSTALL_DIR=\"$HOME/.local/codewhale/bin\" sh\n# Install a specific release\ncurl -fsSL https://codewhale.net/install.sh | CODEWHALE_VERSION=v0.9.13 sh\n# Show help\ncurl -fsSL https://codewhale.net/install.sh | sh -s -- --help\n```\n\n### Verify\n\n```bash\ncodewhale --version # codewhale 0.10.0 (1be1a703b975)\ncodew --version # same\ncodewhale doctor # diagnostics; see the note in §8 about what it does NOT check\n```\n\n### Re-running the installer\n\n* Same version already installed: harmless. It prints\n `Already installed: …` and exits 0.\n* Different version already installed: it **refuses**\n (`codewhale install: refusing to replace existing …/codewhale`), exits 1 and\n changes nothing. It downloads ~160 MB before refusing. Use\n [`codewhale update`](#7-updating-and-rolling-back) instead.\n\n### Upgrade / uninstall\n\n* Upgrade: `codewhale update` (see §7).\n* Uninstall: `rm ~/.local/bin/codewhale ~/.local/bin/codew`, then see §12 for\n data.\n\n---\n\n## 3. Manual download from GitHub Releases\n\nUse this when you want to see and verify every byte yourself. Releases:\n. Each platform has **bare\nbinaries** (`codewhale-linux-x64`, `codew-linux-x64`, …) and an **archive**\n(`codewhale-linux-x64.tar.gz`) that holds the same two binaries plus an\n`install.sh`.\n\n### 3a. Bare binaries\n\n```bash\nmkdir -p ~/codewhale-dl && cd ~/codewhale-dl\nbase=https://github.com/codewhale-hq/CodeWhale/releases/latest/download\ncurl -fsSLO \"$base/codewhale-linux-x64\" # use linux-arm64 on ARM\ncurl -fsSLO \"$base/codew-linux-x64\"\ncurl -fsSLO \"$base/codewhale-artifacts-sha256.txt\"\nsha256sum -c codewhale-artifacts-sha256.txt --ignore-missing\n# codew-linux-x64: OK\n# codewhale-linux-x64: OK\nmkdir -p ~/.local/bin\ninstall -m 755 codewhale-linux-x64 ~/.local/bin/codewhale\ninstall -m 755 codew-linux-x64 ~/.local/bin/codew\n```\n\nThen [put `~/.local/bin` on PATH](#put-it-on-your-path) and run\n`codewhale --version`. On macOS the assets are `codewhale-macos-arm64` and\n`codew-macos-arm64` (`-macos-x64` on Intel), and the built-in `shasum` verifies\nthem (tested on macOS 26.1, Apple silicon):\n\n```bash\n/usr/bin/shasum -a 256 -c codewhale-artifacts-sha256.txt --ignore-missing\n# codew-macos-arm64: OK\n# codewhale-macos-arm64: OK\n```\n\nThe `codewhale-macos-arm64.tar.gz` archive verifies the same way against\n`codewhale-bundles-sha256.txt`, and its `./install.sh` installs into\n`~/.local/bin` (tested).\n\nTo pin a release, replace `latest/download` with `download/vX.Y.Z`, and take\nthe manifest from the same tag.\n\n### 3b. Archive\n\n```bash\ncd \"$(mktemp -d)\"\nbase=https://github.com/codewhale-hq/CodeWhale/releases/latest/download\ncurl -fsSLO \"$base/codewhale-linux-x64.tar.gz\"\ncurl -fsSLO \"$base/codewhale-bundles-sha256.txt\" # note: *bundles*, not *artifacts*\nsha256sum -c codewhale-bundles-sha256.txt --ignore-missing\n# codewhale-linux-x64.tar.gz: OK\ntar -xzf codewhale-linux-x64.tar.gz\ncd codewhale-linux-x64 && ./install.sh # -> ~/.local/bin; PREFIX=/some/dir ./install.sh -> /some/dir/bin\n```\n\nThe archive's `install.sh` behaves like the website installer: no sudo, it\nleaves differing existing files alone, and it prints the same PATH hint.\n\n**Upgrade:** `codewhale update` works for both 3a and 3b, because they're\n\"direct binary\" installs. **Uninstall:** delete the two files (see §12).\n\n---\n\n## 4. npm\n\n**Prerequisites:** Node.js 18+ and npm, with a **global prefix you can write\nto**. npm installs the registry's latest published version, never an\nunpublished source candidate.\n\n```bash\nnpm install -g codewhale\ncodewhale --version\n```\n\nThe package is a small wrapper. Its `postinstall` step downloads the same\n`codewhale`/`codew` release binaries, checks them against the release's SHA-256\nmanifest, and links `codewhale` and `codew` into npm's global `bin`. The whole\nthing took 6 s here.\n\n**Windows npm sessions:** Node remains the native program's launcher for the\nwhole session. A process-name kill such as `taskkill /IM node.exe` or\n`Get-Process node | Stop-Process -Force` can interrupt this and other npm\nCodewhale sessions and prevent normal terminal cleanup. Stop only the server\nPID you started or the process owning its port, or use Codewhale's task\ncancellation. The Windows\nnative archive/installer avoids this npm-parent dependency; this does not\nremove Node requirements for optional JavaScript tools. Codewhale's Windows\nshell safety floor holds recognized image-wide Node kills even in Full Access.\nAn external hard kill or an arbitrary program that terminates the launcher\ncannot be made graceful by this shell-command check.\n\n### If you get `EACCES: permission denied`\n\nThat means Node is installed system-wide (apt, `/usr/local`, `/opt`), and your\nuser can't write to its global prefix:\n\n```\nnpm error code EACCES\nnpm error Error: EACCES: permission denied, mkdir '/opt/node22/lib/node_modules/codewhale'\n```\n\n**Don't use `sudo npm`.** Either use a per-user Node (nvm, fnm, volta), or\npoint npm at a directory you own. I tested the second option:\n\n```bash\nnpm config set prefix \"$HOME/.npm-global\"\necho 'export PATH=\"$HOME/.npm-global/bin:$PATH\"' >> ~/.bashrc\nexport PATH=\"$HOME/.npm-global/bin:$PATH\"\nnpm install -g codewhale\ncommand -v codewhale codew # ~/.npm-global/bin/codewhale, ~/.npm-global/bin/codew\n```\n\n### Notes\n\n* npm hides the download progress. Add `--foreground-scripts` to see it\n (`codewhale: selected GitHub Releases for v0.10.0 … done.`). The chosen\n source is also written to\n `$(npm prefix -g)/lib/node_modules/codewhale/bin/downloads/codewhale.source`.\n* The package uses 157 MB on disk.\n* On macOS 26.1 (Apple silicon, Homebrew Node 25) an install into a user-owned\n prefix (`npm install -g --prefix codewhale`) took 3 s and linked\n `codewhale` and `codew`, both `codewhale 0.10.0 (1be1a703b975)`.\n* **Upgrade:** `npm install -g codewhale@latest`. `codewhale update` refuses\n on npm installs. It prints migration instructions and exits 1 with\n `error: The package-managed executable was not changed.`\n* **Specific version:** `npm install -g codewhale@0.9.13`.\n* **Uninstall:** `npm uninstall -g codewhale`. This removes only the program,\n not your data (§12).\n\n---\n\n\n\n## 5. Cargo and building from source\n\nUse this if there's no prebuilt binary for your platform, or you want to\ncompile it yourself. One Cargo package is required:\n`codewhale-cli` installs the `codewhale` command. npm and prebuilt releases also\nexpose `codew` as a convenience name for the same compiled runtime; Cargo does\nnot create that alias, so add `alias codew=codewhale` to your shell rc if you\nwant the short name.\n\n### Prerequisites (Debian/Ubuntu)\n\n```bash\nsudo apt-get install -y build-essential pkg-config libdbus-1-dev git\n# Rust via rustup (the distro's cargo is too old for this edition-2024 workspace)\ncurl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y\nsource \"$HOME/.cargo/env\"\nrustc --version # the workspace declares rust-version = 1.89\n```\n\n`libdbus-1-dev` **is required**. Without it the build fails after about a\nminute with:\n\n```\nerror: failed to run custom build command for `libdbus-sys v0.2.7`\n The system library `dbus-1` required by crate `libdbus-sys` was not found.\n```\n\nFedora/RHEL: `sudo dnf install -y gcc make pkgconf-pkg-config dbus-devel`\n**(untested on this VM: Ubuntu only)**.\n\n### 5a. From crates.io\n\n```bash\ncargo install codewhale-cli --locked\ncodewhale --version\n```\n\nTested result: **works**, with current stable Rust (1.98.1).\n* It took **25 min 31 s** on 4 vCPU and 15 GB RAM, and pulled about 470 MB\n into `~/.cargo/registry`.\n* It installs one 122 MB file, `~/.cargo/bin/codewhale`. That's a normal\n glibc-linked binary, and it needs `libdbus-1` at runtime.\n* `codewhale --version` prints `codewhale 0.10.0`, with no commit hash.\n* Headless and TUI smoke tests passed.\n\n> **v0.10.0 declared \"Rust 1.88+\", which was wrong.** The workspace now\n> declares 1.89, the version CI's MSRV job builds. With 1.88.0 the v0.10.0\n> install fails in seconds:\n> `rustc 1.88.0 is not supported by the following package: serde-saphyr@1.3.0 requires rustc 1.89`.\n> Use current stable (`rustup update stable`).\n\n### 5b. From a git checkout\n\n```bash\ngit clone --depth 1 --branch v0.10.0 https://github.com/codewhale-hq/CodeWhale.git\ncd CodeWhale\ncargo install --path crates/cli --locked # installs ~/.cargo/bin/codewhale\n```\n\nThings to know (observed):\n\n* The repo contains `rust-toolchain.toml` (`channel = \"stable\"`). The first\n `cargo` command inside the checkout **silently downloads the latest stable\n toolchain** (about 250 MB), whatever your default is.\n* The workspace treats every compiler warning as an error. With Rust 1.89\n the build **fails** after about 11 minutes with 8\n `error: this lint expectation is unfulfilled` errors in `codewhale-tui`. Use\n the stable toolchain the repo selects. Don't pass an older `+toolchain`.\n* The workspace release profile uses thin LTO. On a 4-vCPU / 15 GB VM, the\n `codewhale-tui` crate alone compiled for more than an hour, peaking at\n 6–8 GB of RAM. Budget 16 GB or more, and expect this to be the slowest install\n path by far. `target/` grew past 1.2 GB.\n\nTested result: **works** on the repo-selected stable Rust (1.98.1).\n* `Finished release profile … in 83m 12s`. A Nix build competed for CPU and\n memory for most of that time, so treat it as an upper bound.\n* `target/` ended at **3.1 GB**. Delete it afterwards with `cargo clean`.\n* The binary reports `codewhale 0.10.0 (dev)`, and `exec` worked.\n* Cargo prints `warning: default toolchain implicitly overridden with\n stable-x86_64-unknown-linux-gnu by rustup toolchain file`, which is harmless.\n* **Cargo builds provide no `codew`.**\n\n**Upgrade:** re-run the same `cargo install … --force` (for crates.io, you can\nadd `--version X.Y.Z`). `codewhale update` refuses Cargo installs.\n**Uninstall:** `cargo uninstall codewhale-cli`, then §12.\n\n---\n\n## 6. Homebrew on Linux and Nix\n\n### Homebrew on Linux: works, but not with the command the old docs gave\n\nPrerequisite: Homebrew itself. Its installer needs sudo **once**, to create\n`/home/linuxbrew/.linuxbrew`. Without sudo rights it stops with\n`Insufficient permissions to install Homebrew to \"/home/linuxbrew/.linuxbrew\"`.\nAsk an admin to run\n`sudo mkdir -p /home/linuxbrew/.linuxbrew && sudo chown $USER /home/linuxbrew/.linuxbrew`,\nthen re-run the installer. Afterwards, add\n`eval \"$(/home/linuxbrew/.linuxbrew/bin/brew shellenv bash)\"` to `~/.bashrc`,\nas its \"Next steps\" say.\n\n```bash\nbrew install Hmbown/deepseek-tui/codewhale # full name: taps and trusts in one step\n```\n\nThe two-step form (`brew tap Hmbown/deepseek-tui` then `brew install\ncodewhale`) **fails on Homebrew 7.x**:\n\n```\nError: Refusing to load formula hmbown/deepseek-tui/codewhale from untrusted tap hmbown/deepseek-tui.\nRun `brew trust --formula hmbown/deepseek-tui/codewhale` or `brew trust hmbown/deepseek-tui` to trust it.\n```\n\nRun `brew trust hmbown/deepseek-tui` first, or use the full name above.\n\nTested with Homebrew 7.0.6 against the v0.10.0 tap formula: the install took\n73 s. The tap formula tracks the latest release and downloads the official\nrelease binaries, so there's no compile. It provides **both** `codewhale` and\n`codew`. The `Hmbown/deepseek-tui` tap formula also depends on `node`, which\npulled in 31 bottles (~560 MB) on Linux. That `node` dependency belongs to\nthis tap formula only. The core TUI runs without Node; Computer Use and the JS\nexecution tool use it when it is on PATH.\n\n* **Upgrade:** `brew upgrade codewhale`. (`codewhale update` refuses, and\n suggests migrating.)\n* **Uninstall:** `brew uninstall codewhale && brew untap Hmbown/deepseek-tui`.\n This also autoremoves the tap's node dependency and anything else it pulled\n in. Homebrew's\n download cache (`~/.cache/Homebrew`, ~330 MB) stays until\n `brew cleanup --prune=all`.\n\n### Nix: partially tested\n\n```bash\n# flakes are still experimental; the tested setup enabled them once:\nmkdir -p ~/.config/nix\necho 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf\nnix run github:codewhale-hq/CodeWhale -- --version\n# one-off alternative (untested on this VM): nix --extra-experimental-features 'nix-command flakes' run github:codewhale-hq/CodeWhale -- --version\n```\n\nNix 2.35 installed fine; single-user mode needs `/nix` created by root once.\nFlakes resolved. What I learned before stopping:\n\n* There's **no binary cache**, so this is a full source build of the\n **main branch**, not the v0.10.0 release. The binary reports\n `codewhale 0.10.0 (dev)`.\n* The build step took 30 min on 4 cores. The package then runs its **test\n suite** (`doCheck`), which recompiles the workspace in test mode. That took\n over 70 minutes and more than 8 GB of RAM for one rustc process, and I\n stopped it at 105 minutes. So `nix run` completing, `nix build`, and\n `nix profile install/remove` are **(untested on this VM: build did not\n finish in the time budget; the VM's proxy also required an\n `--override-input fenix …` workaround)**.\n* Nix provides only `codewhale`; there's no `codew` (it builds just the\n `codewhale-cli` package).\n\nUnless you already live in Nix, use §2 instead.\n\n---\n\n## 7. Updating and rolling back\n\nThese work for installs from §2 and §3 (direct binaries). Package-manager\ninstalls (npm, Cargo, Homebrew) must be updated with their own tool.\n\n```bash\ncodewhale update --check\n# Current binary: /home/you/.local/bin/codewhale\n# Current version: v0.9.13\n# Latest stable release: v0.10.0\n# Update available. Run `/home/you/.local/bin/codewhale update` to install v0.10.0.\ncodewhale update\n# Downloading codewhale-linux-x64...\n# SHA256 checksum verified against codewhale-artifacts-sha256.txt from GitHub Releases.\n# ✅ Successfully updated to v0.10.0!\n# Updated binaries:\n# - /home/you/.local/bin/codewhale (codewhale-linux-x64)\n# - /home/you/.local/bin/codew (codewhale-linux-x64)\n```\n\nIt updates `codewhale` **and** `codew` together. It took 10 s here. Run it\nagain and you get `Already up to date; no download needed.` Other options:\n`--beta` and `--proxy `.\n\n\n\n### Rolling back (e.g. to v0.9.13)\n\n`codewhale update` never downgrades, and `CODEWHALE_VERSION=0.9.13 codewhale\nupdate` just says \"Already up to date\". To roll back, replace the files:\n\n```bash\ndir=\"$(dirname \"$(command -v codewhale)\")\" # the install PATH actually selects\nrm \"$dir/codewhale\" \"$dir/codew\"\ncurl -fsSL https://codewhale.net/install.sh | CODEWHALE_VERSION=v0.9.13 CODEWHALE_INSTALL_DIR=\"$dir\" sh\nhash -r; codewhale --version # codewhale 0.9.13 (a0b81f619b66)\n```\n\nTested with both the default `~/.local/bin` and a custom\n`CODEWHALE_INSTALL_DIR`. Use it only for installer, manual or archive\ninstalls. Never point it at an npm, Cargo or Homebrew directory.\n\nOr keep both versions side by side, and put the old one first on PATH:\n\n```bash\ncurl -fsSL https://codewhale.net/install.sh | CODEWHALE_VERSION=v0.9.13 CODEWHALE_INSTALL_DIR=\"$HOME/.local/codewhale-0.9.13\" sh\nexport PATH=\"$HOME/.local/codewhale-0.9.13:$PATH\"; hash -r\n```\n\nTo return to the latest after an in-place rollback, run `codewhale update`.\n(Tested: 0.9.13 → 0.10.0.)\n\nnpm: `npm install -g codewhale@0.9.13`. Cargo:\n`cargo install codewhale-cli --version 0.9.13 --locked --force` **(untested on\nthis VM: only 0.10.0 was built)**.\n\n---\n\n## 8. API keys and providers\n\n### Where Codewhale looks for a key (first match wins)\n\n1. `--api-key ` on the command line\n2. `api_key` in `~/.codewhale/config.toml`\n3. the secret store written by `codewhale auth set`\n4. the environment variable (`DEEPSEEK_API_KEY` for DeepSeek)\n\nThis order matters. **A key in config or the secret store beats\n`DEEPSEEK_API_KEY`.** If you rotate your key by exporting a new env var, an\nold stored key keeps being used. I tested this: a wrong key in `config.toml`\nplus the correct env var gives `Authentication Fails … ****beef is invalid`.\n\n### Ways to set a DeepSeek key (all tested)\n\n**Environment variable.** Good for trying it out and for CI:\n```bash\nexport DEEPSEEK_API_KEY=sk-... # add to ~/.bashrc / ~/.zshenv to persist\n```\n\n**`auth set`.** Saves the key for every folder:\n```bash\ncodewhale auth set --provider deepseek # prompts: \"Enter API key for deepseek:\"\nprintf '%s\\n' \"$KEY\" | codewhale auth set --provider deepseek --api-key-stdin # scripted\n# -> saved API key for deepseek to file-based (\"/home/you/.codewhale/secrets/secrets.json\") (config contains metadata only)\n```\nThe file-based message prints the resolved secret-store path; an explicit\n`CODEWHALE_HOME` changes that location.\n\nOn Linux, the key is stored in **plaintext** in\n`~/.codewhale/secrets/secrets.json`, with mode 0600. It is not in an OS\nkeyring. Note that in v0.10.0, `auth set` also writes\n`default_text_model = \"deepseek-v4-pro\"` into your config, switching you from\nthe default `deepseek-flash` to the pricier Pro model. Change it back with\n`/model` in the TUI, or edit `~/.codewhale/config.toml`.\n\n**Inside the TUI.** Press **F3** (or type `/provider`), select DeepSeek, press\nEnter, paste the key (masked), pick a model, and confirm. This also writes the\nsecret store, and keeps `deepseek-flash`.\n\n**Config file.** `~/.codewhale/config.toml`:\n```toml\n[providers.deepseek]\napi_key = \"sk-...\"\n```\n\n### Check which key is active\n\n```bash\ncodewhale auth status --provider deepseek\n# active source: env (last4: ...xxxx) # or: secret store / config / missing\n# lookup order: config -> secret store -> env\ncodewhale doctor --probe-api\n# · Testing connection... ✓ API connection successful\n```\n\nPlain `codewhale doctor` names an environment key it can see\n(`deepseek: env_source=set via DEEPSEEK_API_KEY (value not shown; not checked offline)`)\nbut does not read the secret store or check the key; `--probe-api` does both.\nDoctor exits 0 even when no key is found, so scripts should use `auth status`.\n\n### Remove a stored key\n\n```bash\ncodewhale auth clear --provider deepseek\n# cleared API key for deepseek from config and secret store\n```\n\nIt doesn't unset `DEEPSEEK_API_KEY` in your shell, and it leaves the\n`default_text_model` line that `auth set` added.\n\n### Other providers\n\n`codewhale auth list` shows about 50 providers (OpenRouter, Anthropic, OpenAI,\nMoonshot, Ollama, …). The pattern is the same:\n`codewhale auth set --provider `, or the provider's env var. Local models\n(Ollama, vLLM, SGLang) need no key. Only DeepSeek was tested here.\n\n---\n\n\n\n## 9. Shell completions\n\n```bash\n# bash (needs the bash-completion package)\nmkdir -p ~/.local/share/bash-completion/completions\ncodewhale completion bash > ~/.local/share/bash-completion/completions/codewhale\n\n# zsh\nmkdir -p ~/.zfunc\ncodewhale completion zsh > ~/.zfunc/_codewhale\n# in ~/.zshrc, if not already there:\n# fpath=(~/.zfunc $fpath)\n# autoload -Uz compinit && compinit\n\n# fish\nmkdir -p ~/.config/fish/completions\ncodewhale completion fish > ~/.config/fish/completions/codewhale.fish\n```\n\nEach script registers both `codewhale` and `codew`. `codewhale completions` is\nan alias. Open a new shell afterwards. Regenerate after upgrading.\n\nHow well they work in v0.10.0 (tested interactively):\n\n* **bash:** fully works (`codewhale comp`, `codew auth `).\n* **fish:** sub-commands complete with descriptions, but\n `codewhale completion ` offers files instead of shell names.\n* **zsh:** only the first word completes. After a sub-command\n (`codewhale auth `), zsh wrongly lists the top-level commands again\n (same on macOS zsh 5.9, where it offers all 126 top-level entries).\n\nPowerShell and Elvish scripts are generated too **(untested on this VM: shells\nnot installed)**.\n\n---\n\n## 10. Running it\n\n### The TUI\n\n```bash\ncd your-git-repo\ncodewhale\n```\n\n* The composer is at the bottom. The footer shows the permission posture\n (`ask`), the mode (`work`) and the model (`DeepSeek · deepseek-flash`).\n* **Shift+Tab** cycles the permission posture: Ask → Auto-Review → Full Access.\n **Tab** (with an empty composer) cycles the mode: Plan → Work → Operate.\n* In **Ask**, file edits in the workspace are applied and shown as a diff.\n Shell commands stop at an **APPROVAL** prompt: `y` allow once, `a` allow for\n this session, `n` deny, `Esc` abort the turn.\n* Useful keys: **F1** help (or `/help`), **Ctrl-K** command palette, **F3**\n provider/model picker, **Ctrl-R** resume a past session, **Ctrl-U** clear the\n input (**Ctrl-Z** restores it), **Ctrl-C** cancel or quit, **Ctrl-D** quit\n with an empty input. Full list: [KEYBINDINGS.md](KEYBINDINGS.md).\n* On exit it prints `To resume this session, run codewhale resume `.\n\nCodewhale creates a `.codewhale/` directory in your repo. Ignore its contents\nbut keep the committable `constitution.json` (these are the same patterns\n`/init` writes):\n\n```gitignore\n**/.codewhale/*\n!**/.codewhale/constitution.json\n```\n\n### Headless (scripts, CI)\n\n```bash\ncodewhale exec \"Reply with exactly: pong\" # one-shot answer, no tools\ncodewhale exec --auto \"create primes.py that prints the first 10 primes and run it\" # tools, auto-approved\ncodewhale exec --json \"…\" # summary JSON (provider, model, usage, output)\ncodewhale exec --auto --output-format stream-json \"…\" # one JSON event per line\n```\n\n`--auto` auto-approves shell commands, so use it only in a repo or sandbox you\ntrust.\n\nPlain `exec` offers the model no tools. Only `--auto`, `--yolo`,\n`--allowed-tools` or resuming a session opens a tool surface; limits such as\n`--max-turns`, `--disallowed-tools`, `--sandbox` and the output format never\nadd tools (tool-only flags print a warning). If the provider stops a reply at\nits output limit, the model is asked to continue and the printed answer is the\nwhole reply. A plain run takes at most 8 model steps unless `--max-turns` sets\nanother limit; a reply still cut off at that limit fails the run.\n\n### Resuming\n\n```bash\ncodewhale resume # or a unique prefix, e.g. e2525dfb\ncodewhale -c # continue the most recent session in this folder\ncodewhale sessions # list saved sessions\ncodewhale exec --continue \"…\" # headless follow-up to the latest session\ncodewhale exec --resume \"…\"\n```\n\nIn v0.10.0, only **TUI sessions** and **`--output-format stream-json`** exec\nruns are saved. A plain `codewhale exec`/`exec --auto` run is *not* saved, so a\nfollowing `exec --continue` fails with `No saved sessions found for workspace`.\n\n---\n\n## 11. Terminal notes\n\n### Ghostty (tested: Ghostty 1.3.1 on Linux/X11)\n\nEverything I checked worked in Ghostty with its default config\n(`TERM=xterm-ghostty`, `COLORTERM=truecolor`). Screenshots are kept with the\n[install receipts](https://github.com/codewhale-hq/Codewhale/tree/37ecdfcc49bc68a9b0d058b97c3946e62c34bd31/docs/install-report/v0.10.0-2026-09-23/screenshots).\n\n| Check | Result |\n|---|---|\n| Colours / truecolor gradient, box drawing, Unicode (✓ é 日本語) | ✅ |\n| Window resize (1504×886 → 800×500 → back) reflows cleanly | ✅ |\n| Mouse wheel scrolls the transcript, with a jump-to-bottom button | ✅ |\n| Paste (`Ctrl+Shift+V`), multi-line: inserted, not sent | ✅ |\n| F1, F3, Ctrl-K, Ctrl-R, Tab, Shift+Tab, Ctrl-U/Ctrl-Z, Ctrl-C, Ctrl-D | ✅ |\n| Window title shows state (`waiting on you…`, `✓ done`) | ✅ |\n| Exit restores the terminal (normal screen, cursor, no mouse-reporting garbage) | ✅ |\n\nGhostty on Linux starts a **non-login** shell, so it reads `~/.bashrc` and not\n`~/.profile`. That's why you need the PATH line in `~/.bashrc` (§2).\n\nYou may notice small dots and a faint label (e.g. `other · drift`) drifting\nacross empty space after a turn. That's Codewhale's decorative \"ambient life\"\nwhale, not a rendering bug.\n\n### Other terminals\n\ntmux eats **F1**, so use `/help` there. Some key chords (Ctrl-Shift-…, Ctrl-Tab)\nneed a terminal with an enhanced keyboard protocol; [KEYBINDINGS.md](KEYBINDINGS.md) lists\nportable alternatives. Windows users should use Windows Terminal\n**(untested on this VM)**.\n\n---\n\n## 12. Uninstalling\n\n### Step 1: forget stored keys (if you used `auth set` or F3)\n\n```bash\ncodewhale auth clear --provider deepseek\n```\n\n### Step 2: remove the program\n\n| Installed with | Remove with |\n|---|---|\n| installer (§2) or manual (§3) | `rm ~/.local/bin/codewhale ~/.local/bin/codew` (or your `CODEWHALE_INSTALL_DIR`) |\n| npm | `npm uninstall -g codewhale` |\n| Cargo | `cargo uninstall codewhale-cli` |\n| Homebrew | `brew uninstall codewhale && brew untap Hmbown/deepseek-tui` (also removes its node dependency) |\n\n### Step 3: remove data. No uninstaller does this for you.\n\n| Path | What it is | Size seen |\n|---|---|---|\n| `~/.codewhale/` | config.toml, **secrets/secrets.json (plaintext keys)**, sessions/, logs/, catalog/ (model list, ~5 MB), skills/, builtin-plugins/, tasks/, automations/, crashes/, audit.log, composer history | 6–7 MB |\n| `~/.deepseek/snapshots/` | v0.10.0 stores its per-turn **copies of your workspaces** here (a legacy path). Contains the contents of every repo you ran it in. | 0.2–0.6 MB here; grows with repo size |\n| `/.codewhale/` | per-workspace state/lock dir | tiny |\n| completion files | `~/.local/share/bash-completion/completions/codewhale`, `~/.zfunc/_codewhale`, `~/.config/fish/completions/codewhale.fish` | – |\n| PATH lines you added | `~/.bashrc`, `~/.zshrc`, `~/.profile` | – |\n\n```bash\nrm -rf ~/.codewhale ~/.deepseek/snapshots\nrmdir ~/.deepseek 2>/dev/null # removes the parent only if it is now empty\n# per-repo dirs, e.g.:\nfind ~ -type d -name .codewhale -prune -print # review, then delete the ones you want\n```\n\nCodewhale wrote nothing outside `$HOME` and the repos it was used in: no\nsystem files, services or cron jobs. (I checked every file owned by the test\nusers outside their home directories.) The commands above delete only\n`~/.deepseek/snapshots`. If you still use the older DeepSeek-TUI, the rest of\n`~/.deepseek` (its config and sessions) is left alone.\n\n---\n\n## 13. Troubleshooting\n\nEvery error below was hit while writing this guide.\n\n**`bash: codewhale: command not found` right after installing.**\n`~/.local/bin` isn't on PATH in this terminal. See\n[Put it on your PATH](#put-it-on-your-path).\n\n**`npm error code EACCES … permission denied, mkdir '…/lib/node_modules/codewhale'`.**\nYour Node is system-owned. See [§4](#if-you-get-eacces-permission-denied).\nDon't use sudo.\n\n**`error: DeepSeek API key not found.` (from `codewhale exec`)**\nNo key anywhere. Follow the printed steps, or see §8.\n\n**The TUI shows your message but never answers.**\nNo key (v0.10.0 doesn't say so). Press F3 → DeepSeek → Enter → paste the key.\n\n**`error: Responses API request failed … Authentication Fails, Your api key: ****dead is invalid`.**\nThe key is wrong or revoked. Run `codewhale auth status --provider deepseek`\nto see *which* source is being used. Remember that config and the secret store\nbeat the env var. Fix with `codewhale auth set --provider deepseek`, or\n`codewhale auth clear --provider deepseek` to fall back to the env var. In the\nTUI, a bad key sends you to a \"Choose your model provider\" screen that marks\nDeepSeek `last check failed (authentication)`.\n\n**`error: Network error: SSE stream request failed after HTTP/1.1 fallback: Responses API request failed. … on Windows or proxy networks, try CODEWHALE_FORCE_HTTP1=1 …`.**\nDespite the wording, on Linux this usually just means **no connection to\n`api.deepseek.com`**. Check with `curl -sI https://api.deepseek.com` (a `401`\nresponse is fine; it means the host is reachable). If you're behind a proxy,\nmake sure `HTTPS_PROXY` is exported. `codewhale doctor --probe-api` only says\n`✗ API connection failed` for both bad keys and network problems.\n\n**`codewhale install: refusing to replace existing ~/.local/bin/codewhale`.**\nA different version is already installed there. Run `codewhale update`, or\ndelete the two files first (§7 rollback), or install into a fresh\n`CODEWHALE_INSTALL_DIR`.\n\n**`codewhale install: checksum mismatch for codew-linux-x64`.**\nThe download was corrupted or tampered with. Nothing was installed. Retry, and\nif it repeats, don't use a mirror.\n\n**`error: The package-managed executable was not changed.` (from `codewhale update`)**\nYou installed with npm, Cargo or Homebrew. Update with that tool instead.\n\n**`error: failed to run custom build command for libdbus-sys` (Cargo).**\nRun `sudo apt-get install -y libdbus-1-dev pkg-config`.\n\n**`error: No saved sessions found for workspace …` (from `exec --continue`).**\nThe previous run was plain-text `exec`, which isn't saved. Use the TUI, or\n`--output-format stream-json`.\n\n**zsh completion suggests the wrong things after the first word.**\nKnown v0.10.0 bug. bash and fish are fine.\n\n**Getting help:** `codewhale doctor --json` produces a diagnostics bundle\nwithout secrets.\n\n---\n\n## Appendix: other platforms (not re-tested in this revision)\n\nThe sections below are carried over unchanged from the previous revision of\nthis page. They were **not re-run** for the v0.10.0 install test above\n(out of scope: Windows, macOS, Android/Termux, FreeBSD, mainland-China\nmirrors), apart from the macOS paths noted in [macOS notes](#macos-notes).\nKnown contradictions with the published v0.10.0 assets, found by inspecting\nthem ([details](https://github.com/codewhale-hq/Codewhale/blob/37ecdfcc49bc68a9b0d058b97c3946e62c34bd31/docs/install-report/v0.10.0-2026-09-23/DOC_DEFECTS.md), D15 and D16):\n\n* The winget manifest kept in `packaging/winget/` is stale (0.9.6) and not the\n published package; winget serves `HunterBown.CodeWhale`, see\n [Windows winget](#windows-winget).\n* v0.10.0 publishes both `codewhale-windows-x64.zip` (with an `install.bat`\n that copies to `%USERPROFILE%\\bin`) and `codewhale-windows-x64-portable.zip`;\n the sections below mention only the first.\n* The standalone `codewhale.bat` launcher works only next to the x64 exe.\n\n### Supported platforms and assets\n\nThe [latest stable release](https://github.com/codewhale-hq/CodeWhale/releases/latest)\npublishes Linux x64/arm64, macOS x64/arm64, Windows x64/arm64, and Android arm64\nassets. Artifact presence is distinct from platform qualification.\nThe table below describes the current source tree's platform and secondary\npackaging support; `latest` installation still selects the published release.\nAndroid/Termux is preview pending real-device QA. Linux ARM64 is available from\nv0.8.8 onward. Linux RISC-V prebuilts are temporarily paused because the locked\n`rquickjs-sys` dependency does not ship `riscv64gc-unknown-linux-gnu` bindings.\n\n| Platform | Architecture | GitHub release asset | npm install | `cargo install` |\n| ------------ | ------------ | ----------------------------------------------------- | :---------: | :-------------: |\n| Linux | x64 (x86_64) | `codewhale-linux-x64`, `codew-linux-x64` | ✅ | ✅ |\n| Linux | arm64 | `codewhale-linux-arm64`, `codew-linux-arm64` | ✅ | ✅ |\n| Android / Termux | arm64 (aarch64) | `codewhale-android-arm64.tar.gz` (published in v0.9.12; device support is preview) | ⚠️⁴ preview | ⚠️⁴ preview |\n| Linux | riscv64 | temporarily unsupported until upstream bindings land | ❌¹ | ❌³ |\n| macOS | x64 | `codewhale-macos-x64`, `codew-macos-x64` | ✅ | ✅ |\n| macOS | arm64 (M-series) | `codewhale-macos-arm64`, `codew-macos-arm64` | ✅ | ✅ |\n| Windows | x64 | `codewhale-windows-x64.exe`, `codew-windows-x64.exe` | ✅ | ✅ |\n| Windows | arm64 | `codewhale-windows-arm64.exe`, `codew-windows-arm64.exe` | ✅ | ✅ |\n| Linux x64 or arm64 on musl (Alpine) | native arch | matching static Linux asset | ✅ (static) | ✅ |\n| Other Linux (musl on other arches) | — | build from source | ❌¹ | ✅² |\n| FreeBSD 14+ / OpenBSD | x64, arm64 | `cargo install codewhale-cli --locked` (no prebuilt; see § FreeBSD) | ❌ | ✅² |\n\n¹ The npm package will exit with a clear error and point you here.\n² Provided your toolchain can compile a recent Rust workspace; see\n [Build from source](#5-cargo-and-building-from-source) below.\n³ RISC-V source builds currently need upstream `rquickjs-sys` RISC-V bindings or\n a bindgen-enabled dependency build.\n⁴ The current npm wrapper recognizes Android arm64 and resolves\n the matching `codewhale` and `codew` Android assets. npm\n installation works only for a package version whose GitHub Release publishes\n those matching assets. The Android/Termux path remains preview-only until the\n real-device compile, startup, approval, file-tool, and update checks tracked\n in #4236 and #4242 are complete.\n\nAndroid / Termux is not the same target as Linux arm64. Do not install the\nLinux `codewhale-linux-arm64` archive in Termux; use the Termux-specific\nAndroid archive when a release or release candidate publishes one, or build\nfrom source inside Termux.\n\nThe current Linux **x64 and arm64** assets are **static musl builds**.\nThe x64 release path has used musl since v0.8.65; v0.9.6 extends the same build\nand static-launch check to arm64. These binaries have no glibc dependency and\nrun on their matching architecture across Ubuntu, Debian, RHEL/CentOS, and\nAlpine/musl. SQLite is bundled through `rusqlite`, so no separate `libsqlite3`\nruntime package is needed.\n\n#### Linux ARM64 portability\n\nLinux arm64 assets before v0.9.6 were GNU libc builds and could inherit the\nUbuntu 24.04 build host's `GLIBC_2.39` floor. Ubuntu 22.04 ships glibc 2.35, so\nthose older arm64 binaries can fail with errors such as:\n\n```text\nversion `GLIBC_2.39' not found\n```\n\nThe npm wrapper, `codewhale update`, and the Unix archive installer retain their\nGNU-binary preflight for older releases. The current arm64 build instead uses\n`aarch64-unknown-linux-musl`, so it has no `GLIBC_*` floor. If you are installing\nan earlier release on an older arm64 distribution, use:\n\n```bash\ncargo install codewhale-cli --locked # installs `codewhale`\n```\n\n> **Linux ARM64 note (v0.8.7 and earlier).** v0.8.7 and earlier do **not**\n> publish a Linux ARM64 prebuilt; users on HarmonyOS thin-and-light, Asahi\n> Linux, Raspberry Pi, AWS Graviton, etc. saw `Unsupported architecture: arm64`\n> from `npm i -g codewhale`. v0.8.8 publishes `codewhale-linux-arm64`, so a plain `npm i -g codewhale` works\n> on any glibc-based ARM64 Linux. If you're stuck on v0.8.7, jump to\n> [Build from source](#5-cargo-and-building-from-source) — `cargo install` works fine.\n> For HarmonyOS PC and OpenHarmony cross-build setup, see\n> [HarmonyOS and OpenHarmony](HarmonyOS.md).\n\n### Migrating from npm, Cargo, or another installation\n\n#### Migrating from npm, Cargo, or another installation\n\nPackage managers continue to own their files. `codewhale update` gives migration\ninstructions for npm, Cargo, Homebrew, and Omarchy instead of overwriting them.\nKnown system/package directories are also protected. A `CODEWHALE_INSTALL_METHOD=binary`\noverride cannot bypass a recognized managed path.\n\nCreate a fresh destination when `~/.local/bin` is occupied or a sibling command\nhas different bytes. This leaves every existing installation in place:\n\n```bash\nmkdir -p \"$HOME/.local\"\ncodewhale_install_dir=\"$(mktemp -d \"$HOME/.local/codewhale-release.XXXXXX\")\"\ncurl -fsSL https://codewhale.net/install.sh | CODEWHALE_INSTALL_DIR=\"$codewhale_install_dir\" sh\n\"$codewhale_install_dir/codewhale\" --version\nexport PATH=\"$codewhale_install_dir:$PATH\"\nhash -r\ncommand -v codewhale codew\n\"$codewhale_install_dir/codewhale\" update --check\n```\n\nAfter verifying the version and command paths, keep that directory first in your\nshell profile. In PowerShell, use `Get-Command codewhale, codew -All` to inspect\nresolution; run the selected executable using its full path. A successful update\nonly changes its own install directory, so another earlier PATH entry can still\nlaunch an older copy.\n\nModern matched `codewhale`, `codew`, and compatibility copies update from the\nsame verified bytes. Symlinks to the running binary are preserved. A different\nor unrelated sibling is named in the error and left untouched; no sibling is\nexecuted merely to guess its owner. Use the fresh-directory migration above\nfor older installs with separate dispatcher/TUI binaries.\n\nTo retain a secondary package-managed install, use its manager:\n\n```bash\nnpm install -g codewhale@latest\n# or\ncargo install codewhale-cli --locked --force\n```\n\nHomebrew uses `brew upgrade codewhale`; Omarchy uses `omarchy update`. These\ncommands update their own copies, so verify PATH again afterward.\n\n\n\n### Android / Termux arm64 (preview)\n\nTermux runs on Android's Bionic libc and uses `$PREFIX` as its Unix prefix, so\nit needs a Termux-specific Android arm64 archive. The Linux arm64 release asset\ntargets standard Linux with musl; Android uses a distinct Rust target, so the\nLinux asset should not be used there.\n\nInstall the minimum archive/runtime tools first:\n\n```bash\npkg update\npkg install -y ca-certificates curl tar gzip coreutils\n```\n\nWhen the release includes `codewhale-android-arm64.tar.gz`, install it with the\narchive's bundled installer. Passing `PREFIX=\"$PREFIX\"` matters: the installer\ndefaults to `~/.local`, while Termux users normally expect commands under\n`$PREFIX/bin`.\n\n```bash\ncd \"$HOME\"\ncurl -L -O https://github.com/codewhale-hq/CodeWhale/releases/latest/download/codewhale-android-arm64.tar.gz\ncurl -L -O https://github.com/codewhale-hq/CodeWhale/releases/latest/download/codewhale-bundles-sha256.txt\nsha256sum -c codewhale-bundles-sha256.txt --ignore-missing\n\ntar xzf codewhale-android-arm64.tar.gz\ncd codewhale-android-arm64\nPREFIX=\"$PREFIX\" ./install.sh\nhash -r\n```\n\nIf you are validating from source or building a release candidate locally,\ninstall the build packages before running Cargo:\n\n```bash\npkg install -y rust clang pkg-config make git\ncargo install codewhale-cli --locked # installs `codewhale`\n```\n\nThe normal first-run setup path is implemented, but its Android interaction is\nstill part of the preview QA above. Prefer provider environment variables for\ntemporary credentials. `codewhale auth set` is available, but the Termux build\nhas no supported OS keyring integration and falls back to file-backed secrets\nby writing `~/.codewhale/config.toml` and mirroring keys to\n`~/.codewhale/secrets/secrets.json`. Both are plaintext files protected by\n`0600` permissions and are not encrypted at rest.\n\n```bash\ncodewhale auth set --provider deepseek\ncodewhale auth status\ncodewhale doctor\n```\n\nMaintainers should use this repeatable smoke checklist for a Termux / Android\narm64 release candidate:\n\n```bash\ncommand -v codewhale codew\ntest -x \"$PREFIX/bin/codewhale\"\ntest -x \"$PREFIX/bin/codew\"\n\ncodewhale --version\ncodewhale doctor\ncodewhale exec --auto \"run pwd\"\n```\n\nKnown limitations:\n\n- Commands inherit Android's per-app UID, SELinux, and seccomp protections and\n any permissions granted to Termux. Codewhale's bubblewrap\n child-process sandbox is Linux-only and is not built on Android, so approved\n commands receive no Codewhale-specific filesystem narrowing.\n- The Termux build has no supported Android Keystore or desktop Secret Service\n integration. Use `codewhale auth status` to confirm the active source and\n prefer provider environment variables when file-backed plaintext storage is\n not acceptable.\n- Terminal rendering varies by Android terminal app. The TUI always owns the\n alternate screen. If a terminal app cannot render the full-screen TUI,\n use `codewhale exec` for headless runs instead.\n\n### China / mirror-friendly install\n\nWhen installing from mainland China, configure mirrors for both **rustup**\n(the Rust toolchain installer) and **Cargo** (the package registry) to avoid\nTLS timeouts and download failures.\n\n**Step 1: Install Rust via a rustup mirror**\n\n```bash\n# PowerShell\n[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12\n(New-Object Net.WebClient).DownloadFile('https://win.rustup.rs/x86_64', 'rustup-init.exe')\n\n# git-bash / msys2\nexport RUSTUP_DIST_SERVER=https://mirrors.tuna.tsinghua.edu.cn/rustup\nexport RUSTUP_UPDATE_ROOT=https://mirrors.tuna.tsinghua.edu.cn/rustup/rustup\n./rustup-init.exe -y --default-toolchain stable\n\n# Linux / macOS\nexport RUSTUP_DIST_SERVER=https://mirrors.tuna.tsinghua.edu.cn/rustup\nexport RUSTUP_UPDATE_ROOT=https://mirrors.tuna.tsinghua.edu.cn/rustup/rustup\ncurl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable\n```\n\nIf the TUNA mirror is slow from your network, `rsproxy.cn` is another\nrustup mirror option for Linux/macOS:\n\n```bash\nexport RUSTUP_DIST_SERVER=https://rsproxy.cn\nexport RUSTUP_UPDATE_ROOT=https://rsproxy.cn/rustup\ncurl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable\n```\n\nThe `RUSTUP_DIST_SERVER` and `RUSTUP_UPDATE_ROOT` environment variables must\nbe set **before** running rustup-init; the toolchain download otherwise hits\nthe same TLS handshake problem as the installer.\n\n**Step 2: Configure Cargo registry mirror**\n\n```toml\n# ~/.cargo/config.toml\n[source.crates-io]\nreplace-with = \"tuna\"\n\n[source.tuna]\nregistry = \"sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/\"\n```\n\n`rsproxy`, Tencent COS, and Aliyun OSS mirrors work the same way; pick whichever\nis fastest from your network.\n\n### Omarchy / AUR\n\nOn Omarchy, install the prebuilt AUR package:\n\n```bash\nomarchy pkg aur add codewhale-bin\ncodewhale --version\n```\n\n`codewhale-bin` packages the same checksum-pinned Linux release archives as the\nother binary install paths and provides both `codewhale` and `codew`. It does\nnot carry a separate Codewhale version; the existing `codewhale-tui`\ncompatibility command remains an alias to the same runtime. Package updates\narrive through `omarchy update`; the in-app updater leaves the pacman-owned\nbinary to Omarchy.\n\nThe AUR update follows the matching Codewhale tag and release assets, so it may\nappear after the GitHub release while its generated `PKGBUILD` and `.SRCINFO`\nare validated. Release-maintainer instructions live in\n[`packaging/aur/README.md`](../packaging/aur/README.md).\n\n---\n\n### Windows\n\n#### Windows Scoop\n\nThe `codewhale` package is listed in Scoop's main bucket:\n\n```powershell\nscoop update\nscoop install codewhale\ncodewhale --version\n```\n\nScoop manifests are maintained outside this repository's release workflow and\ncan lag GitHub/npm/Cargo releases. Use npm or manual GitHub release downloads\nwhen you need the newest version immediately.\n\n#### Windows winget\n\nThe published winget package is **`HunterBown.CodeWhale`** (verified in\n[microsoft/winget-pkgs](https://github.com/microsoft/winget-pkgs/tree/master/manifests/h/HunterBown/CodeWhale)\non 2026-10-04; latest published version 0.10.0). It is a portable x64 package:\nwinget downloads `codewhale-tui-windows-x64.exe`, installs it as the `codewhale`\ncommand, and pulls in the Microsoft Visual C++ 2015+ x64 runtime.\n\n```powershell\nwinget install HunterBown.CodeWhale\ncodewhale --version\n```\n\nUpdate with `winget upgrade HunterBown.CodeWhale` or `codewhale update`. Each\nversion goes through winget-pkgs review after the GitHub Release, so winget can\nlag GitHub/npm/Cargo; use npm or the GitHub Release asset when you need the\nnewest version immediately.\n\nKnown limits of the winget route:\n\n* **x64 only.** The published manifest has no ARM64 installer. On Windows ARM64,\n use `npm install -g codewhale` under native ARM64 Node.js, or download\n `codewhale-windows-arm64.zip` from GitHub Releases.\n* **`codewhale` only.** The short `codew` alias is not installed by winget; use\n `codewhale`.\n* The manifest in this repository (`packaging/winget/`) is not the one winget\n serves; see [`packaging/winget/README.md`](../packaging/winget/README.md).\n\n#### Windows NSIS Installer\n\nA standalone NSIS-based installer is available starting with v0.8.50 for\nWindows users who prefer a traditional double-click setup (no npm, no Scoop, no\nCargo required).\n\nThe NSIS installer currently contains the Windows x64 binaries. Windows ARM64\nusers should install through npm running under native ARM64 Node.js or download\n`codewhale-windows-arm64.zip` from the same release; both paths then use native\nARM64 binaries.\n\n**Download** `CodeWhaleSetup.exe` from the\n[Releases page](https://github.com/codewhale-hq/CodeWhale/releases/latest).\n\n**Install** by double-clicking the setup executable. The installer:\n\n- Installs `codewhale.exe` and `codew.exe` side-by-side (single binary, no `codewhale-tui.exe`) into\n `%LOCALAPPDATA%\\Programs\\CodeWhale\\bin`\n- Installs `codewhale.bat`, which prefers Windows Terminal (`wt.exe`) when it is on `PATH` and\n otherwise launches the exe directly\n- Creates a current-user Start Menu shortcut that opens that launcher, not the raw `.exe`\n- Adds the install directory to the **current user** `PATH`\n- Registers in Windows **Apps & Features** for easy uninstall\n\nUninstall removes the binaries, `codewhale.bat`, the Start Menu shortcut, and the user `PATH` entry.\n\n**Silent install** (for IT admins, SCCM, Intune):\n\n```powershell\nCodeWhaleSetup.exe /S\n```\n\nThe installer is per-user and does not request elevation. Run silent installs in\nthe target user's context, or use a deployment tool that can run the installer\nfor each user profile that needs Codewhale.\n\nThe release-built installer is currently unsigned and may trigger Windows\nSmartScreen. Verify the SHA-256 checksum from `codewhale-artifacts-sha256.txt`\nbefore deploying, and sign the installer in your internal deployment pipeline if\nyour environment requires signed application packages.\n\n**Build the installer yourself** (requires [NSIS](https://nsis.sourceforge.io)):\n\n```powershell\ncd scripts\\installer\n# Place codewhale.exe and codew.exe here (single binary, no codewhale-tui.exe), then:\nmakensis /DVERSION= codewhale.nsi\n```\n\n**Manual fallback** — if the installer is blocked by group policy, see the\n[CLASSROOM_INSTALL.md](CLASSROOM_INSTALL.md) guide for step-by-step PowerShell\ncommands.\n\n> **Deploying to a classroom or lab?** See the full\n> [Classroom Install Checklist](CLASSROOM_INSTALL.md) for silent install,\n> API key provisioning, imaging notes, and troubleshooting.\n\n\n\n### FreeBSD, cross-compiling, Windows source builds\n\n#### FreeBSD 14+ source-build workaround (#1097)\n\nFreeBSD has no prebuilt GitHub Release asset — `npm install -g codewhale` intentionally\nfails with `Unsupported platform: freebsd` and points to Cargo. Install from source:\n\n```bash\npkg install -y rust pkgconf git\ncargo install codewhale-cli --locked # installs `codewhale`\ncodewhale --version\ncodewhale doctor\n```\n\nThe `rquickjs` FreeBSD bindings are generated at build time via `bindgen` (see\n`1582ba965`/`5eb0385e8`). No separate `pkg install codewhale` port exists yet —\na native port is tracked as the follow-up to #1097 under `packaging/freebsd/`\n(contributions welcome). Validate with `cargo check --target x86_64-unknown-freebsd -p codewhale-cli --locked`\non the release branch; the 7×1 release matrix (Linux musl x64/arm64,\nAndroid arm64, macOS x64/arm64, Windows x64/arm64) stays 7 targets — FreeBSD is a\nsource-build target, not a prebuilt asset.\n\n#### Cross-compiling from x64 to ARM64 Linux\n\nThe release asset uses `aarch64-unknown-linux-musl` and is built on a native ARM\nrunner. If you want to build a GNU-linked ARM64 Linux binary on an x64 Linux\nhost (e.g. for a HarmonyOS / openEuler ARM64 thin-and-light), use\n[`cross`](https://github.com/cross-rs/cross), which wraps the official Rust\ncross-targets in a Docker container:\n\n```bash\n# Once\nrustup target add aarch64-unknown-linux-gnu\ncargo install cross --locked\n\n# Per build\ncross build --release --target aarch64-unknown-linux-gnu -p codewhale-cli # single binary\n```\n\nThe resulting binary lands in\n`target/aarch64-unknown-linux-gnu/release/codewhale`. Copy it to the ARM64 host\n(e.g. via `scp`) and make it executable. This local GNU build is distinct from\nthe portable musl release asset; either executable can be copied under the\n`codew` convenience name.\n\nIf you don't have Docker available, install the cross-linker directly and let\nCargo do the work:\n\n```bash\nsudo apt-get install -y gcc-aarch64-linux-gnu\nrustup target add aarch64-unknown-linux-gnu\n\ncat >> ~/.cargo/config.toml <<'EOF'\n[target.aarch64-unknown-linux-gnu]\nlinker = \"aarch64-linux-gnu-gcc\"\nEOF\n\ncargo build --release --target aarch64-unknown-linux-gnu -p codewhale-cli # single binary\n```\n\nProducing `aarch64-unknown-linux-musl` while cross-compiling requires an\nappropriate musl cross-linker. The release workflow avoids that extra moving\npart by building and launching the musl binary on GitHub's native ARM runner.\n\n#### Windows build from source\n\nBuilding on Windows requires the **MSVC C toolchain** from\n[Visual Studio Build Tools](https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022)\n(the free workload-selectable installer, not the full IDE).\n\n**Prerequisites (Windows)**\n\n1. Install Visual Studio 2022 Build Tools — select the **\"Desktop development\n with C++\"** workload.\n2. Install [Rust](https://rustup.rs) 1.89+ (see the\n [China mirror instructions](#china--mirror-friendly-install) above if\n downloading from mainland China).\n3. Install [Git for Windows](https://git-scm.com/download/win) (provides `git`\n and the `git-bash` terminal).\n\n**Recommended terminals**: Windows Terminal, `git-bash`, or PowerShell.\n`cmd.exe` works but has a small buffer and limited PATH behavior.\n\n**Setting up the MSVC environment**\n\nVisual Studio Build Tools install `cl.exe` to a versioned directory but do\n**not** add it to `PATH` globally. You must set the environment manually or\nuse a Developer Command Prompt. The required variables are:\n\n```powershell\n# Adjust version numbers to match your installation\n$msvc = \"C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\VC\\Tools\\MSVC\\14.44.35207\"\n$sdk = \"C:\\Program Files (x86)\\Windows Kits\\10\"\n$sdkv = \"10.0.26100.0\"\n\n$env:INCLUDE = \"$msvc\\include;$msvc\\atlmfc\\include;$sdk\\Include\\$sdkv\\ucrt;$sdk\\Include\\$sdkv\\um;$sdk\\Include\\$sdkv\\shared\"\n$env:LIB = \"$msvc\\lib\\x64;$msvc\\atlmfc\\lib\\x64;$sdk\\Lib\\$sdkv\\ucrt\\x64;$sdk\\Lib\\$sdkv\\um\\x64\"\n$env:LIBPATH = \"$msvc\\lib\\x64;$msvc\\atlmfc\\lib\\x64\"\n$env:CC = \"$msvc\\bin\\Hostx64\\x64\\cl.exe\"\n$env:CXX = \"$msvc\\bin\\Hostx64\\x64\\cl.exe\"\n$env:PATH = \"$msvc\\bin\\Hostx64\\x64;$env:PATH\"\n```\n\nAlternatively, open a **\"Developer Command Prompt for VS 2022\"** (available\nfrom the Start Menu after installing Build Tools), which runs `vcvars64.bat`\nto configure all of the above automatically. Then add `cargo` to `PATH` inside\nthat session and run `cargo build` from the project root.\n\n**Cargo registry mirror** — on Windows the mirror config goes to\n`%USERPROFILE%\\.cargo\\config.toml`. See [Step 2 above](#china--mirror-friendly-install).\n\n**Build**\n\n```bash\ngit clone https://github.com/codewhale-hq/CodeWhale.git\ncd CodeWhale\nset CARGO_HTTP_CHECK_REVOKE=false # may be needed behind some Chinese ISPs\ncargo build --release\n```\n\nThe Cargo-built binary appears at `target\\release\\codewhale.exe`. Release\npackaging separately exposes the same executable as `codew.exe`.\n\n> Prefer not to build? Install via npm, Cargo, GitHub Releases, or the CNB\n> mirror — see the sections above.\n\n### Older-release and regional troubleshooting\n\n#### `Unsupported architecture: arm64 on platform linux`\n\nYou're on a release earlier than v0.8.8 that doesn't publish Linux ARM64\nbinaries. Use the GitHub installer in a fresh directory as described above, or use\n`cargo install` per [Section 4](#5-cargo-and-building-from-source).\n\n#### `MISSING_COMPANION_BINARY` after upgrading an older install\n\nThe current single binary runs the TUI in-process and does not require a\ncompanion executable. This error identifies a stale pre-v0.9.5 dispatcher.\nUse the fresh-directory GitHub migration above, then verify the selected\n`codewhale` and `codew` paths. Do not download another separate runtime.\n\n#### `codewhale update` reports `no asset found for platform codewhale-linux-aarch64`\n\nOlder updaters used Rust architecture names that did not match the published\nasset names. Use the official installer in a fresh directory as described above,\nthen run the newly installed command by its full path.\n\n#### npm download is slow or times out from mainland China\n\nOn Linux x64 the npm wrapper already probes GitHub Releases and the CNB\nfirst-party checksum manifests in parallel and downloads binaries only from\nthe first source that validates. You do not need `CODEWHALE_USE_CNB_MIRROR=1`\nfor that automatic path.\n\nIf both first-party sources fail, set `CODEWHALE_RELEASE_BASE_URL` to a\nmirrored release-asset directory (rsproxy, TUNA, Tencent COS, Aliyun OSS),\nor skip npm entirely and use the Cargo mirror setup in\n[Section 4](#5-cargo-and-building-from-source). The legacy\n`DEEPSEEK_TUI_RELEASE_BASE_URL` name is still accepted. `CODEWHALE_USE_CNB_MIRROR=1`\nstill forces CNB only on Linux x64 / OpenHarmony x64.\n\n#### `codewhale update` is blocked by GitHub from mainland China\n\n`codewhale update` prefers GitHub Releases. On supported Linux x64 targets,\na failed GitHub manifest permits the matching CNB manifest and binary fallback.\nIf GitHub metadata is also unreachable, explicitly select a known published CNB\nversion (`CODEWHALE_USE_CNB_MIRROR=1 CODEWHALE_VERSION=X.Y.Z codewhale update`)\nor a binary mirror below. Existing newer builds are kept.\n\nBuilding from the CNB source mirror with Cargo is a secondary option. Cargo\ninstalls its own `codewhale` command:\n\nTo check the latest release without downloading or replacing binaries, run\n`codewhale update --check`.\n\n```bash\ncargo install --git https://cnb.cool/codewhale.net/codewhale --tag vX.Y.Z codewhale-cli --locked --force # single binary\n```\n\nIf you operate a binary asset mirror, `codewhale update` can use it directly:\n\n```bash\nCODEWHALE_RELEASE_BASE_URL=https://your-mirror.example.com/CodeWhale/vX.Y.Z/ \\\nCODEWHALE_VERSION=X.Y.Z \\\ncodewhale update\n```\n\nThe mirror directory must contain `codewhale-artifacts-sha256.txt` and the\nplatform binaries from the GitHub release. The legacy\n`DEEPSEEK_TUI_RELEASE_BASE_URL` mirror variable remains supported as an alias.\n\n`codewhale update` only talks HTTPS, and only to GitHub's release hosts, the CNB\nmirror, and the host of the `CODEWHALE_RELEASE_BASE_URL` you set; every\nredirect hop is held to the same rule, so a plain-`http://` mirror is refused.\nA private mirror therefore works as soon as its base URL is HTTPS. If that\nmirror redirects asset downloads to a separate download host (a CDN or an\nobject-store domain), name that host too:\n\n```bash\nCODEWHALE_UPDATE_ALLOWED_HOSTS=cdn.your-mirror.example.com,objects.example.net \\\nCODEWHALE_RELEASE_BASE_URL=https://your-mirror.example.com/CodeWhale/vX.Y.Z/ \\\nCODEWHALE_VERSION=X.Y.Z \\\ncodewhale update\n```\n\nThe error message for a refused host names it and this variable.\n\n### Windows and npm-download troubleshooting\n\n#### Windows: `TLS handshake eof` or `CRYPT_E_REVOCATION_OFFLINE` from `rustup-init`\n\nThe TLS handshake to `static.rust-lang.org` fails from behind the GFW or\ncertain Chinese ISPs. Set the rustup mirror environment variables **before**\nrunning the installer:\n\n```bash\n# git-bash / msys2\nexport RUSTUP_DIST_SERVER=https://mirrors.tuna.tsinghua.edu.cn/rustup\nexport RUSTUP_UPDATE_ROOT=https://mirrors.tuna.tsinghua.edu.cn/rustup/rustup\n./rustup-init.exe -y --default-toolchain stable\n```\n\nIf you see `CRYPT_E_REVOCATION_OFFLINE` from Cargo after Rust is installed,\nalso set `CARGO_HTTP_CHECK_REVOKE=false` during `cargo build`.\n\n#### Windows: MSVC compiler (`cl.exe`) not found during `cargo build`\n\nVisual Studio Build Tools do not add `cl.exe` to the global `PATH`. Either:\n\n1. Open **\"Developer Command Prompt for VS 2022\"** from the Start Menu, add\n `%USERPROFILE%\\.cargo\\bin` to `PATH` in that window, and run `cargo build`\n from there; or\n2. Set the MSVC environment variables manually — see the\n [Windows build from source](#windows-build-from-source) section for the\n PowerShell snippet.\n\nVerify the compiler is reachable: `cl.exe /?` should print help text.\n\n#### Windows: `拒绝访问 (os error 5)` when Cargo executes build scripts\n\nThird-party antivirus software (Huorong, 360, Kaspersky, etc.) may block\nCargo from executing freshly-compiled build-script binaries\n(e.g. `libsqlite3-sys`, `aws-lc-sys`, `instability`). The error is\npath-agnostic — moving `target-dir` does not help.\n\n**Symptoms**: `could not execute process ... build-script-build (never executed)`\n\n**Workarounds** (pick one):\n\n1. **Add the project's `target/` directory to your AV exclusions list.**\n2. **Close the antivirus software temporarily** during `cargo build`.\n3. **Use the GitHub Release installer/archive instead** — the release assets\n ship prebuilt binaries and skip the Cargo build entirely\n ([Section 6](#3-manual-download-from-github-releases)).\n4. **Use `cargo install codewhale-cli --locked`** from crates.io — this\n changes the binary path, which some AV tools treat differently.\n\nTo verify that the build-script binary itself is valid (not corrupted), locate\nit under `target/debug/build//build-script-build` and run it manually:\n\n```bash\ntarget/debug/build/libsqlite3-sys-*/build-script-build\n# If this runs but panics with \"NotPresent\" (no C compiler), the binary is\n# fine — the AV is blocking Cargo's process-spawning path specifically.\n```\n\n#### npm binary download times out\n\nIf `codewhale` waits several seconds and prints `connect ETIMEDOUT` or\n`EAI_AGAIN` while fetching from `github.com`, the npm wrapper installed\nsuccessfully but the prebuilt binary download is blocked or unreliable on\nyour network. This download is separate from the npm registry package\ndownload. On Linux x64 the wrapper first races the small GitHub and CNB\nchecksum manifests and does not wait for a full GitHub binary to time out\nbefore using a valid CNB manifest.\n\nUse one of these paths:\n\n1. Set a proxy and retry:\n\n ```bash\n export HTTPS_PROXY=http://your-proxy:port\n codewhale\n ```\n\n2. Mirror the release assets internally and set `CODEWHALE_RELEASE_BASE_URL`:\n\n ```bash\n export CODEWHALE_RELEASE_BASE_URL=https://your-mirror.example.com/CodeWhale/\n codewhale\n ```\n\n The directory must contain `codewhale-artifacts-sha256.txt` and the platform\n binaries from the GitHub release.\n\n3. Install via Cargo, which builds locally and does not download GitHub release\n assets. See [Section 4](#5-cargo-and-building-from-source).\n\n4. Download both matching `codewhale` and `codew`\n binaries from the [Releases page](https://github.com/codewhale-hq/CodeWhale/releases),\n place them in a directory on `PATH`, and make them executable. See\n [Section 6](#3-manual-download-from-github-releases).\n" + "sha256": "9a4d8df8666044abe49a6403ea6e38da1fa99555014e01f87cd0a8778896ecf0", + "content": "# Installing Codewhale\n\n> 阅读简体中文版:[zh_hans/INSTALL.md](zh_hans/INSTALL.md)\n\nCodewhale is an open-source coding agent that runs in your terminal. You give\nit a task (\"fix the failing test\", \"add a CLI flag\"). It reads your repository,\nedits files and runs commands. In the default **Ask** posture it applies file\nedits inside the workspace immediately (and shows you the diff), but asks before\nrunning shell commands, so commit or stash anything you care about first. It\nworks with many model providers. **DeepSeek** is the default.\n\nThe command is `codewhale`. `codew` is a shorter alias for the same program.\n\nThe installation paths were first checked with **v0.10.0** (released 2026-09-22) on a\nfresh **Ubuntu 24.04 x86_64** machine. The dated\n[install receipts](https://github.com/codewhale-hq/Codewhale/blob/37ecdfcc49bc68a9b0d058b97c3946e62c34bd31/docs/install-report/v0.10.0-2026-09-23/RECEIPTS.md) record the commands and output checked at that time. Steps that\ncould not be run on that machine are marked **(untested on this VM: reason)**.\nmacOS, Windows and Android are out of scope, apart from a few notes. A second\npass re-ran the installer, manual-download, archive and npm paths, the no-key\nchecks and zsh completion on **macOS 26.1 (Apple silicon)**; see\n[macOS notes](#macos-notes). Steps that need a model call were not re-run\nthere.\n\nInstall commands that use `latest` resolve to the latest **published** GitHub\nRelease or package. Between releases, `main` may already describe the next\nversion (for example a v0.10.2 source candidate before its tag). A\nprebuilt candidate is available through the official installer only after its\ntag, checksums and release assets exist. Contributors can build `main` now.\n\n## One account for your provider keys\n\n[Register](https://app.codewhale.net/register) or [sign in](https://app.codewhale.net/login)\nto keep your model providers' API keys in one place. Add, replace or remove\nkeys in your account's [Providers settings](https://app.codewhale.net/providers),\nor use the terminal commands below. On another device, sign in and select the\nCodewhale model route to use the same account keys without pasting them again.\n\n```bash\ncodewhale login\ncodewhale account keys set deepseek # asks for the key without echoing it\ncodewhale account keys list # shows status, never the key itself\ncodewhale --provider codewhale # use your account's model route\n```\n\nThe account setup here describes the 0.10.1 source candidate. Signing in does\nnot upload existing local keys. To copy one deliberately, use\n`codewhale account keys set deepseek --from-local`. Account keys stay on the\nservice; they are used for account-routed requests, not downloaded into each\ndevice's local key store. You can keep using local keys or a local model\nwithout an account. See [account key settings](CONFIGURATION.md#account-provider-keys)\nfor updating keys and switching between these options.\n\n## Test and contribute to current source\n\n`latest` selects the latest published release. To test unreleased repairs,\nbuild canonical GitHub `main`, or CNB `main` after verifying its commit matches.\nThe source version number alone does not establish a published package.\n\n```bash\ngit clone --branch main https://github.com/codewhale-hq/CodeWhale.git\ncd CodeWhale\ncargo build --release --locked -p codewhale-cli\ntarget/release/codewhale --version\n```\n\nSee the Cargo section for build prerequisites; use the repository's stable Rust\nselection (declared minimum 1.89). Windows uses `target/release/codewhale.exe`\nand needs MSVC. A development build is separate from a signed release package.\nFor mainland-China downloads and source validation, see [CNB mirror](CNB_MIRROR.md).\nThe [Tencent Lighthouse bootstrap](../scripts/tencent-lighthouse/README.md) is\nbilingual. An npm registry mirror serves the wrapper; native binary downloads\nstill need an available release asset source.\n\n---\n\n## 60-second quickstart (Linux or macOS)\n\n```bash\n# 1. Install. Downloads two checksum-verified binaries into ~/.local/bin (no sudo).\ncurl -fsSL https://codewhale.net/install.sh | sh\n\n# 2. Make sure ~/.local/bin is on your PATH, now and in future terminals.\necho 'export PATH=\"$HOME/.local/bin:$PATH\"' >> ~/.bashrc # zsh: use ~/.zshrc\nexport PATH=\"$HOME/.local/bin:$PATH\"\ncodewhale --version # -> codewhale 0.10.0 (1be1a703b975)\n\n# 3. Give it a DeepSeek API key (from https://platform.deepseek.com/api_keys).\ncodewhale auth set --provider deepseek # prompts for the key; nothing is echoed\ncodewhale auth status --provider deepseek # \"active source: secret store\"\n\n# 4. Run your first task inside a git repository.\ncd ~/your-project\ncodewhale\n```\n\nIn the TUI, type something concrete:\n\n```text\ncreate a Python file primes.py that prints the first 10 primes, run it, and show me the output\n```\n\nCodewhale writes the file, shows you a diff, then asks **APPROVAL: bash\npython3 primes.py – Do you want to proceed?** Press `y` to allow it once. It\nruns the command and reports the output. Press `Ctrl-D` (with an empty input\nbox) to quit. It prints the command to resume the session later.\n\n> **If nothing happens after you send your first message,** you have no key\n> configured. v0.10.0 doesn't warn you in that case. Press **F3**. If DeepSeek\n> shows `missing key`, press Enter, paste the key, then pick a model and\n> confirm.\n\n---\n\n## Contents\n\n1. [Before you start](#1-before-you-start)\n2. [Install: recommended installer](#2-recommended-installer-curl--sh)\n3. [Install: manual download from GitHub Releases](#3-manual-download-from-github-releases)\n4. [Install: npm](#4-npm)\n5. [Install: Cargo / build from source](#5-cargo-and-building-from-source)\n6. [Install: Homebrew (Linux) and Nix](#6-homebrew-on-linux-and-nix)\n7. [Updating and rolling back](#7-updating-and-rolling-back)\n8. [API keys and providers](#8-api-keys-and-providers)\n9. [Shell completions](#9-shell-completions)\n10. [Running it: TUI, headless, resume](#10-running-it)\n11. [Terminal notes (Ghostty and others)](#11-terminal-notes)\n12. [Uninstalling and what Codewhale leaves behind](#12-uninstalling)\n13. [Troubleshooting](#13-troubleshooting)\n14. [Appendix: other platforms (not re-tested in this revision)](#appendix-other-platforms-not-re-tested-in-this-revision)\n\n---\n\n## 1. Before you start\n\n| You need | Why |\n|---|---|\n| Linux x86_64 or arm64, or macOS | Prebuilt binaries exist for these. The Linux binaries are **static** (musl), so they have no glibc or libdbus dependency and run on any distro. |\n| `curl` (or `wget`) and `sha256sum` (or `shasum`) | The installer uses them to download and verify. |\n| A model provider key, e.g. [DeepSeek](https://platform.deepseek.com/api_keys) | Codewhale does nothing useful without a model. |\n| `git` (recommended) | Codewhale works best inside a git repository. |\n| Optional: Python 3, Node.js 20+ | If present, Codewhale enables its Python and JS execution tools (`codewhale doctor` lists them). |\n\n**Which install path?**\n\n* **Most people:** the [recommended installer](#2-recommended-installer-curl--sh).\n It's the fastest (about 6 s here), verifies checksums, and supports\n `codewhale update`.\n* **Air-gapped or security-reviewed machines:**\n [manual download](#3-manual-download-from-github-releases).\n* **You already manage CLI tools with npm:** [npm](#4-npm).\n* **No prebuilt binary for your platform, or you want to build it yourself:**\n [Cargo](#5-cargo-and-building-from-source).\n\nPick **one**. Several installs on one machine end up fighting over PATH (see\n[Troubleshooting](#13-troubleshooting)).\n\n**Privacy note:** Codewhale sends aggregate usage counts (PostHog) **by\ndefault**. To turn this off permanently:\n`codewhale config set telemetry false`, or export `CODEWHALE_TELEMETRY=0`,\nwhich always wins. The TUI also checks GitHub for updates at startup\n(`[update] check_for_updates` in `~/.codewhale/config.toml`).\n\n---\n\n\n\n## 2. Recommended installer (`curl | sh`)\n\n**Prerequisites:** curl, `sha256sum` (Linux) or the built-in `shasum` (macOS),\na writable home directory. No sudo, no Node, no Rust.\n\n```bash\ncurl -fsSL https://codewhale.net/install.sh | sh\n```\n\nWhat it does (verified):\n\n* It detects your platform (`linux-x64`, `linux-arm64`, `macos-x64`,\n `macos-arm64`). It refuses Android/Termux and riscv64 with a clear message.\n* It downloads `codewhale-`, `codew-` and\n `codewhale-artifacts-sha256.txt` from the latest GitHub Release, and verifies\n both binaries against the manifest. If either doesn't match, it stops before\n installing anything (`codewhale install: checksum mismatch for …`).\n* It installs `~/.local/bin/codewhale` and `~/.local/bin/codew`: two identical\n 78 MB files.\n* It **never uses sudo and never edits your shell profile.** It refuses to\n install into system or package-manager directories (`/usr/bin`,\n `~/.cargo/bin`, Homebrew, `node_modules`, `/nix/store`…) and refuses to\n overwrite a *different* existing `codewhale`.\n\nExpected output:\n\n```\nInstalling Codewhale for linux-x64\nRelease assets: https://github.com/codewhale-hq/CodeWhale/releases/latest/download\nInstall dir: /home/you/.local/bin\nChecksums verified\nInstalled checksummed release commands:\n /home/you/.local/bin/codewhale\n /home/you/.local/bin/codew\n…\nPATH selects no codewhale command; this install is /home/you/.local/bin/codewhale\n…\nPut /home/you/.local/bin first on PATH in future shells (run once; this installer does not edit shell profiles):\n echo 'export PATH=\"$HOME/.local/bin:$PATH\"' >> ~/.bashrc\nThen run: . ~/.bashrc (or open a new terminal)\n…\n```\n\n### macOS notes\n\nRe-checked on macOS 26.1, Apple silicon (`macos-arm64`), with a fresh `HOME`:\n\n* The installer printed `Installing Codewhale for macos-arm64`, verified\n checksums with the system tools, and installed `codewhale` and `codew`\n (64 MiB each, Mach-O arm64) in 4.3 s. Both report\n `codewhale 0.10.0 (1be1a703b975)`. They ran without a Gatekeeper prompt.\n* When Node isn't on `PATH`, it also prints `Computer Use is included and needs\n Node.js 20 or newer on PATH.` The core TUI works without Node, but Computer Use\n and the JavaScript execution tool (`js_execution`) stay unavailable until Node\n is on `PATH`.\n* `codewhale doctor` behaves as on Linux (exit 0, `All checks complete!` with no\n key, file-based secret store under `~/.codewhale/secrets/`), except that it\n reports `✓ sandbox available: macos-seatbelt`.\n\n### Put it on your PATH\n\nIf the installer says `PATH selects no codewhale command`, `~/.local/bin` isn't\non your PATH **in this shell**. The `codewhale.net/install.sh` installer then\nprints the matching line from the block below for your `$SHELL` (zsh, bash,\nfish, or a POSIX `sh`; for any other shell, or a directory name with quotes,\n`$`, backticks or backslashes, it tells you to add the directory yourself). It\nnever edits a shell profile itself. The `install.sh` inside a release archive\nprints only the current-shell `export` line. On Ubuntu and Debian, `~/.profile` adds\n`~/.local/bin`, but only if the directory existed when you *logged in*. So:\n\n* a new SSH or login shell picks it up automatically;\n* a new terminal **window** on a desktop (GNOME Terminal, Ghostty, …) usually\n doesn't, until you log out and back in. I hit\n `bash: codewhale: command not found` in Ghostty right after installing.\n\nFix it once:\n\n```bash\necho 'export PATH=\"$HOME/.local/bin:$PATH\"' >> ~/.bashrc # bash (macOS login bash: ~/.bash_profile, or ~/.profile if only that exists)\n# echo 'export PATH=\"$HOME/.local/bin:$PATH\"' >> ~/.zshrc # zsh\n# fish_add_path ~/.local/bin # fish (untested on this VM)\nexport PATH=\"$HOME/.local/bin:$PATH\"; hash -r\ncommand -v codewhale codew\n```\n\n### Options\n\n```bash\n# Choose the directory (must be absolute; created if missing)\ncurl -fsSL https://codewhale.net/install.sh | CODEWHALE_INSTALL_DIR=\"$HOME/.local/codewhale/bin\" sh\n# Install a specific release\ncurl -fsSL https://codewhale.net/install.sh | CODEWHALE_VERSION=v0.9.13 sh\n# Show help\ncurl -fsSL https://codewhale.net/install.sh | sh -s -- --help\n```\n\n### Verify\n\n```bash\ncodewhale --version # codewhale 0.10.0 (1be1a703b975)\ncodew --version # same\ncodewhale doctor # diagnostics; see the note in §8 about what it does NOT check\n```\n\n### Re-running the installer\n\n* Same version already installed: harmless. It prints\n `Already installed: …` and exits 0.\n* Different version already installed: it **refuses**\n (`codewhale install: refusing to replace existing …/codewhale`), exits 1 and\n changes nothing. It downloads ~160 MB before refusing. Use\n [`codewhale update`](#7-updating-and-rolling-back) instead.\n\n### Upgrade / uninstall\n\n* Upgrade: `codewhale update` (see §7).\n* Uninstall: `rm ~/.local/bin/codewhale ~/.local/bin/codew`, then see §12 for\n data.\n\n---\n\n## 3. Manual download from GitHub Releases\n\nUse this when you want to see and verify every byte yourself. Releases:\n. Each platform has **bare\nbinaries** (`codewhale-linux-x64`, `codew-linux-x64`, …) and an **archive**\n(`codewhale-linux-x64.tar.gz`) that holds the same two binaries plus an\n`install.sh`.\n\n### 3a. Bare binaries\n\n```bash\nmkdir -p ~/codewhale-dl && cd ~/codewhale-dl\nbase=https://github.com/codewhale-hq/CodeWhale/releases/latest/download\ncurl -fsSLO \"$base/codewhale-linux-x64\" # use linux-arm64 on ARM\ncurl -fsSLO \"$base/codew-linux-x64\"\ncurl -fsSLO \"$base/codewhale-artifacts-sha256.txt\"\nsha256sum -c codewhale-artifacts-sha256.txt --ignore-missing\n# codew-linux-x64: OK\n# codewhale-linux-x64: OK\nmkdir -p ~/.local/bin\ninstall -m 755 codewhale-linux-x64 ~/.local/bin/codewhale\ninstall -m 755 codew-linux-x64 ~/.local/bin/codew\n```\n\nThen [put `~/.local/bin` on PATH](#put-it-on-your-path) and run\n`codewhale --version`. On macOS the assets are `codewhale-macos-arm64` and\n`codew-macos-arm64` (`-macos-x64` on Intel), and the built-in `shasum` verifies\nthem (tested on macOS 26.1, Apple silicon):\n\n```bash\n/usr/bin/shasum -a 256 -c codewhale-artifacts-sha256.txt --ignore-missing\n# codew-macos-arm64: OK\n# codewhale-macos-arm64: OK\n```\n\nThe `codewhale-macos-arm64.tar.gz` archive verifies the same way against\n`codewhale-bundles-sha256.txt`, and its `./install.sh` installs into\n`~/.local/bin` (tested).\n\nTo pin a release, replace `latest/download` with `download/vX.Y.Z`, and take\nthe manifest from the same tag.\n\n### 3b. Archive\n\n```bash\ncd \"$(mktemp -d)\"\nbase=https://github.com/codewhale-hq/CodeWhale/releases/latest/download\ncurl -fsSLO \"$base/codewhale-linux-x64.tar.gz\"\ncurl -fsSLO \"$base/codewhale-bundles-sha256.txt\" # note: *bundles*, not *artifacts*\nsha256sum -c codewhale-bundles-sha256.txt --ignore-missing\n# codewhale-linux-x64.tar.gz: OK\ntar -xzf codewhale-linux-x64.tar.gz\ncd codewhale-linux-x64 && ./install.sh # -> ~/.local/bin; PREFIX=/some/dir ./install.sh -> /some/dir/bin\n```\n\nThe archive's `install.sh` behaves like the website installer: no sudo, it\nleaves differing existing files alone, and it prints the same PATH hint.\n\n**Upgrade:** `codewhale update` works for both 3a and 3b, because they're\n\"direct binary\" installs. **Uninstall:** delete the two files (see §12).\n\n---\n\n## 4. npm\n\n**Prerequisites:** Node.js 18+ and npm, with a **global prefix you can write\nto**. npm installs the registry's latest published version, never an\nunpublished source candidate.\n\n```bash\nnpm install -g codewhale\ncodewhale --version\n```\n\nThe package is a small wrapper. Its `postinstall` step downloads the same\n`codewhale`/`codew` release binaries, checks them against the release's SHA-256\nmanifest, and links `codewhale` and `codew` into npm's global `bin`. The whole\nthing took 6 s here.\n\n**Windows npm sessions:** Node remains the native program's launcher for the\nwhole session. A process-name kill such as `taskkill /IM node.exe` or\n`Get-Process node | Stop-Process -Force` can interrupt this and other npm\nCodewhale sessions and prevent normal terminal cleanup. Stop only the server\nPID you started or the process owning its port, or use Codewhale's task\ncancellation. The Windows\nnative archive/installer avoids this npm-parent dependency; this does not\nremove Node requirements for optional JavaScript tools. Codewhale's Windows\nshell safety floor holds recognized image-wide Node kills even in Full Access.\nAn external hard kill or an arbitrary program that terminates the launcher\ncannot be made graceful by this shell-command check.\n\n### If you get `EACCES: permission denied`\n\nThat means Node is installed system-wide (apt, `/usr/local`, `/opt`), and your\nuser can't write to its global prefix:\n\n```\nnpm error code EACCES\nnpm error Error: EACCES: permission denied, mkdir '/opt/node22/lib/node_modules/codewhale'\n```\n\n**Don't use `sudo npm`.** Either use a per-user Node (nvm, fnm, volta), or\npoint npm at a directory you own. I tested the second option:\n\n```bash\nnpm config set prefix \"$HOME/.npm-global\"\necho 'export PATH=\"$HOME/.npm-global/bin:$PATH\"' >> ~/.bashrc\nexport PATH=\"$HOME/.npm-global/bin:$PATH\"\nnpm install -g codewhale\ncommand -v codewhale codew # ~/.npm-global/bin/codewhale, ~/.npm-global/bin/codew\n```\n\n### Notes\n\n* npm hides the download progress. Add `--foreground-scripts` to see it\n (`codewhale: selected GitHub Releases for v0.10.0 … done.`). The chosen\n source is also written to\n `$(npm prefix -g)/lib/node_modules/codewhale/bin/downloads/codewhale.source`.\n* The package uses 157 MB on disk.\n* On macOS 26.1 (Apple silicon, Homebrew Node 25) an install into a user-owned\n prefix (`npm install -g --prefix codewhale`) took 3 s and linked\n `codewhale` and `codew`, both `codewhale 0.10.0 (1be1a703b975)`.\n* **Upgrade:** `npm install -g codewhale@latest`. `codewhale update` refuses\n on npm installs. It prints migration instructions and exits 1 with\n `error: The package-managed executable was not changed.`\n* **Specific version:** `npm install -g codewhale@0.9.13`.\n* **Uninstall:** `npm uninstall -g codewhale`. This removes only the program,\n not your data (§12).\n\n---\n\n\n\n## 5. Cargo and building from source\n\nUse this if there's no prebuilt binary for your platform, or you want to\ncompile it yourself. One Cargo package is required:\n`codewhale-cli` installs the `codewhale` command. npm and prebuilt releases also\nexpose `codew` as a convenience name for the same compiled runtime; Cargo does\nnot create that alias, so add `alias codew=codewhale` to your shell rc if you\nwant the short name.\n\n### Prerequisites (Debian/Ubuntu)\n\n```bash\nsudo apt-get install -y build-essential pkg-config libdbus-1-dev git\n# Rust via rustup (the distro's cargo is too old for this edition-2024 workspace)\ncurl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y\nsource \"$HOME/.cargo/env\"\nrustc --version # the workspace declares rust-version = 1.89\n```\n\n`libdbus-1-dev` **is required**. Without it the build fails after about a\nminute with:\n\n```\nerror: failed to run custom build command for `libdbus-sys v0.2.7`\n The system library `dbus-1` required by crate `libdbus-sys` was not found.\n```\n\nFedora/RHEL: `sudo dnf install -y gcc make pkgconf-pkg-config dbus-devel`\n**(untested on this VM: Ubuntu only)**.\n\n### 5a. From crates.io\n\n```bash\ncargo install codewhale-cli --locked\ncodewhale --version\n```\n\nTested result: **works**, with current stable Rust (1.98.1).\n* It took **25 min 31 s** on 4 vCPU and 15 GB RAM, and pulled about 470 MB\n into `~/.cargo/registry`.\n* It installs one 122 MB file, `~/.cargo/bin/codewhale`. That's a normal\n glibc-linked binary, and it needs `libdbus-1` at runtime.\n* `codewhale --version` prints `codewhale 0.10.0`, with no commit hash.\n* Headless and TUI smoke tests passed.\n\n> **v0.10.0 declared \"Rust 1.88+\", which was wrong.** The workspace now\n> declares 1.89, the version CI's MSRV job builds. With 1.88.0 the v0.10.0\n> install fails in seconds:\n> `rustc 1.88.0 is not supported by the following package: serde-saphyr@1.3.0 requires rustc 1.89`.\n> Use current stable (`rustup update stable`).\n\n### 5b. From a git checkout\n\n```bash\ngit clone --depth 1 --branch v0.10.0 https://github.com/codewhale-hq/CodeWhale.git\ncd CodeWhale\ncargo install --path crates/cli --locked # installs ~/.cargo/bin/codewhale\n```\n\nThings to know (observed):\n\n* The repo contains `rust-toolchain.toml` (`channel = \"stable\"`). The first\n `cargo` command inside the checkout **silently downloads the latest stable\n toolchain** (about 250 MB), whatever your default is.\n* The workspace treats every compiler warning as an error. With Rust 1.89\n the build **fails** after about 11 minutes with 8\n `error: this lint expectation is unfulfilled` errors in `codewhale-tui`. Use\n the stable toolchain the repo selects. Don't pass an older `+toolchain`.\n* The workspace release profile uses thin LTO. On a 4-vCPU / 15 GB VM, the\n `codewhale-tui` crate alone compiled for more than an hour, peaking at\n 6–8 GB of RAM. Budget 16 GB or more, and expect this to be the slowest install\n path by far. `target/` grew past 1.2 GB.\n\nTested result: **works** on the repo-selected stable Rust (1.98.1).\n* `Finished release profile … in 83m 12s`. A Nix build competed for CPU and\n memory for most of that time, so treat it as an upper bound.\n* `target/` ended at **3.1 GB**. Delete it afterwards with `cargo clean`.\n* The binary reports `codewhale 0.10.0 (dev)`, and `exec` worked.\n* Cargo prints `warning: default toolchain implicitly overridden with\n stable-x86_64-unknown-linux-gnu by rustup toolchain file`, which is harmless.\n* **Cargo builds provide no `codew`.**\n\n**Upgrade:** re-run the same `cargo install … --force` (for crates.io, you can\nadd `--version X.Y.Z`). `codewhale update` refuses Cargo installs.\n**Uninstall:** `cargo uninstall codewhale-cli`, then §12.\n\n---\n\n## 6. Homebrew on Linux and Nix\n\n### Homebrew on Linux: works, but not with the command the old docs gave\n\nPrerequisite: Homebrew itself. Its installer needs sudo **once**, to create\n`/home/linuxbrew/.linuxbrew`. Without sudo rights it stops with\n`Insufficient permissions to install Homebrew to \"/home/linuxbrew/.linuxbrew\"`.\nAsk an admin to run\n`sudo mkdir -p /home/linuxbrew/.linuxbrew && sudo chown $USER /home/linuxbrew/.linuxbrew`,\nthen re-run the installer. Afterwards, add\n`eval \"$(/home/linuxbrew/.linuxbrew/bin/brew shellenv bash)\"` to `~/.bashrc`,\nas its \"Next steps\" say.\n\n```bash\nbrew install Hmbown/deepseek-tui/codewhale # full name: taps and trusts in one step\n```\n\nThe two-step form (`brew tap Hmbown/deepseek-tui` then `brew install\ncodewhale`) **fails on Homebrew 7.x**:\n\n```\nError: Refusing to load formula hmbown/deepseek-tui/codewhale from untrusted tap hmbown/deepseek-tui.\nRun `brew trust --formula hmbown/deepseek-tui/codewhale` or `brew trust hmbown/deepseek-tui` to trust it.\n```\n\nRun `brew trust hmbown/deepseek-tui` first, or use the full name above.\n\nTested with Homebrew 7.0.6 against the v0.10.0 tap formula: the install took\n73 s. The tap formula tracks the latest release and downloads the official\nrelease binaries, so there's no compile. It provides **both** `codewhale` and\n`codew`. The `Hmbown/deepseek-tui` tap formula also depends on `node`, which\npulled in 31 bottles (~560 MB) on Linux. That `node` dependency belongs to\nthis tap formula only. The core TUI runs without Node; Computer Use and the JS\nexecution tool use it when it is on PATH.\n\n* **Upgrade:** `brew upgrade codewhale`. (`codewhale update` refuses, and\n suggests migrating.)\n* **Uninstall:** `brew uninstall codewhale && brew untap Hmbown/deepseek-tui`.\n This also autoremoves the tap's node dependency and anything else it pulled\n in. Homebrew's\n download cache (`~/.cache/Homebrew`, ~330 MB) stays until\n `brew cleanup --prune=all`.\n\n### Nix: partially tested\n\n```bash\n# flakes are still experimental; the tested setup enabled them once:\nmkdir -p ~/.config/nix\necho 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf\nnix run github:codewhale-hq/CodeWhale -- --version\n# one-off alternative (untested on this VM): nix --extra-experimental-features 'nix-command flakes' run github:codewhale-hq/CodeWhale -- --version\n```\n\nNix 2.35 installed fine; single-user mode needs `/nix` created by root once.\nFlakes resolved. What I learned before stopping:\n\n* There's **no binary cache**, so this is a full source build of the\n **main branch**, not the v0.10.0 release. The binary reports\n `codewhale 0.10.0 (dev)`.\n* The build step took 30 min on 4 cores. The package then runs its **test\n suite** (`doCheck`), which recompiles the workspace in test mode. That took\n over 70 minutes and more than 8 GB of RAM for one rustc process, and I\n stopped it at 105 minutes. So `nix run` completing, `nix build`, and\n `nix profile install/remove` are **(untested on this VM: build did not\n finish in the time budget; the VM's proxy also required an\n `--override-input fenix …` workaround)**.\n* Nix provides only `codewhale`; there's no `codew` (it builds just the\n `codewhale-cli` package).\n\nUnless you already live in Nix, use §2 instead.\n\n---\n\n## 7. Updating and rolling back\n\nThese work for installs from §2 and §3 (direct binaries). Package-manager\ninstalls (npm, Cargo, Homebrew) must be updated with their own tool.\n\n```bash\ncodewhale update --check\n# Current binary: /home/you/.local/bin/codewhale\n# Current version: v0.9.13\n# Latest stable release: v0.10.0\n# Update available. Run `/home/you/.local/bin/codewhale update` to install v0.10.0.\ncodewhale update\n# Downloading codewhale-linux-x64...\n# SHA256 checksum verified against codewhale-artifacts-sha256.txt from GitHub Releases.\n# ✅ Successfully updated to v0.10.0!\n# Updated binaries:\n# - /home/you/.local/bin/codewhale (codewhale-linux-x64)\n# - /home/you/.local/bin/codew (codewhale-linux-x64)\n```\n\nIt updates `codewhale` **and** `codew` together. It took 10 s here. Run it\nagain and you get `Already up to date; no download needed.` Other options:\n`--beta` and `--proxy `.\n\n\n\n### Rolling back (e.g. to v0.9.13)\n\n`codewhale update` never downgrades, and `CODEWHALE_VERSION=0.9.13 codewhale\nupdate` just says \"Already up to date\". To roll back, replace the files:\n\n```bash\ndir=\"$(dirname \"$(command -v codewhale)\")\" # the install PATH actually selects\nrm \"$dir/codewhale\" \"$dir/codew\"\ncurl -fsSL https://codewhale.net/install.sh | CODEWHALE_VERSION=v0.9.13 CODEWHALE_INSTALL_DIR=\"$dir\" sh\nhash -r; codewhale --version # codewhale 0.9.13 (a0b81f619b66)\n```\n\nTested with both the default `~/.local/bin` and a custom\n`CODEWHALE_INSTALL_DIR`. Use it only for installer, manual or archive\ninstalls. Never point it at an npm, Cargo or Homebrew directory.\n\nOr keep both versions side by side, and put the old one first on PATH:\n\n```bash\ncurl -fsSL https://codewhale.net/install.sh | CODEWHALE_VERSION=v0.9.13 CODEWHALE_INSTALL_DIR=\"$HOME/.local/codewhale-0.9.13\" sh\nexport PATH=\"$HOME/.local/codewhale-0.9.13:$PATH\"; hash -r\n```\n\nTo return to the latest after an in-place rollback, run `codewhale update`.\n(Tested: 0.9.13 → 0.10.0.)\n\nnpm: `npm install -g codewhale@0.9.13`. Cargo:\n`cargo install codewhale-cli --version 0.9.13 --locked --force` **(untested on\nthis VM: only 0.10.0 was built)**.\n\n---\n\n## 8. API keys and providers\n\n### Where Codewhale looks for a key (first match wins)\n\n1. `--api-key ` on the command line\n2. `api_key` in `~/.codewhale/config.toml`\n3. the secret store written by `codewhale auth set`\n4. the environment variable (`DEEPSEEK_API_KEY` for DeepSeek)\n\nThis order matters. **A key in config or the secret store beats\n`DEEPSEEK_API_KEY`.** If you rotate your key by exporting a new env var, an\nold stored key keeps being used. I tested this: a wrong key in `config.toml`\nplus the correct env var gives `Authentication Fails … ****beef is invalid`.\n\n### Ways to set a DeepSeek key (all tested)\n\n**Environment variable.** Good for trying it out and for CI:\n```bash\nexport DEEPSEEK_API_KEY=sk-... # add to ~/.bashrc / ~/.zshenv to persist\n```\n\n**`auth set`.** Saves the key for every folder:\n```bash\ncodewhale auth set --provider deepseek # prompts: \"Enter API key for deepseek:\"\nprintf '%s\\n' \"$KEY\" | codewhale auth set --provider deepseek --api-key-stdin # scripted\n# -> saved API key for deepseek to file-based (\"/home/you/.codewhale/secrets/secrets.json\") (config contains metadata only)\n```\nThe file-based message prints the resolved secret-store path; an explicit\n`CODEWHALE_HOME` changes that location.\n\nOn Linux, the key is stored in **plaintext** in\n`~/.codewhale/secrets/secrets.json`, with mode 0600. It is not in an OS\nkeyring. Note that in v0.10.0, `auth set` also writes\n`default_text_model = \"deepseek-v4-pro\"` into your config, switching you from\nthe default `deepseek-flash` to the pricier Pro model. Change it back with\n`/model` in the TUI, or edit `~/.codewhale/config.toml`.\n\n**Inside the TUI.** Press **F3** (or type `/provider`), select DeepSeek, press\nEnter, paste the key (masked), pick a model, and confirm. This also writes the\nsecret store, and keeps `deepseek-flash`.\n\n**Config file.** `~/.codewhale/config.toml`:\n```toml\n[providers.deepseek]\napi_key = \"sk-...\"\n```\n\n### Check which key is active\n\n```bash\ncodewhale auth status --provider deepseek\n# active source: env (last4: ...xxxx) # or: secret store / config / missing\n# lookup order: config -> secret store -> env\ncodewhale doctor --probe-api\n# · Testing connection... ✓ API connection successful\n```\n\nPlain `codewhale doctor` names an environment key it can see\n(`deepseek: env_source=set via DEEPSEEK_API_KEY (value not shown; not checked offline)`)\nbut does not read the secret store or check the key; `--probe-api` does both.\nDoctor exits 0 even when no key is found, so scripts should use `auth status`.\n\n### Remove a stored key\n\n```bash\ncodewhale auth clear --provider deepseek\n# cleared API key for deepseek from config and secret store\n```\n\nIt doesn't unset `DEEPSEEK_API_KEY` in your shell, and it leaves the\n`default_text_model` line that `auth set` added.\n\n### Other providers\n\n`codewhale auth list` shows about 50 providers (OpenRouter, Anthropic, OpenAI,\nMoonshot, Ollama, …). The pattern is the same:\n`codewhale auth set --provider `, or the provider's env var. Local models\n(Ollama, vLLM, SGLang) need no key. Only DeepSeek was tested here.\n\n---\n\n\n\n## 9. Shell completions\n\n```bash\n# bash (needs the bash-completion package)\nmkdir -p ~/.local/share/bash-completion/completions\ncodewhale completion bash > ~/.local/share/bash-completion/completions/codewhale\n\n# zsh\nmkdir -p ~/.zfunc\ncodewhale completion zsh > ~/.zfunc/_codewhale\n# in ~/.zshrc, if not already there:\n# fpath=(~/.zfunc $fpath)\n# autoload -Uz compinit && compinit\n\n# fish\nmkdir -p ~/.config/fish/completions\ncodewhale completion fish > ~/.config/fish/completions/codewhale.fish\n```\n\nEach script registers both `codewhale` and `codew`. `codewhale completions` is\nan alias. Open a new shell afterwards. Regenerate after upgrading.\n\nHow well they work in v0.10.0 (tested interactively):\n\n* **bash:** fully works (`codewhale comp`, `codew auth `).\n* **fish:** sub-commands complete with descriptions, but\n `codewhale completion ` offers files instead of shell names.\n* **zsh:** only the first word completes. After a sub-command\n (`codewhale auth `), zsh wrongly lists the top-level commands again\n (same on macOS zsh 5.9, where it offers all 126 top-level entries).\n\nPowerShell and Elvish scripts are generated too **(untested on this VM: shells\nnot installed)**.\n\n---\n\n## 10. Running it\n\n### The TUI\n\n```bash\ncd your-git-repo\ncodewhale\n```\n\n* The composer is at the bottom. The footer shows the permission posture\n (`ask`), the mode (`work`) and the model (`DeepSeek · deepseek-flash`).\n* **Shift+Tab** cycles the permission posture: Ask → Auto-Review → Full Access.\n **Tab** (with an empty composer) cycles the mode: Plan → Work → Operate.\n* In **Ask**, file edits in the workspace are applied and shown as a diff.\n Shell commands stop at an **APPROVAL** prompt: `y` allow once, `a` allow for\n this session, `n` deny, `Esc` abort the turn.\n* Useful keys: **F1** help (or `/help`), **Ctrl-K** command palette, **F3**\n provider/model picker, **Ctrl-R** resume a past session, **Ctrl-U** clear the\n input (**Ctrl-Z** restores it), **Ctrl-C** cancel or quit, **Ctrl-D** quit\n with an empty input. Full list: [KEYBINDINGS.md](KEYBINDINGS.md).\n* On exit it prints `To resume this session, run codewhale resume `.\n\nCodewhale creates a `.codewhale/` directory in your repo. Ignore its contents\nbut keep the committable `constitution.json` (these are the same patterns\n`/init` writes):\n\n```gitignore\n**/.codewhale/*\n!**/.codewhale/constitution.json\n```\n\n### Headless (scripts, CI)\n\n```bash\ncodewhale exec \"Reply with exactly: pong\" # one-shot answer, no tools\ncodewhale exec --auto \"create primes.py that prints the first 10 primes and run it\" # tools, auto-approved\ncodewhale exec --json \"…\" # summary JSON (provider, model, usage, output)\ncodewhale exec --auto --output-format stream-json \"…\" # one JSON event per line\n```\n\n`--auto` auto-approves shell commands, so use it only in a repo or sandbox you\ntrust.\n\nPlain `exec` offers the model no tools. Only `--auto`, `--yolo`,\n`--allowed-tools` or resuming a session opens a tool surface; limits such as\n`--max-turns`, `--disallowed-tools`, `--sandbox` and the output format never\nadd tools (tool-only flags print a warning). If the provider stops a reply at\nits output limit, the model is asked to continue and the printed answer is the\nwhole reply. A plain run takes at most 8 model steps unless `--max-turns` sets\nanother limit; a reply still cut off at that limit fails the run.\n\n### Resuming\n\n```bash\ncodewhale resume # or a unique prefix, e.g. e2525dfb\ncodewhale -c # continue the most recent session in this folder\ncodewhale sessions # list saved sessions\ncodewhale exec --continue \"…\" # headless follow-up to the latest session\ncodewhale exec --resume \"…\"\n```\n\nIn v0.10.0, only **TUI sessions** and **`--output-format stream-json`** exec\nruns are saved. A plain `codewhale exec`/`exec --auto` run is *not* saved, so a\nfollowing `exec --continue` fails with `No saved sessions found for workspace`.\n\n---\n\n## 11. Terminal notes\n\n### Ghostty (tested: Ghostty 1.3.1 on Linux/X11)\n\nEverything I checked worked in Ghostty with its default config\n(`TERM=xterm-ghostty`, `COLORTERM=truecolor`). Screenshots are kept with the\n[install receipts](https://github.com/codewhale-hq/Codewhale/tree/37ecdfcc49bc68a9b0d058b97c3946e62c34bd31/docs/install-report/v0.10.0-2026-09-23/screenshots).\n\n| Check | Result |\n|---|---|\n| Colours / truecolor gradient, box drawing, Unicode (✓ é 日本語) | ✅ |\n| Window resize (1504×886 → 800×500 → back) reflows cleanly | ✅ |\n| Mouse wheel scrolls the transcript, with a jump-to-bottom button | ✅ |\n| Paste (`Ctrl+Shift+V`), multi-line: inserted, not sent | ✅ |\n| F1, F3, Ctrl-K, Ctrl-R, Tab, Shift+Tab, Ctrl-U/Ctrl-Z, Ctrl-C, Ctrl-D | ✅ |\n| Window title shows state (`waiting on you…`, `✓ done`) | ✅ |\n| Exit restores the terminal (normal screen, cursor, no mouse-reporting garbage) | ✅ |\n\nGhostty on Linux starts a **non-login** shell, so it reads `~/.bashrc` and not\n`~/.profile`. That's why you need the PATH line in `~/.bashrc` (§2).\n\nYou may notice small dots and a faint label (e.g. `other · drift`) drifting\nacross empty space after a turn. That's Codewhale's decorative \"ambient life\"\nwhale, not a rendering bug.\n\n### Other terminals\n\ntmux eats **F1**, so use `/help` there. Some key chords (Ctrl-Shift-…, Ctrl-Tab)\nneed a terminal with an enhanced keyboard protocol; [KEYBINDINGS.md](KEYBINDINGS.md) lists\nportable alternatives. Windows users should use Windows Terminal\n**(untested on this VM)**.\n\n---\n\n## 12. Uninstalling\n\n### Step 1: forget stored keys (if you used `auth set` or F3)\n\n```bash\ncodewhale auth clear --provider deepseek\n```\n\n### Step 2: remove the program\n\n| Installed with | Remove with |\n|---|---|\n| installer (§2) or manual (§3) | `rm ~/.local/bin/codewhale ~/.local/bin/codew` (or your `CODEWHALE_INSTALL_DIR`) |\n| npm | `npm uninstall -g codewhale` |\n| Cargo | `cargo uninstall codewhale-cli` |\n| Homebrew | `brew uninstall codewhale && brew untap Hmbown/deepseek-tui` (also removes its node dependency) |\n\n### Step 3: remove data. No uninstaller does this for you.\n\n| Path | What it is | Size seen |\n|---|---|---|\n| `~/.codewhale/` | config.toml, **secrets/secrets.json (plaintext keys)**, sessions/, logs/, catalog/ (model list, ~5 MB), skills/, builtin-plugins/, tasks/, automations/, crashes/, audit.log, composer history | 6–7 MB |\n| `~/.deepseek/snapshots/` | v0.10.0 stores its per-turn **copies of your workspaces** here (a legacy path). Contains the contents of every repo you ran it in. | 0.2–0.6 MB here; grows with repo size |\n| `/.codewhale/` | per-workspace state/lock dir | tiny |\n| completion files | `~/.local/share/bash-completion/completions/codewhale`, `~/.zfunc/_codewhale`, `~/.config/fish/completions/codewhale.fish` | – |\n| PATH lines you added | `~/.bashrc`, `~/.zshrc`, `~/.profile` | – |\n\n```bash\nrm -rf ~/.codewhale ~/.deepseek/snapshots\nrmdir ~/.deepseek 2>/dev/null # removes the parent only if it is now empty\n# per-repo dirs, e.g.:\nfind ~ -type d -name .codewhale -prune -print # review, then delete the ones you want\n```\n\nCodewhale wrote nothing outside `$HOME` and the repos it was used in: no\nsystem files, services or cron jobs. (I checked every file owned by the test\nusers outside their home directories.) The commands above delete only\n`~/.deepseek/snapshots`. If you still use the older DeepSeek-TUI, the rest of\n`~/.deepseek` (its config and sessions) is left alone.\n\n---\n\n## 13. Troubleshooting\n\nEvery error below was hit while writing this guide.\n\n**`bash: codewhale: command not found` right after installing.**\n`~/.local/bin` isn't on PATH in this terminal. See\n[Put it on your PATH](#put-it-on-your-path).\n\n**`npm error code EACCES … permission denied, mkdir '…/lib/node_modules/codewhale'`.**\nYour Node is system-owned. See [§4](#if-you-get-eacces-permission-denied).\nDon't use sudo.\n\n**`error: DeepSeek API key not found.` (from `codewhale exec`)**\nNo key anywhere. Follow the printed steps, or see §8.\n\n**The TUI shows your message but never answers.**\nNo key (v0.10.0 doesn't say so). Press F3 → DeepSeek → Enter → paste the key.\n\n**`error: Responses API request failed … Authentication Fails, Your api key: ****dead is invalid`.**\nThe key is wrong or revoked. Run `codewhale auth status --provider deepseek`\nto see *which* source is being used. Remember that config and the secret store\nbeat the env var. Fix with `codewhale auth set --provider deepseek`, or\n`codewhale auth clear --provider deepseek` to fall back to the env var. In the\nTUI, a bad key sends you to a \"Choose your model provider\" screen that marks\nDeepSeek `last check failed (authentication)`.\n\n**`error: Network error: SSE stream request failed after HTTP/1.1 fallback: Responses API request failed. … on Windows or proxy networks, try CODEWHALE_FORCE_HTTP1=1 …`.**\nDespite the wording, on Linux this usually just means **no connection to\n`api.deepseek.com`**. Check with `curl -sI https://api.deepseek.com` (a `401`\nresponse is fine; it means the host is reachable). If you're behind a proxy,\nmake sure `HTTPS_PROXY` is exported. `codewhale doctor --probe-api` only says\n`✗ API connection failed` for both bad keys and network problems.\n\n**`codewhale install: refusing to replace existing ~/.local/bin/codewhale`.**\nA different version is already installed there. Run `codewhale update`, or\ndelete the two files first (§7 rollback), or install into a fresh\n`CODEWHALE_INSTALL_DIR`.\n\n**`codewhale install: checksum mismatch for codew-linux-x64`.**\nThe download was corrupted or tampered with. Nothing was installed. Retry, and\nif it repeats, don't use a mirror.\n\n**`error: The package-managed executable was not changed.` (from `codewhale update`)**\nYou installed with npm, Cargo or Homebrew. Update with that tool instead.\n\n**`error: failed to run custom build command for libdbus-sys` (Cargo).**\nRun `sudo apt-get install -y libdbus-1-dev pkg-config`.\n\n**`error: No saved sessions found for workspace …` (from `exec --continue`).**\nThe previous run was plain-text `exec`, which isn't saved. Use the TUI, or\n`--output-format stream-json`.\n\n**zsh completion suggests the wrong things after the first word.**\nKnown v0.10.0 bug. bash and fish are fine.\n\n**Getting help:** `codewhale doctor --json` produces a diagnostics bundle\nwithout secrets.\n\n---\n\n## Appendix: other platforms (not re-tested in this revision)\n\nThe sections below are carried over unchanged from the previous revision of\nthis page. They were **not re-run** for the v0.10.0 install test above\n(out of scope: Windows, macOS, Android/Termux, FreeBSD, mainland-China\nmirrors), apart from the macOS paths noted in [macOS notes](#macos-notes).\nKnown contradictions with the published v0.10.0 assets, found by inspecting\nthem ([details](https://github.com/codewhale-hq/Codewhale/blob/37ecdfcc49bc68a9b0d058b97c3946e62c34bd31/docs/install-report/v0.10.0-2026-09-23/DOC_DEFECTS.md), D15 and D16):\n\n* The winget manifest kept in `packaging/winget/` is stale (0.9.6) and not the\n published package; winget serves `HunterBown.CodeWhale`, see\n [Windows winget](#windows-winget).\n* v0.10.0 publishes both `codewhale-windows-x64.zip` (with an `install.bat`\n that copies to `%USERPROFILE%\\bin`) and `codewhale-windows-x64-portable.zip`;\n the sections below mention only the first.\n* The standalone `codewhale.bat` launcher works only next to the x64 exe.\n\n### Supported platforms and assets\n\nThe [latest stable release](https://github.com/codewhale-hq/CodeWhale/releases/latest)\npublishes Linux x64/arm64, macOS x64/arm64, Windows x64/arm64, and Android arm64\nassets. Artifact presence is distinct from platform qualification.\nThe table below describes the current source tree's platform and secondary\npackaging support; `latest` installation still selects the published release.\nAndroid/Termux is preview pending real-device QA. Linux ARM64 is available from\nv0.8.8 onward. Linux RISC-V prebuilts are temporarily paused because the locked\n`rquickjs-sys` dependency does not ship `riscv64gc-unknown-linux-gnu` bindings.\n\n| Platform | Architecture | GitHub release asset | npm install | `cargo install` |\n| ------------ | ------------ | ----------------------------------------------------- | :---------: | :-------------: |\n| Linux | x64 (x86_64) | `codewhale-linux-x64`, `codew-linux-x64` | ✅ | ✅ |\n| Linux | arm64 | `codewhale-linux-arm64`, `codew-linux-arm64` | ✅ | ✅ |\n| Android / Termux | arm64 (aarch64) | `codewhale-android-arm64.tar.gz` (published in v0.9.12; device support is preview) | ⚠️⁴ preview | ⚠️⁴ preview |\n| Linux | riscv64 | temporarily unsupported until upstream bindings land | ❌¹ | ❌³ |\n| macOS | x64 | `codewhale-macos-x64`, `codew-macos-x64` | ✅ | ✅ |\n| macOS | arm64 (M-series) | `codewhale-macos-arm64`, `codew-macos-arm64` | ✅ | ✅ |\n| Windows | x64 | `codewhale-windows-x64.exe`, `codew-windows-x64.exe` | ✅ | ✅ |\n| Windows | arm64 | `codewhale-windows-arm64.exe`, `codew-windows-arm64.exe` | ✅ | ✅ |\n| Linux x64 or arm64 on musl (Alpine) | native arch | matching static Linux asset | ✅ (static) | ✅ |\n| Other Linux (musl on other arches) | — | build from source | ❌¹ | ✅² |\n| FreeBSD 14+ / OpenBSD | x64, arm64 | `cargo install codewhale-cli --locked` (no prebuilt; see § FreeBSD) | ❌ | ✅² |\n\n¹ The npm package will exit with a clear error and point you here.\n² Provided your toolchain can compile a recent Rust workspace; see\n [Build from source](#5-cargo-and-building-from-source) below.\n³ RISC-V source builds currently need upstream `rquickjs-sys` RISC-V bindings or\n a bindgen-enabled dependency build.\n⁴ The current npm wrapper recognizes Android arm64 and resolves\n the matching `codewhale` and `codew` Android assets. npm\n installation works only for a package version whose GitHub Release publishes\n those matching assets. The Android/Termux path remains preview-only until the\n real-device compile, startup, approval, file-tool, and update checks tracked\n in #4236 and #4242 are complete.\n\nAndroid / Termux is not the same target as Linux arm64. Do not install the\nLinux `codewhale-linux-arm64` archive in Termux; use the Termux-specific\nAndroid archive when a release or release candidate publishes one, or build\nfrom source inside Termux.\n\nThe current Linux **x64 and arm64** assets are **static musl builds**.\nThe x64 release path has used musl since v0.8.65; v0.9.6 extends the same build\nand static-launch check to arm64. These binaries have no glibc dependency and\nrun on their matching architecture across Ubuntu, Debian, RHEL/CentOS, and\nAlpine/musl. SQLite is bundled through `rusqlite`, so no separate `libsqlite3`\nruntime package is needed.\n\n#### Linux ARM64 portability\n\nLinux arm64 assets before v0.9.6 were GNU libc builds and could inherit the\nUbuntu 24.04 build host's `GLIBC_2.39` floor. Ubuntu 22.04 ships glibc 2.35, so\nthose older arm64 binaries can fail with errors such as:\n\n```text\nversion `GLIBC_2.39' not found\n```\n\nThe npm wrapper, `codewhale update`, and the Unix archive installer retain their\nGNU-binary preflight for older releases. The current arm64 build instead uses\n`aarch64-unknown-linux-musl`, so it has no `GLIBC_*` floor. If you are installing\nan earlier release on an older arm64 distribution, use:\n\n```bash\ncargo install codewhale-cli --locked # installs `codewhale`\n```\n\n> **Linux ARM64 note (v0.8.7 and earlier).** v0.8.7 and earlier do **not**\n> publish a Linux ARM64 prebuilt; users on HarmonyOS thin-and-light, Asahi\n> Linux, Raspberry Pi, AWS Graviton, etc. saw `Unsupported architecture: arm64`\n> from `npm i -g codewhale`. v0.8.8 publishes `codewhale-linux-arm64`, so a plain `npm i -g codewhale` works\n> on any glibc-based ARM64 Linux. If you're stuck on v0.8.7, jump to\n> [Build from source](#5-cargo-and-building-from-source) — `cargo install` works fine.\n> For HarmonyOS PC and OpenHarmony cross-build setup, see\n> [HarmonyOS and OpenHarmony](HarmonyOS.md).\n\n### Migrating from npm, Cargo, or another installation\n\n#### Migrating from npm, Cargo, or another installation\n\nPackage managers continue to own their files. `codewhale update` gives migration\ninstructions for npm, Cargo, Homebrew, and Omarchy instead of overwriting them.\nKnown system/package directories are also protected. A `CODEWHALE_INSTALL_METHOD=binary`\noverride cannot bypass a recognized managed path.\n\nCreate a fresh destination when `~/.local/bin` is occupied or a sibling command\nhas different bytes. This leaves every existing installation in place:\n\n```bash\nmkdir -p \"$HOME/.local\"\ncodewhale_install_dir=\"$(mktemp -d \"$HOME/.local/codewhale-release.XXXXXX\")\"\ncurl -fsSL https://codewhale.net/install.sh | CODEWHALE_INSTALL_DIR=\"$codewhale_install_dir\" sh\n\"$codewhale_install_dir/codewhale\" --version\nexport PATH=\"$codewhale_install_dir:$PATH\"\nhash -r\ncommand -v codewhale codew\n\"$codewhale_install_dir/codewhale\" update --check\n```\n\nAfter verifying the version and command paths, keep that directory first in your\nshell profile. In PowerShell, use `Get-Command codewhale, codew -All` to inspect\nresolution; run the selected executable using its full path. A successful update\nonly changes its own install directory, so another earlier PATH entry can still\nlaunch an older copy.\n\nModern matched `codewhale`, `codew`, and compatibility copies update from the\nsame verified bytes. Symlinks to the running binary are preserved. A different\nor unrelated sibling is named in the error and left untouched; no sibling is\nexecuted merely to guess its owner. Use the fresh-directory migration above\nfor older installs with separate dispatcher/TUI binaries.\n\nTo retain a secondary package-managed install, use its manager:\n\n```bash\nnpm install -g codewhale@latest\n# or\ncargo install codewhale-cli --locked --force\n```\n\nHomebrew uses `brew upgrade codewhale`; Omarchy uses `omarchy update`. These\ncommands update their own copies, so verify PATH again afterward.\n\n\n\n### Android / Termux arm64 (preview)\n\nTermux runs on Android's Bionic libc and uses `$PREFIX` as its Unix prefix, so\nit needs a Termux-specific Android arm64 archive. The Linux arm64 release asset\ntargets standard Linux with musl; Android uses a distinct Rust target, so the\nLinux asset should not be used there.\n\nInstall the minimum archive/runtime tools first:\n\n```bash\npkg update\npkg install -y ca-certificates curl tar gzip coreutils\n```\n\nWhen the release includes `codewhale-android-arm64.tar.gz`, install it with the\narchive's bundled installer. Passing `PREFIX=\"$PREFIX\"` matters: the installer\ndefaults to `~/.local`, while Termux users normally expect commands under\n`$PREFIX/bin`.\n\n```bash\ncd \"$HOME\"\ncurl -L -O https://github.com/codewhale-hq/CodeWhale/releases/latest/download/codewhale-android-arm64.tar.gz\ncurl -L -O https://github.com/codewhale-hq/CodeWhale/releases/latest/download/codewhale-bundles-sha256.txt\nsha256sum -c codewhale-bundles-sha256.txt --ignore-missing\n\ntar xzf codewhale-android-arm64.tar.gz\ncd codewhale-android-arm64\nPREFIX=\"$PREFIX\" ./install.sh\nhash -r\n```\n\nIf you are validating from source or building a release candidate locally,\ninstall the build packages before running Cargo:\n\n```bash\npkg install -y rust clang pkg-config make git\ncargo install codewhale-cli --locked # installs `codewhale`\n```\n\nThe normal first-run setup path is implemented, but its Android interaction is\nstill part of the preview QA above. Prefer provider environment variables for\ntemporary credentials. `codewhale auth set` is available, but the Termux build\nhas no supported OS keyring integration and falls back to file-backed secrets\nby writing `~/.codewhale/config.toml` and mirroring keys to\n`~/.codewhale/secrets/secrets.json`. Both are plaintext files protected by\n`0600` permissions and are not encrypted at rest.\n\n```bash\ncodewhale auth set --provider deepseek\ncodewhale auth status\ncodewhale doctor\n```\n\nMaintainers should use this repeatable smoke checklist for a Termux / Android\narm64 release candidate:\n\n```bash\ncommand -v codewhale codew\ntest -x \"$PREFIX/bin/codewhale\"\ntest -x \"$PREFIX/bin/codew\"\n\ncodewhale --version\ncodewhale doctor\ncodewhale exec --auto \"run pwd\"\n```\n\nKnown limitations:\n\n- Commands inherit Android's per-app UID, SELinux, and seccomp protections and\n any permissions granted to Termux. Codewhale's bubblewrap\n child-process sandbox is Linux-only and is not built on Android, so approved\n commands receive no Codewhale-specific filesystem narrowing.\n- The Termux build has no supported Android Keystore or desktop Secret Service\n integration. Use `codewhale auth status` to confirm the active source and\n prefer provider environment variables when file-backed plaintext storage is\n not acceptable.\n- Terminal rendering varies by Android terminal app. The TUI always owns the\n alternate screen. If a terminal app cannot render the full-screen TUI,\n use `codewhale exec` for headless runs instead.\n\n### China / mirror-friendly install\n\nWhen installing from mainland China, configure mirrors for both **rustup**\n(the Rust toolchain installer) and **Cargo** (the package registry) to avoid\nTLS timeouts and download failures.\n\n**Step 1: Install Rust via a rustup mirror**\n\n```bash\n# PowerShell\n[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12\n(New-Object Net.WebClient).DownloadFile('https://win.rustup.rs/x86_64', 'rustup-init.exe')\n\n# git-bash / msys2\nexport RUSTUP_DIST_SERVER=https://mirrors.tuna.tsinghua.edu.cn/rustup\nexport RUSTUP_UPDATE_ROOT=https://mirrors.tuna.tsinghua.edu.cn/rustup/rustup\n./rustup-init.exe -y --default-toolchain stable\n\n# Linux / macOS\nexport RUSTUP_DIST_SERVER=https://mirrors.tuna.tsinghua.edu.cn/rustup\nexport RUSTUP_UPDATE_ROOT=https://mirrors.tuna.tsinghua.edu.cn/rustup/rustup\ncurl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable\n```\n\nIf the TUNA mirror is slow from your network, `rsproxy.cn` is another\nrustup mirror option for Linux/macOS:\n\n```bash\nexport RUSTUP_DIST_SERVER=https://rsproxy.cn\nexport RUSTUP_UPDATE_ROOT=https://rsproxy.cn/rustup\ncurl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable\n```\n\nThe `RUSTUP_DIST_SERVER` and `RUSTUP_UPDATE_ROOT` environment variables must\nbe set **before** running rustup-init; the toolchain download otherwise hits\nthe same TLS handshake problem as the installer.\n\n**Step 2: Configure Cargo registry mirror**\n\n```toml\n# ~/.cargo/config.toml\n[source.crates-io]\nreplace-with = \"tuna\"\n\n[source.tuna]\nregistry = \"sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/\"\n```\n\n`rsproxy`, Tencent COS, and Aliyun OSS mirrors work the same way; pick whichever\nis fastest from your network.\n\n### Omarchy / AUR\n\nOn Omarchy, install the prebuilt AUR package:\n\n```bash\nomarchy pkg aur add codewhale-bin\ncodewhale --version\n```\n\n`codewhale-bin` packages the same checksum-pinned Linux release archives as the\nother binary install paths and provides both `codewhale` and `codew`. It does\nnot carry a separate Codewhale version; the existing `codewhale-tui`\ncompatibility command remains an alias to the same runtime. Package updates\narrive through `omarchy update`; the in-app updater leaves the pacman-owned\nbinary to Omarchy.\n\nThe AUR update follows the matching Codewhale tag and release assets, so it may\nappear after the GitHub release while its generated `PKGBUILD` and `.SRCINFO`\nare validated. Release-maintainer instructions live in\n[`packaging/aur/README.md`](../packaging/aur/README.md).\n\n---\n\n### Windows\n\n#### Windows Scoop\n\nThe `codewhale` package is listed in Scoop's main bucket:\n\n```powershell\nscoop update\nscoop install codewhale\ncodewhale --version\n```\n\nScoop manifests are maintained outside this repository's release workflow and\ncan lag GitHub/npm/Cargo releases. Use npm or manual GitHub release downloads\nwhen you need the newest version immediately.\n\n#### Windows winget\n\nThe published winget package is **`HunterBown.CodeWhale`** (verified in\n[microsoft/winget-pkgs](https://github.com/microsoft/winget-pkgs/tree/master/manifests/h/HunterBown/CodeWhale)\non 2026-10-04; latest published version 0.10.0). It is a portable x64 package:\nwinget downloads `codewhale-tui-windows-x64.exe`, installs it as the `codewhale`\ncommand, and pulls in the Microsoft Visual C++ 2015+ x64 runtime.\n\n```powershell\nwinget install HunterBown.CodeWhale\ncodewhale --version\n```\n\nUpdate with `winget upgrade HunterBown.CodeWhale` or `codewhale update`. Each\nversion goes through winget-pkgs review after the GitHub Release, so winget can\nlag GitHub/npm/Cargo; use npm or the GitHub Release asset when you need the\nnewest version immediately.\n\nKnown limits of the winget route:\n\n* **x64 only.** The published manifest has no ARM64 installer. On Windows ARM64,\n use `npm install -g codewhale` under native ARM64 Node.js, or download\n `codewhale-windows-arm64.zip` from GitHub Releases.\n* **`codewhale` only.** The short `codew` alias is not installed by winget; use\n `codewhale`.\n* The manifest in this repository (`packaging/winget/`) is not the one winget\n serves; see [`packaging/winget/README.md`](../packaging/winget/README.md).\n\n#### Windows NSIS Installer\n\nFor the OS/terminal compatibility targets and current evidence, see\n[Windows support and release qualification](ENVIRONMENTS.md#windows-support-and-release-qualification).\n\nA standalone NSIS-based installer is available starting with v0.8.50 for\nWindows users who prefer a traditional double-click setup (no npm, no Scoop, no\nCargo required).\n\nThe NSIS installer currently contains the Windows x64 binaries. Windows ARM64\nusers should install through npm running under native ARM64 Node.js or download\n`codewhale-windows-arm64.zip` from the same release; both paths then use native\nARM64 binaries.\n\n**Download** `CodeWhaleSetup.exe` from the\n[Releases page](https://github.com/codewhale-hq/CodeWhale/releases/latest).\n\n**Install** by double-clicking the setup executable. The installer:\n\n- Installs `codewhale.exe` and `codew.exe` side-by-side (single binary, no `codewhale-tui.exe`) into\n `%LOCALAPPDATA%\\Programs\\CodeWhale\\bin`\n- Installs `codewhale.bat`, which prefers Windows Terminal (`wt.exe`) when it is on `PATH` and\n otherwise launches the exe directly\n- Creates a current-user Start Menu shortcut that opens that launcher, not the raw `.exe`\n- Adds the install directory to the **current user** `PATH`\n- Registers in Windows **Apps & Features** for easy uninstall\n\nUninstall removes the binaries, `codewhale.bat`, the Start Menu shortcut, and the user `PATH` entry.\n\n**Silent install** (for IT admins, SCCM, Intune):\n\n```powershell\nCodeWhaleSetup.exe /S\n```\n\nThe installer is per-user and does not request elevation. Run silent installs in\nthe target user's context, or use a deployment tool that can run the installer\nfor each user profile that needs Codewhale.\n\nThe release-built installer is currently unsigned and may trigger Windows\nSmartScreen. Verify the SHA-256 checksum from `codewhale-artifacts-sha256.txt`\nbefore deploying, and sign the installer in your internal deployment pipeline if\nyour environment requires signed application packages.\n\n**Build the installer yourself** (requires [NSIS](https://nsis.sourceforge.io)):\n\n```powershell\ncd scripts\\installer\n# Place codewhale.exe and codew.exe here (single binary, no codewhale-tui.exe), then:\nmakensis /DVERSION= codewhale.nsi\n```\n\n**Manual fallback** — if the installer is blocked by group policy, see the\n[CLASSROOM_INSTALL.md](CLASSROOM_INSTALL.md) guide for step-by-step PowerShell\ncommands.\n\n> **Deploying to a classroom or lab?** See the full\n> [Classroom Install Checklist](CLASSROOM_INSTALL.md) for silent install,\n> API key provisioning, imaging notes, and troubleshooting.\n\n\n\n### FreeBSD, cross-compiling, Windows source builds\n\n#### FreeBSD 14+ source-build workaround (#1097)\n\nFreeBSD has no prebuilt GitHub Release asset — `npm install -g codewhale` intentionally\nfails with `Unsupported platform: freebsd` and points to Cargo. Install from source:\n\n```bash\npkg install -y rust pkgconf git\ncargo install codewhale-cli --locked # installs `codewhale`\ncodewhale --version\ncodewhale doctor\n```\n\nThe `rquickjs` FreeBSD bindings are generated at build time via `bindgen` (see\n`1582ba965`/`5eb0385e8`). No separate `pkg install codewhale` port exists yet —\na native port is tracked as the follow-up to #1097 under `packaging/freebsd/`\n(contributions welcome). Validate with `cargo check --target x86_64-unknown-freebsd -p codewhale-cli --locked`\non the release branch; the 7×1 release matrix (Linux musl x64/arm64,\nAndroid arm64, macOS x64/arm64, Windows x64/arm64) stays 7 targets — FreeBSD is a\nsource-build target, not a prebuilt asset.\n\n#### Cross-compiling from x64 to ARM64 Linux\n\nThe release asset uses `aarch64-unknown-linux-musl` and is built on a native ARM\nrunner. If you want to build a GNU-linked ARM64 Linux binary on an x64 Linux\nhost (e.g. for a HarmonyOS / openEuler ARM64 thin-and-light), use\n[`cross`](https://github.com/cross-rs/cross), which wraps the official Rust\ncross-targets in a Docker container:\n\n```bash\n# Once\nrustup target add aarch64-unknown-linux-gnu\ncargo install cross --locked\n\n# Per build\ncross build --release --target aarch64-unknown-linux-gnu -p codewhale-cli # single binary\n```\n\nThe resulting binary lands in\n`target/aarch64-unknown-linux-gnu/release/codewhale`. Copy it to the ARM64 host\n(e.g. via `scp`) and make it executable. This local GNU build is distinct from\nthe portable musl release asset; either executable can be copied under the\n`codew` convenience name.\n\nIf you don't have Docker available, install the cross-linker directly and let\nCargo do the work:\n\n```bash\nsudo apt-get install -y gcc-aarch64-linux-gnu\nrustup target add aarch64-unknown-linux-gnu\n\ncat >> ~/.cargo/config.toml <<'EOF'\n[target.aarch64-unknown-linux-gnu]\nlinker = \"aarch64-linux-gnu-gcc\"\nEOF\n\ncargo build --release --target aarch64-unknown-linux-gnu -p codewhale-cli # single binary\n```\n\nProducing `aarch64-unknown-linux-musl` while cross-compiling requires an\nappropriate musl cross-linker. The release workflow avoids that extra moving\npart by building and launching the musl binary on GitHub's native ARM runner.\n\n#### Windows build from source\n\nBuilding on Windows requires the **MSVC C toolchain** from\n[Visual Studio Build Tools](https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022)\n(the free workload-selectable installer, not the full IDE).\n\n**Prerequisites (Windows)**\n\n1. Install Visual Studio 2022 Build Tools — select the **\"Desktop development\n with C++\"** workload.\n2. Install [Rust](https://rustup.rs) 1.89+ (see the\n [China mirror instructions](#china--mirror-friendly-install) above if\n downloading from mainland China).\n3. Install [Git for Windows](https://git-scm.com/download/win) (provides `git`\n and the `git-bash` terminal).\n\n**Recommended terminals**: Windows Terminal, `git-bash`, or PowerShell.\n`cmd.exe` works but has a small buffer and limited PATH behavior.\n\n**Setting up the MSVC environment**\n\nVisual Studio Build Tools install `cl.exe` to a versioned directory but do\n**not** add it to `PATH` globally. You must set the environment manually or\nuse a Developer Command Prompt. The required variables are:\n\n```powershell\n# Adjust version numbers to match your installation\n$msvc = \"C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\VC\\Tools\\MSVC\\14.44.35207\"\n$sdk = \"C:\\Program Files (x86)\\Windows Kits\\10\"\n$sdkv = \"10.0.26100.0\"\n\n$env:INCLUDE = \"$msvc\\include;$msvc\\atlmfc\\include;$sdk\\Include\\$sdkv\\ucrt;$sdk\\Include\\$sdkv\\um;$sdk\\Include\\$sdkv\\shared\"\n$env:LIB = \"$msvc\\lib\\x64;$msvc\\atlmfc\\lib\\x64;$sdk\\Lib\\$sdkv\\ucrt\\x64;$sdk\\Lib\\$sdkv\\um\\x64\"\n$env:LIBPATH = \"$msvc\\lib\\x64;$msvc\\atlmfc\\lib\\x64\"\n$env:CC = \"$msvc\\bin\\Hostx64\\x64\\cl.exe\"\n$env:CXX = \"$msvc\\bin\\Hostx64\\x64\\cl.exe\"\n$env:PATH = \"$msvc\\bin\\Hostx64\\x64;$env:PATH\"\n```\n\nAlternatively, open a **\"Developer Command Prompt for VS 2022\"** (available\nfrom the Start Menu after installing Build Tools), which runs `vcvars64.bat`\nto configure all of the above automatically. Then add `cargo` to `PATH` inside\nthat session and run `cargo build` from the project root.\n\n**Cargo registry mirror** — on Windows the mirror config goes to\n`%USERPROFILE%\\.cargo\\config.toml`. See [Step 2 above](#china--mirror-friendly-install).\n\n**Build**\n\n```bash\ngit clone https://github.com/codewhale-hq/CodeWhale.git\ncd CodeWhale\nset CARGO_HTTP_CHECK_REVOKE=false # may be needed behind some Chinese ISPs\ncargo build --release\n```\n\nThe Cargo-built binary appears at `target\\release\\codewhale.exe`. Release\npackaging separately exposes the same executable as `codew.exe`.\n\n> Prefer not to build? Install via npm, Cargo, GitHub Releases, or the CNB\n> mirror — see the sections above.\n\n### Older-release and regional troubleshooting\n\n#### `Unsupported architecture: arm64 on platform linux`\n\nYou're on a release earlier than v0.8.8 that doesn't publish Linux ARM64\nbinaries. Use the GitHub installer in a fresh directory as described above, or use\n`cargo install` per [Section 4](#5-cargo-and-building-from-source).\n\n#### `MISSING_COMPANION_BINARY` after upgrading an older install\n\nThe current single binary runs the TUI in-process and does not require a\ncompanion executable. This error identifies a stale pre-v0.9.5 dispatcher.\nUse the fresh-directory GitHub migration above, then verify the selected\n`codewhale` and `codew` paths. Do not download another separate runtime.\n\n#### `codewhale update` reports `no asset found for platform codewhale-linux-aarch64`\n\nOlder updaters used Rust architecture names that did not match the published\nasset names. Use the official installer in a fresh directory as described above,\nthen run the newly installed command by its full path.\n\n#### npm download is slow or times out from mainland China\n\nOn Linux x64 the npm wrapper already probes GitHub Releases and the CNB\nfirst-party checksum manifests in parallel and downloads binaries only from\nthe first source that validates. You do not need `CODEWHALE_USE_CNB_MIRROR=1`\nfor that automatic path.\n\nIf both first-party sources fail, set `CODEWHALE_RELEASE_BASE_URL` to a\nmirrored release-asset directory (rsproxy, TUNA, Tencent COS, Aliyun OSS),\nor skip npm entirely and use the Cargo mirror setup in\n[Section 4](#5-cargo-and-building-from-source). The legacy\n`DEEPSEEK_TUI_RELEASE_BASE_URL` name is still accepted. `CODEWHALE_USE_CNB_MIRROR=1`\nstill forces CNB only on Linux x64 / OpenHarmony x64.\n\n#### `codewhale update` is blocked by GitHub from mainland China\n\n`codewhale update` prefers GitHub Releases. On supported Linux x64 targets,\na failed GitHub manifest permits the matching CNB manifest and binary fallback.\nIf GitHub metadata is also unreachable, explicitly select a known published CNB\nversion (`CODEWHALE_USE_CNB_MIRROR=1 CODEWHALE_VERSION=X.Y.Z codewhale update`)\nor a binary mirror below. Existing newer builds are kept.\n\nBuilding from the CNB source mirror with Cargo is a secondary option. Cargo\ninstalls its own `codewhale` command:\n\nTo check the latest release without downloading or replacing binaries, run\n`codewhale update --check`.\n\n```bash\ncargo install --git https://cnb.cool/codewhale.net/codewhale --tag vX.Y.Z codewhale-cli --locked --force # single binary\n```\n\nIf you operate a binary asset mirror, `codewhale update` can use it directly:\n\n```bash\nCODEWHALE_RELEASE_BASE_URL=https://your-mirror.example.com/CodeWhale/vX.Y.Z/ \\\nCODEWHALE_VERSION=X.Y.Z \\\ncodewhale update\n```\n\nThe mirror directory must contain `codewhale-artifacts-sha256.txt` and the\nplatform binaries from the GitHub release. The legacy\n`DEEPSEEK_TUI_RELEASE_BASE_URL` mirror variable remains supported as an alias.\n\n`codewhale update` only talks HTTPS, and only to GitHub's release hosts, the CNB\nmirror, and the host of the `CODEWHALE_RELEASE_BASE_URL` you set; every\nredirect hop is held to the same rule, so a plain-`http://` mirror is refused.\nA private mirror therefore works as soon as its base URL is HTTPS. If that\nmirror redirects asset downloads to a separate download host (a CDN or an\nobject-store domain), name that host too:\n\n```bash\nCODEWHALE_UPDATE_ALLOWED_HOSTS=cdn.your-mirror.example.com,objects.example.net \\\nCODEWHALE_RELEASE_BASE_URL=https://your-mirror.example.com/CodeWhale/vX.Y.Z/ \\\nCODEWHALE_VERSION=X.Y.Z \\\ncodewhale update\n```\n\nThe error message for a refused host names it and this variable.\n\n### Windows and npm-download troubleshooting\n\n#### Windows: `TLS handshake eof` or `CRYPT_E_REVOCATION_OFFLINE` from `rustup-init`\n\nThe TLS handshake to `static.rust-lang.org` fails from behind the GFW or\ncertain Chinese ISPs. Set the rustup mirror environment variables **before**\nrunning the installer:\n\n```bash\n# git-bash / msys2\nexport RUSTUP_DIST_SERVER=https://mirrors.tuna.tsinghua.edu.cn/rustup\nexport RUSTUP_UPDATE_ROOT=https://mirrors.tuna.tsinghua.edu.cn/rustup/rustup\n./rustup-init.exe -y --default-toolchain stable\n```\n\nIf you see `CRYPT_E_REVOCATION_OFFLINE` from Cargo after Rust is installed,\nalso set `CARGO_HTTP_CHECK_REVOKE=false` during `cargo build`.\n\n#### Windows: MSVC compiler (`cl.exe`) not found during `cargo build`\n\nVisual Studio Build Tools do not add `cl.exe` to the global `PATH`. Either:\n\n1. Open **\"Developer Command Prompt for VS 2022\"** from the Start Menu, add\n `%USERPROFILE%\\.cargo\\bin` to `PATH` in that window, and run `cargo build`\n from there; or\n2. Set the MSVC environment variables manually — see the\n [Windows build from source](#windows-build-from-source) section for the\n PowerShell snippet.\n\nVerify the compiler is reachable: `cl.exe /?` should print help text.\n\n#### Windows: `拒绝访问 (os error 5)` when Cargo executes build scripts\n\nThird-party antivirus software (Huorong, 360, Kaspersky, etc.) may block\nCargo from executing freshly-compiled build-script binaries\n(e.g. `libsqlite3-sys`, `aws-lc-sys`, `instability`). The error is\npath-agnostic — moving `target-dir` does not help.\n\n**Symptoms**: `could not execute process ... build-script-build (never executed)`\n\n**Workarounds** (pick one):\n\n1. **Add the project's `target/` directory to your AV exclusions list.**\n2. **Close the antivirus software temporarily** during `cargo build`.\n3. **Use the GitHub Release installer/archive instead** — the release assets\n ship prebuilt binaries and skip the Cargo build entirely\n ([Section 6](#3-manual-download-from-github-releases)).\n4. **Use `cargo install codewhale-cli --locked`** from crates.io — this\n changes the binary path, which some AV tools treat differently.\n\nTo verify that the build-script binary itself is valid (not corrupted), locate\nit under `target/debug/build//build-script-build` and run it manually:\n\n```bash\ntarget/debug/build/libsqlite3-sys-*/build-script-build\n# If this runs but panics with \"NotPresent\" (no C compiler), the binary is\n# fine — the AV is blocking Cargo's process-spawning path specifically.\n```\n\n#### npm binary download times out\n\nIf `codewhale` waits several seconds and prints `connect ETIMEDOUT` or\n`EAI_AGAIN` while fetching from `github.com`, the npm wrapper installed\nsuccessfully but the prebuilt binary download is blocked or unreliable on\nyour network. This download is separate from the npm registry package\ndownload. On Linux x64 the wrapper first races the small GitHub and CNB\nchecksum manifests and does not wait for a full GitHub binary to time out\nbefore using a valid CNB manifest.\n\nUse one of these paths:\n\n1. Set a proxy and retry:\n\n ```bash\n export HTTPS_PROXY=http://your-proxy:port\n codewhale\n ```\n\n2. Mirror the release assets internally and set `CODEWHALE_RELEASE_BASE_URL`:\n\n ```bash\n export CODEWHALE_RELEASE_BASE_URL=https://your-mirror.example.com/CodeWhale/\n codewhale\n ```\n\n The directory must contain `codewhale-artifacts-sha256.txt` and the platform\n binaries from the GitHub release.\n\n3. Install via Cargo, which builds locally and does not download GitHub release\n assets. See [Section 4](#5-cargo-and-building-from-source).\n\n4. Download both matching `codewhale` and `codew`\n binaries from the [Releases page](https://github.com/codewhale-hq/CodeWhale/releases),\n place them in a directory on `PATH`, and make them executable. See\n [Section 6](#3-manual-download-from-github-releases).\n" } ] }, @@ -80,8 +80,8 @@ "sources": [ { "path": "docs/MODES.md", - "sha256": "7026b81d483cfaf911f6467f2ef318c7eb51dd84bc8c0aaf0788f3e53138269d", - "content": "# Modes and Permission Postures\n\n> 阅读简体中文版:[zh_hans/MODES.md](zh_hans/MODES.md)\n\nCodewhale has three related concepts:\n\n- **TUI mode**: what kind of visible interaction you're in (Plan/Work/Operate).\n- **Permission posture**: how aggressively the UI asks before executing tools.\n- **Workflow**: named steps that coordinate sub-agents, with dependencies and\n results, available in any TUI mode.\n\nModel selection is separate. `--model auto` and `/model auto` route each turn to\na concrete model and thinking level; they are not TUI modes and are not part of\nthe `Tab` cycle.\n\nWorkflow runs named steps through the same sub-agent runtime; Fleet manages\ntheir saved roles and model assignments. A workflow adds ordering, result\nhandoffs, gates and a progress view. The active mode and permission posture\nstill control what each step may execute.\n\nBefore assigning steps, query `agent(action=\"roster\")` for the saved Fleet\nmodels and roles. A plan child can select a listed model with `model`, or use\nthe saved `role`/`profile` assignment. An Exact Fleet fixes each member's route.\n\n## TUI Modes\n\nPress `Tab` to complete composer menus or cycle through the visible modes\nwhen the composer is empty: **Plan → Work → Operate → Plan**. `Tab` never sends\nor queues composer text; use `Enter` to send or queue it.\nPress `Shift+Tab` to cycle permission posture (Ask → Auto-Review → Full Access).\nEvery change of effective permission writes a `Permissions changed to …` line\nin the transcript.\nPress `Ctrl+T` to cycle reasoning effort.\nRun `/mode` to open the mode picker, or switch directly with `/mode work`,\n`/mode plan`, or `/mode operate`.\n\n- **Plan**: design-first prompting. The stable primitive names remain familiar, but the runtime centrally refuses file mutation and shell execution. Read-only inspection and policy-allowed research, including deferred Web search/fetch, remain available. When a Plan turn successfully finishes a nonempty response, including a prose-only plan, the TUI asks how to continue: Work (Ask), Work (Auto-Review), or keep planning. Work sends the exact approved response as a normal follow-up and preserves that turn's existing To-dos, or adds one pending plan item without replacing unrelated work.\n- **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `bash`, `agent`, `workflow`, and `todo_write`, plus `create_goal`, `get_goal`, and `update_goal` so goal controls are available without discovery. The agent calls `create_goal` when a request is a durable objective; the host never infers a goal from wording, and the contract must not tell the model otherwise. Approval, sandbox, repository law, and managed policy decide what may execute.\n- **Operate**: Work at full strength, the \"ultra\" mode. Same tools and authority as Work; the difference is how they are used. Each substantive request becomes a goal (`create_goal`), the plan stays visible (`todo_write`), multi-part work runs through `workflow` and parallel `agent` workers, non-trivial changes get an independent reviewer before they are called done, long commands keep running in the background, and recurring or watch-type work is offered as an `automation` (created after approval). It keeps going until the goal is verified, blocked on you, or paused.\n\n`Act` and `/mode act` remain compatibility aliases for Work. Saved settings\nstill normalize to the internal value `agent`.\n\n### Tool availability by mode\n\n| Tool family | Plan | Work | Operate |\n|:---|:---:|:---:|:---:|\n| `read` and policy-allowed deferred research tools | yes | yes | yes |\n| `write` and `edit` | visible names; execution denied | approval- and policy-gated | same as Work |\n| `bash` | visible name; execution denied | approval- and policy-gated | same as Work; delegation is preferred when parallelism or isolation helps |\n| `agent` | yes, subject to child-depth authority | yes, subject to child-depth authority | yes, subject to child-depth authority |\n| Deferred native, MCP, and plugin tools | discoverable through `tool_search` when policy permits | same | same |\n| Paid or external-service tools | follows permission posture | follows permission posture | follows permission posture |\n| Access outside the workspace root | explicit trusted paths only | only through trusted paths or trust mode | same trusted-path/trust policy as Work; fleet profiles never widen it |\n\nOperate changes scheduling emphasis, not authority. It neither adds a\nmode-specific tool denial nor bypasses the active approval, sandbox, shell,\nask-rule, repository-law, or managed-policy boundary. Plan remains the\nmode-specific execution boundary for shell and write-capable tools; that\nauthority difference does not require a different primitive vocabulary.\n\n### Operate loop (one screen)\n\n```text\nUser message\n → small / chat / one-file? → parent does it (Work-equivalent tools)\n → multi-step work? → goal → named steps + dependencies + completion checks\n → Workflow phases → independent sub-agents in parallel\n → collect results → check evidence → hand off to the next phase\n → missing required result? → stop dependent work and repair the step\n → one independent task? → one direct sub-agent\n → parent integrates results and reports completed, blocked and next steps\n```\n\nLifecycle claims stay exact: dispatched ≠ settled ≠ verified.\n\n`allow_shell` controls whether `bash` can execute; it does not rename the tool\nor make mode the approval authority. Durable tasks and automation keep\nconservative omitted-field defaults and receive shell authority only when their\nsettings explicitly grant it. Stateful terminal/background controls are\nspecialized deferred tools rather than fields on the small foreground `bash`\nschema. Full Access changes the permission posture: the deterministic\nAuto-Review floor applies in the reviewing postures and is skipped under Full\nAccess, while repository-policy holds remain authoritative.\n\nAction-capable modes can discover the deferred `rlm` family through\n`tool_search`; its `open`, `eval`, `configure`, and `close` actions own persistent\nRLM sessions. The legacy split `rlm_*` spellings remain replay-only aliases.\nInside an RLM Python REPL, `sub_query_batch` fans out 1-16 cheap parallel child\ncalls pinned to `deepseek-v4-flash`.\n\nThe fast `deepseek-v4-flash` / thinking-off path is called Fin in the product\nlanguage. Fin is a seam for routing, summaries, cheap child calls, and\ncoordination work; it does not change approval behavior.\n\nThe orchestration controls remain available without taking over the starting\nscreen: `/auto` turns on Auto-Review so the agent just works, `/goal` keeps one\nobjective across turns, and `/workflow` prepares a repeatable ordered or\nfan-out workflow. They are directly callable and searchable through the full\ncommand palette, but are not pinned to the starter slash menu, idle welcome,\nfooter, or default Hotbar. A bare `/` instead opens the small task-oriented\nstarter set; use `/help` or the command palette for the complete inventory.\n\n`/goal ` sets a session objective with an optional token budget and\nkeeps active objectives visible as Work context. The agent may also create the\ngoal itself when a direct request describes a verifiable end state that will\ntake more than one turn (\"until the tests pass\", \"make X work end to end\"); it\nthen shows one receipt line and you can `/goal pause` or `/goal clear` it. Bare\n`/goal` shows progress (state, elapsed, continuations, and how to continue when\nno turn is running); with no goal and no conversation yet it prints usage.\n`/goal pause` stops goal continuation without changing the objective, `/goal\nresume` resumes and sends the objective back into the turn, `/goal complete`\nmarks it done, `/goal blocked` marks it blocked, and `/goal clear` removes it. Goal state does not change the active TUI mode,\npermission posture, or model route. This remains distinct from `--model auto`, which\nonly controls model and thinking selection.\n\nWorkflow builds on the same separation: a goal can ask the agent to keep\nworking, while Workflow supplies the repeatable workflow/progress surface for\nlarge fanout. In the UI, a Workflow run should be shown as an overlay on the\nmain screen, not as another mode beside Plan, Work, and Operate.\n\nApp-server clients can persist a thread-scoped goal with `thread/goal/set`, read\nit with `thread/goal/get`, and clear it with `thread/goal/clear`. That persisted\nrecord carries `active`, `paused`, `blocked`, `usage_limited`, `budget_limited`,\nor `complete` status plus token/time accounting fields for clients that need\nthread resume semantics.\n\n## Mode Persistence\n\nChoosing a mode interactively also sets the mode a fresh session starts in.\nTab/Shift+Tab cycling, the `Alt+A` / `Alt+P` / `Alt+Y` shortcuts, the hotbar's\nPlan/Work/Operate actions, and `/mode` all write `default_mode` to\n`~/.codewhale/settings.toml`, so switching to Operate survives a restart. The\nwrite happens off the event loop; if it fails, the TUI says so in a warning\ntoast rather than reverting silently on the next launch.\n\nMode, thinking level, and the model picker share one serialized writer, so the\nselection you made last is the one on disk — a burst of Tab presses cannot end\nup persisting whichever write happened to finish last — and a mode write never\nrolls back an unrelated key such as `default_model`.\n\nTwo paths deliberately do **not** rewrite the startup default: restoring a saved\nsession (which re-installs the mode that session was in) and a mode change\nrefused because a turn is in flight. The legacy `yolo` entry point installs Work\nplus Full Access, and `agent` is what it persists — `yolo` is a permission\nalias, never a startup mode.\n\nRe-selecting the mode you are already in is not a no-op. After a restored\nsession the live mode and `default_mode` routinely disagree, so choosing the\nlive mode again is how you make it durable; Codewhale confirms with a\n\"saved as startup default\" receipt rather than reporting \"already in that mode\".\n\nWhile a turn is running, every change to the live route is refused — mode,\nmodel, thinking level, and provider — no matter which surface you use. That\nnow includes the slash surfaces (`/mode`, `/model`, `/config `,\n`/config preset`), which are reachable mid-turn. Press\nEsc to interrupt first. The restart-only `default_mode` key is exempt, because\nit does not touch the running turn.\n\nCodewhale writes `settings.toml` under a lock that spans processes, and replaces\nthe file atomically, so a second Codewhale instance on the same home directory\ncannot lose your selection or read a half-written file. At exit, queued writes\nare flushed before the terminal is restored; anything that failed is printed on\nthe way out instead of disappearing with the alternate screen.\n\n## Compatibility Notes\n\n- Older settings files with `default_mode = \"normal\"` still load as `agent`; saving rewrites the normalized value.\n\n## Escape Key Behavior\n\n`Esc` is a cancel stack, not a mode switch.\n\n- Close slash menus or transient UI first.\n- Cancel the active request if a turn is running.\n- Discard a queued draft if the composer is empty.\n- Clear the current input if text is present.\n- Otherwise it is a no-op.\n\n## Permission Posture\n\nPermission posture controls tool approval and whether a turn may pause for a\nmissing user decision. It is one layer of the full\n[authorization order](AUTHORIZATION_ORDER.md), not a bypass for tool admission,\nrepository law, or sandbox enforcement. Cycle it with `Shift+Tab`, or edit it\nat runtime:\n\n```text\n/config\n# edit the approval_mode row to: suggest | auto | never\n```\n\nLegacy note: `/set approval_mode ...` was retired in favor of `/config`.\n\n- `suggest` (**Ask**, default): tool approvals may interrupt, and Codewhale asks\n when an unresolved user choice materially changes authority, cost, scope, or\n outcome.\n- `auto` (**Auto-Review**): reviews tool calls automatically. Deliberate user\n questions remain available in interactive sessions through `request_user_input`;\n a question parks the turn until answered, canceled, or its configured timeout\n expires. Headless `exec` withholds that tool because it has no responder.\n Tool safety holds remain separate from user questions. Two layers decide approvals. The\n **deterministic floor** (configured block rules plus the built-in safety\n floor) allows proven-safe calls and hard-blocks publish-like actions and\n destructive background/headless work; it is never model-reviewed. Fallback\n holds — calls the deterministic engine could not prove safe — escalate to a\n one-shot **model guardian** (v0.9.8) that returns risk, allow/deny, and a\n rationale. The guardian sees the exact held call and deterministic\n observations in separate JSON fields; conversation history, skill\n instructions, attachments, and expanded model context are excluded. It does\n not infer user intent or compute a generic user-intent score.\n High or critical risk cannot auto-run even if the model says allow. It has no\n tools, remembers no rules, and denies rather than truncates an oversized\n exact call. Exactly one reviewer request is made; incomplete or malformed\n output, timeout, cancellation, or provider failure fails closed. Headless\n adapters use the deterministic-only tier. Repo-law\n holds that explicitly require a person block in Auto-Review rather than\n opening a hidden approval modal.\n\nThe LLM reviewer is closest to OpenAI Codex's experimental Auto-Review at\ncommit [`6fc6b9d6d2580d62622fc9884b5f5707f6505a5e`](https://github.com/openai/codex/tree/6fc6b9d6d2580d62622fc9884b5f5707f6505a5e).\nCodex's [guardian entry point](https://github.com/openai/codex/blob/6fc6b9d6d2580d62622fc9884b5f5707f6505a5e/codex-rs/core/src/guardian/mod.rs)\nreconstructs conversation context and runs a dedicated review session.\nCodewhale deliberately adopts only the exact-action structured decision,\n90-second deadline, and fail-closed result. It does not copy Codex's transcript\nreconstruction, user-authorization score, reviewer tools, retries, persistent\nreview session, or denial ledger.\n\nKimi Code at commit\n[`1414d4602898f406e540b23342cb18db23ff9efc`](https://github.com/MoonshotAI/kimi-code/tree/1414d4602898f406e540b23342cb18db23ff9efc)\nalso has no LLM reviewer. Its ordered\n[permission policy](https://github.com/MoonshotAI/kimi-code/blob/1414d4602898f406e540b23342cb18db23ff9efc/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicyService.ts)\napplies explicit deny rules and then its\n[Auto policy](https://github.com/MoonshotAI/kimi-code/blob/1414d4602898f406e540b23342cb18db23ff9efc/packages/agent-core-v2/src/agent/permissionPolicy/policies/auto-mode-approve.ts)\nreturns `approve` directly. Codewhale uses the deterministic floor and guardian\ndescribed above, while keeping deliberate user questions available.\n\nThe sandbox and escalation baseline is grounded in DeepSeek Harness\n`0.1.0-rc.5` at\ncommit [`47f943859bef60e4160492346772ded9b24f765a`](https://github.com/deepseek-ai/deepseek-harness/tree/47f943859bef60e4160492346772ded9b24f765a):\nits [sandbox contract](https://github.com/deepseek-ai/deepseek-harness/blob/47f943859bef60e4160492346772ded9b24f765a/docs/subsystems/sandbox.md)\ndefines per-call `read-only`, `workspace-write`, and `danger-full-access`\nboundaries and forbids silent unconfined fallback; its\n[approval contract](https://github.com/deepseek-ai/deepseek-harness/blob/47f943859bef60e4160492346772ded9b24f765a/docs/subsystems/approval.md)\ngrants only `allowed-once` and fails closed on rejection, cancellation, or an\nunavailable answerer; and its\n[sandbox result contract](https://github.com/deepseek-ai/deepseek-harness/blob/47f943859bef60e4160492346772ded9b24f765a/packages/shell/bash-sandbox/README.md)\ntells the model to retry a denied command exactly once with the narrowest wider\nmode plus a justification. DeepSeek Harness does not add an LLM reviewer to\nthat path. Codewhale's autonomous posture adds only the single stateless\nguardian request described above; deterministic hard blocks remain\nnon-bypassable in the reviewing postures.\n- `bypass` (**Full Access**): ordinary tool calls do not show approval prompts,\n while deliberate user questions remain available. The deterministic\n Auto-Review floor is skipped — publish-like commands, destructive\n background/headless work, and the Windows npm-launcher platform hold do not\n strand a call the person already granted. Non-bypassable registered holds\n auto-approve instead of opening a contradictory modal. Repository-law and\n managed-policy holds fail closed as hard blocks instead of contradicting\n Full Access with an approval modal.\n- `never`: blocks any tool that is not considered safe/read-only; deliberate\n user questions remain available.\n\nThe effective posture and its question discipline are projected into every\nturn from the same runtime authority that gates tools. A mode/posture change is\ntherefore visible to the next turn. Untrusted runtime-generated input is\nnarrowed before metadata is built and cannot invent approval authority. An\nexplicit Full Access sub-agent handoff preserves the parent's standing posture\nso ordinary child work does not begin prompting again.\n\n### Children (sub-agents and fleet workers)\n\nChildren inherit the session posture faithfully rather than a bare\nauto-approve bit:\n\n- **Auto-Review**: a worker's held call goes through the same deterministic\n policy (proven-safe calls run; publish-like and destructive background work\n is hard-blocked) and, for holds it cannot prove safe, the same one-shot\n model guardian using the child's own session client. No prompt is ever\n opened for a child; an unavailable guardian denies, fail closed.\n- **Ask**: a call the role may delegate runs. A held call is raised as an\n approval prompt in the parent's UI (`agent::approval:`) when the\n host is an interactive TUI; the worker waits visibly (`waiting for user`)\n and the person's answer is routed back to it, whether the parent turn is\n idle or itself awaiting an approval. Hosts that cannot prompt deny with the\n reason.\n- **Full Access**: calls run, including destructive detached work — the\n Auto-Review floor is skipped exactly as it is for the parent turn. The\n execution envelope still applies and never widens.\n\nRole posture and the execution envelope are checked before and after this\ngate and never widen. Every decision a person did not make at a prompt is\nwritten to the audit log and to the child's transcript as a one-line note\n(`Auto-Review allowed 'bash' (low risk, model guardian): …`), visible when\nthe worker is focused.\n\n## Small-Screen Status Behavior\n\nWhen terminal height is constrained, the status area compacts first so header/chat/composer/footer remain visible:\n\n- Loading and queued status rows are budgeted by available height.\n- Queued previews collapse to compact summaries when full previews do not fit.\n- `/queue` workflows remain available; compact status only affects rendering density.\n\n## Workspace Boundary and Trust Mode\n\nBy default, file tools are restricted to the `--workspace` directory. Enable trust mode to allow file access outside the workspace:\n\n```text\n/trust on\n```\n\nBare `/trust` (like `/trust status`) only *reports* the current setting — it\ndoes not enable anything. Use `/trust off` to restrict access again.\n\nFull Access enables trust mode automatically.\n\n## MCP Behavior\n\nMCP tools are exposed as `mcp__` and use the same approval flow as\nbuilt-in tools. Read-only MCP helpers may auto-run in Ask and Auto-Review when\npolicy permits; MCP tools with possible side effects require approval. Full\nAccess does not bypass hard policy holds.\n\nA tool's own MCP annotations count only as far as their source is trusted. A\ntool from a reviewed, enabled plugin that declares `readOnlyHint: true` runs\nlike the built-in read helpers, with no prompt; the same claim from any other\nserver is ignored. A tool that declares `destructiveHint: true` never gets\nthat relaxation, even from a reviewed plugin, and its approval card says the\nserver marked it destructive. Full Access still runs it without a prompt, like\nany other tool that would ask.\n\nSee `MCP.md`.\n\n## Related CLI Flags\n\nRun `codewhale --help` for the canonical list. Common flags:\n\n- `-p, --prompt `: one-shot prompt mode (prints and exits)\n- `codewhale exec --auto --output-format stream-json `: run the tool-backed non-interactive agent and emit one JSON object per line for harnesses and backend wrappers. Exit codes: `0` on success, `1` for genuine task/agent failures, `75` (`EX_TEMPFAIL`) when the turn ended on a retryable infrastructure failure (provider/transport `network`/`timeout` after all in-session retries) so harnesses can tell a retryable infra exit apart from a task failure; the terminal stream `metadata` event's `error_category` carries the same classification\n- `codewhale exec --prompt-file ` / `cat prompt.txt | codewhale exec --prompt-file -`: read the prompt from a file or stdin instead of argv, for prompts past the OS per-argument limit (~128 KiB on Linux). Conflicts with a positional prompt, and a positional `-` is literal prompt text; `--prompt-file -` is refused with `--parent-death-watch`, which owns stdin\n- `codewhale exec --resume ` / `--session-id `: continue a saved session non-interactively\n- `codewhale exec --continue `: continue the most recent saved session for this workspace non-interactively\n- `codewhale fork ` / `codewhale fork --last`: copy a saved session into a new sibling session; forked sessions retain additive parent-session metadata and show that lineage in session listings\n- `--model `: when using the `codewhale` facade, forward a DeepSeek model override to the TUI\n- `--workspace `: workspace root for file tools\n- `-r, --resume `: resume a saved session\n- `-c, --continue`: resume the most recent session in this workspace\n- `--max-subagents `: clamp to `1..=128`\n- `--mouse-capture` / `--no-mouse-capture`: opt in or out of internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. Mouse capture is enabled by default on non-Windows terminals and on Windows Terminal/ConEmu/Cmder so drag selection copies only transcript text, removes visual wrap-column line breaks from paragraphs, and stays scoped to the transcript pane; hold Shift while dragging or use `--no-mouse-capture` for raw terminal selection. It defaults off on legacy Windows console (CMD without `WT_SESSION` / `ConEmuPID`) and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where the terminal advertises mouse support but forwards SGR mouse events as raw text (#878, #898). Use `--mouse-capture` to opt in anywhere it's defaulted off. Raw terminal selection may cross the right workbar and include visual wraps because the terminal, not the TUI, owns the selection.\n- `--profile `: select config profile\n- `--config `: config file path\n- `-v, --verbose`: verbose logging\n\n## Branching and Rollback\n\nCodewhale has three related but intentionally separate recovery paths:\n\n- `codewhale fork ` creates a new saved session from an existing saved\n conversation and records the source session id. This is the safe way to\n explore a different answer path without overwriting the original session.\n- Esc-Esc backtrack rewinds the live transcript to a previous user prompt and\n restores that prompt into the composer for editing. It does not change\n files; `/undo` afterwards puts back the files that request changed.\n- `/restore` and the `revert_turn` tool restore workspace files from side-git\n snapshots. `/restore list [N]` lists more snapshot options before choosing a\n rollback point. They do not rewrite conversation history.\n\nA Pi-style in-file tree browser is a larger UI/data-model project. v0.8.40\nships the bounded fork/backtrack primitives and explicit lineage metadata.\n" + "sha256": "2beb562e60c861e8d48781eb57e854c4d1a5177e8cc2f179eadc80a45edbbb84", + "content": "# Modes and Permission Postures\n\n> 阅读简体中文版:[zh_hans/MODES.md](zh_hans/MODES.md)\n\nCodewhale has three related concepts:\n\n- **TUI mode**: what kind of visible interaction you're in (Plan/Work/Operate).\n- **Permission posture**: how aggressively the UI asks before executing tools.\n- **Workflow**: named steps that coordinate sub-agents, with dependencies and\n results, available in any TUI mode.\n\nModel selection is separate. `--model auto` and `/model auto` route each turn to\na concrete model and thinking level; they are not TUI modes and are not part of\nthe `Tab` cycle.\n\nWorkflow runs named steps through the same sub-agent runtime; Fleet manages\ntheir saved roles and model assignments. A workflow adds ordering, result\nhandoffs, gates and a progress view. The active mode and permission posture\nstill control what each step may execute.\n\nBefore assigning steps, query `agent(action=\"roster\")` for the saved Fleet\nmodels and roles. A plan child can select a listed model with `model`, or use\nthe saved `role`/`profile` assignment. An Exact Fleet fixes each member's route.\n\n## TUI Modes\n\nPress `Tab` to complete composer menus or cycle through the visible modes\nwhen the composer is empty: **Plan → Work → Operate → Plan**. `Tab` never sends\nor queues composer text; use `Enter` to send or queue it.\nPress `Shift+Tab` to cycle permission posture (Ask → Auto-Review → Full Access).\nEvery change of effective permission writes a `Permissions changed to …` line\nin the transcript.\nPress `Ctrl+T` to cycle reasoning effort.\nRun `/mode` to open the mode picker, or switch directly with `/mode work`,\n`/mode plan`, or `/mode operate`.\n\n- **Plan**: design-first prompting. The stable primitive names remain familiar, but the runtime centrally refuses file mutation and shell execution. Read-only inspection and policy-allowed research, including deferred Web search/fetch, remain available. When a Plan turn successfully finishes a nonempty response, including a prose-only plan, the TUI asks how to continue: Work (Ask), Work (Auto-Review), or keep planning. Work sends the exact approved response as a normal follow-up and preserves that turn's existing To-dos, or adds one pending plan item without replacing unrelated work.\n- **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `file_search`, `bash`, `agent`, `workflow`, `todo_write`, and `load_skill`, plus `create_goal`, `get_goal`, and `update_goal` so goal controls are available without discovery. The agent calls `create_goal` when a request is a durable objective; the host never infers a goal from wording, and the contract must not tell the model otherwise. Approval, sandbox, repository law, and managed policy decide what may execute.\n- **Operate**: Work at full strength, the \"ultra\" mode. Same tools and authority as Work; the difference is how they are used. Each substantive request becomes a goal (`create_goal`), the plan stays visible (`todo_write`), multi-part work runs through `workflow` and parallel `agent` workers, non-trivial changes get an independent reviewer before they are called done, long commands keep running in the background, and recurring or watch-type work is offered as an `automation` (created after approval). It keeps going until the goal is verified, blocked on you, or paused.\n\n`Act` and `/mode act` remain compatibility aliases for Work. Saved settings\nstill normalize to the internal value `agent`.\n\n### Tool availability by mode\n\n| Tool family | Plan | Work | Operate |\n|:---|:---:|:---:|:---:|\n| `read` and policy-allowed deferred research tools | yes | yes | yes |\n| `write` and `edit` | visible names; execution denied | approval- and policy-gated | same as Work |\n| `bash` | visible name; execution denied | approval- and policy-gated | same as Work; delegation is preferred when parallelism or isolation helps |\n| `agent` | yes, subject to child-depth authority | yes, subject to child-depth authority | yes, subject to child-depth authority |\n| Deferred native, MCP, and plugin tools | discoverable through `tool_search` when policy permits | same | same |\n| Paid or external-service tools | follows permission posture | follows permission posture | follows permission posture |\n| Access outside the workspace root | explicit trusted paths only | only through trusted paths or trust mode | same trusted-path/trust policy as Work; fleet profiles never widen it |\n\nOperate changes scheduling emphasis, not authority. It neither adds a\nmode-specific tool denial nor bypasses the active approval, sandbox, shell,\nask-rule, repository-law, or managed-policy boundary. Plan remains the\nmode-specific execution boundary for shell and write-capable tools; that\nauthority difference does not require a different primitive vocabulary.\n\n### Operate loop (one screen)\n\n```text\nUser message\n → small / chat / one-file? → parent does it (Work-equivalent tools)\n → multi-step work? → goal → named steps + dependencies + completion checks\n → Workflow phases → independent sub-agents in parallel\n → collect results → check evidence → hand off to the next phase\n → missing required result? → stop dependent work and repair the step\n → one independent task? → one direct sub-agent\n → parent integrates results and reports completed, blocked and next steps\n```\n\nLifecycle claims stay exact: dispatched ≠ settled ≠ verified.\n\n`allow_shell` controls whether `bash` can execute; it does not rename the tool\nor make mode the approval authority. Durable tasks and automation keep\nconservative omitted-field defaults and receive shell authority only when their\nsettings explicitly grant it. Stateful terminal/background controls are\nspecialized deferred tools rather than fields on the small foreground `bash`\nschema. Full Access changes the permission posture: the deterministic\nAuto-Review floor applies in the reviewing postures and is skipped under Full\nAccess, while repository-policy holds remain authoritative.\n\nAction-capable modes can discover the deferred `rlm` family through\n`tool_search`; its `open`, `eval`, `configure`, and `close` actions own persistent\nRLM sessions. The legacy split `rlm_*` spellings remain replay-only aliases.\nInside an RLM Python REPL, `sub_query_batch` fans out 1-16 cheap parallel child\ncalls pinned to `deepseek-v4-flash`.\n\nThe fast `deepseek-v4-flash` / thinking-off path is called Fin in the product\nlanguage. Fin is a seam for routing, summaries, cheap child calls, and\ncoordination work; it does not change approval behavior.\n\nThe orchestration controls remain available without taking over the starting\nscreen: `/auto` turns on Auto-Review so the agent just works, `/goal` keeps one\nobjective across turns, and `/workflow` prepares a repeatable ordered or\nfan-out workflow. They are directly callable and searchable through the full\ncommand palette, but are not pinned to the starter slash menu, idle welcome,\nfooter, or default Hotbar. A bare `/` instead opens the small task-oriented\nstarter set; use `/help` or the command palette for the complete inventory.\n\n`/goal ` sets a session objective with an optional token budget and\nkeeps active objectives visible as Work context. The agent may also create the\ngoal itself when a direct request describes a verifiable end state that will\ntake more than one turn (\"until the tests pass\", \"make X work end to end\"); it\nthen shows one receipt line and you can `/goal pause` or `/goal clear` it. Bare\n`/goal` shows progress (state, elapsed, continuations, and how to continue when\nno turn is running); with no goal and no conversation yet it prints usage.\n`/goal pause` stops goal continuation without changing the objective, `/goal\nresume` resumes and sends the objective back into the turn, `/goal complete`\nmarks it done, `/goal blocked` marks it blocked, and `/goal clear` removes it. Goal state does not change the active TUI mode,\npermission posture, or model route. This remains distinct from `--model auto`, which\nonly controls model and thinking selection.\n\nWorkflow builds on the same separation: a goal can ask the agent to keep\nworking, while Workflow supplies the repeatable workflow/progress surface for\nlarge fanout. In the UI, a Workflow run should be shown as an overlay on the\nmain screen, not as another mode beside Plan, Work, and Operate.\n\nApp-server clients can persist a thread-scoped goal with `thread/goal/set`, read\nit with `thread/goal/get`, and clear it with `thread/goal/clear`. That persisted\nrecord carries `active`, `paused`, `blocked`, `usage_limited`, `budget_limited`,\nor `complete` status plus token/time accounting fields for clients that need\nthread resume semantics.\n\n## Mode Persistence\n\nChoosing a mode interactively also sets the mode a fresh session starts in.\nTab/Shift+Tab cycling, the `Alt+A` / `Alt+P` / `Alt+Y` shortcuts, the hotbar's\nPlan/Work/Operate actions, and `/mode` all write `default_mode` to\n`~/.codewhale/settings.toml`, so switching to Operate survives a restart. The\nwrite happens off the event loop; if it fails, the TUI says so in a warning\ntoast rather than reverting silently on the next launch.\n\nMode, thinking level, and the model picker share one serialized writer, so the\nselection you made last is the one on disk — a burst of Tab presses cannot end\nup persisting whichever write happened to finish last — and a mode write never\nrolls back an unrelated key such as `default_model`.\n\nTwo paths deliberately do **not** rewrite the startup default: restoring a saved\nsession (which re-installs the mode that session was in) and a mode change\nrefused because a turn is in flight. The legacy `yolo` entry point installs Work\nplus Full Access, and `agent` is what it persists — `yolo` is a permission\nalias, never a startup mode.\n\nRe-selecting the mode you are already in is not a no-op. After a restored\nsession the live mode and `default_mode` routinely disagree, so choosing the\nlive mode again is how you make it durable; Codewhale confirms with a\n\"saved as startup default\" receipt rather than reporting \"already in that mode\".\n\nWhile a turn is running, every change to the live route is refused — mode,\nmodel, thinking level, and provider — no matter which surface you use. That\nnow includes the slash surfaces (`/mode`, `/model`, `/config `,\n`/config preset`), which are reachable mid-turn. Press\nEsc to interrupt first. The restart-only `default_mode` key is exempt, because\nit does not touch the running turn.\n\nCodewhale writes `settings.toml` under a lock that spans processes, and replaces\nthe file atomically, so a second Codewhale instance on the same home directory\ncannot lose your selection or read a half-written file. At exit, queued writes\nare flushed before the terminal is restored; anything that failed is printed on\nthe way out instead of disappearing with the alternate screen.\n\n## Compatibility Notes\n\n- Older settings files with `default_mode = \"normal\"` still load as `agent`; saving rewrites the normalized value.\n\n## Escape Key Behavior\n\n`Esc` is a cancel stack, not a mode switch.\n\n- Close slash menus or transient UI first.\n- Cancel the active request if a turn is running.\n- Discard a queued draft if the composer is empty.\n- Clear the current input if text is present.\n- Otherwise it is a no-op.\n\n## Permission Posture\n\nPermission posture controls tool approval and whether a turn may pause for a\nmissing user decision. It is one layer of the full\n[authorization order](AUTHORIZATION_ORDER.md), not a bypass for tool admission,\nrepository law, or sandbox enforcement. Cycle it with `Shift+Tab`, or edit it\nat runtime:\n\n```text\n/config\n# edit the approval_mode row to: suggest | auto | never\n```\n\nLegacy note: `/set approval_mode ...` was retired in favor of `/config`.\n\n- `suggest` (**Ask**, default): tool approvals may interrupt, and Codewhale asks\n when an unresolved user choice materially changes authority, cost, scope, or\n outcome.\n- `auto` (**Auto-Review**): reviews tool calls automatically. Deliberate user\n questions remain available in interactive sessions through `request_user_input`;\n a question parks the turn until answered, canceled, or its configured timeout\n expires. Headless `exec` withholds that tool because it has no responder.\n Tool safety holds remain separate from user questions. Two layers decide approvals. The\n **deterministic floor** (configured block rules plus the built-in safety\n floor) allows proven-safe calls and hard-blocks publish-like actions and\n destructive background/headless work; it is never model-reviewed. Fallback\n holds — calls the deterministic engine could not prove safe — escalate to a\n one-shot **model guardian** (v0.9.8) that returns risk, allow/deny, and a\n rationale. The guardian sees the exact held call and deterministic\n observations in separate JSON fields; conversation history, skill\n instructions, attachments, and expanded model context are excluded. It does\n not infer user intent or compute a generic user-intent score.\n High or critical risk cannot auto-run even if the model says allow. It has no\n tools, remembers no rules, and denies rather than truncates an oversized\n exact call. Exactly one reviewer request is made; incomplete or malformed\n output, timeout, cancellation, or provider failure fails closed. Headless\n adapters use the deterministic-only tier. Repo-law\n holds that explicitly require a person block in Auto-Review rather than\n opening a hidden approval modal.\n\nThe LLM reviewer is closest to OpenAI Codex's experimental Auto-Review at\ncommit [`6fc6b9d6d2580d62622fc9884b5f5707f6505a5e`](https://github.com/openai/codex/tree/6fc6b9d6d2580d62622fc9884b5f5707f6505a5e).\nCodex's [guardian entry point](https://github.com/openai/codex/blob/6fc6b9d6d2580d62622fc9884b5f5707f6505a5e/codex-rs/core/src/guardian/mod.rs)\nreconstructs conversation context and runs a dedicated review session.\nCodewhale deliberately adopts only the exact-action structured decision,\n90-second deadline, and fail-closed result. It does not copy Codex's transcript\nreconstruction, user-authorization score, reviewer tools, retries, persistent\nreview session, or denial ledger.\n\nKimi Code at commit\n[`1414d4602898f406e540b23342cb18db23ff9efc`](https://github.com/MoonshotAI/kimi-code/tree/1414d4602898f406e540b23342cb18db23ff9efc)\nalso has no LLM reviewer. Its ordered\n[permission policy](https://github.com/MoonshotAI/kimi-code/blob/1414d4602898f406e540b23342cb18db23ff9efc/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicyService.ts)\napplies explicit deny rules and then its\n[Auto policy](https://github.com/MoonshotAI/kimi-code/blob/1414d4602898f406e540b23342cb18db23ff9efc/packages/agent-core-v2/src/agent/permissionPolicy/policies/auto-mode-approve.ts)\nreturns `approve` directly. Codewhale uses the deterministic floor and guardian\ndescribed above, while keeping deliberate user questions available.\n\nThe sandbox and escalation baseline is grounded in DeepSeek Harness\n`0.1.0-rc.5` at\ncommit [`47f943859bef60e4160492346772ded9b24f765a`](https://github.com/deepseek-ai/deepseek-harness/tree/47f943859bef60e4160492346772ded9b24f765a):\nits [sandbox contract](https://github.com/deepseek-ai/deepseek-harness/blob/47f943859bef60e4160492346772ded9b24f765a/docs/subsystems/sandbox.md)\ndefines per-call `read-only`, `workspace-write`, and `danger-full-access`\nboundaries and forbids silent unconfined fallback; its\n[approval contract](https://github.com/deepseek-ai/deepseek-harness/blob/47f943859bef60e4160492346772ded9b24f765a/docs/subsystems/approval.md)\ngrants only `allowed-once` and fails closed on rejection, cancellation, or an\nunavailable answerer; and its\n[sandbox result contract](https://github.com/deepseek-ai/deepseek-harness/blob/47f943859bef60e4160492346772ded9b24f765a/packages/shell/bash-sandbox/README.md)\ntells the model to retry a denied command exactly once with the narrowest wider\nmode plus a justification. DeepSeek Harness does not add an LLM reviewer to\nthat path. Codewhale's autonomous posture adds only the single stateless\nguardian request described above; deterministic hard blocks remain\nnon-bypassable in the reviewing postures.\n- `bypass` (**Full Access**): ordinary tool calls do not show approval prompts,\n while deliberate user questions remain available. The deterministic\n Auto-Review floor is skipped — publish-like commands, destructive\n background/headless work, and the Windows npm-launcher platform hold do not\n strand a call the person already granted. Non-bypassable registered holds\n auto-approve instead of opening a contradictory modal. Repository-law and\n managed-policy holds fail closed as hard blocks instead of contradicting\n Full Access with an approval modal.\n- `never`: blocks any tool that is not considered safe/read-only; deliberate\n user questions remain available.\n\nThe effective posture and its question discipline are projected into every\nturn from the same runtime authority that gates tools. A mode/posture change is\ntherefore visible to the next turn. Untrusted runtime-generated input is\nnarrowed before metadata is built and cannot invent approval authority. An\nexplicit Full Access sub-agent handoff preserves the parent's standing posture\nso ordinary child work does not begin prompting again.\n\n### Children (sub-agents and fleet workers)\n\nChildren inherit the session posture faithfully rather than a bare\nauto-approve bit:\n\n- **Auto-Review**: a worker's held call goes through the same deterministic\n policy (proven-safe calls run; publish-like and destructive background work\n is hard-blocked) and, for holds it cannot prove safe, the same one-shot\n model guardian using the child's own session client. No prompt is ever\n opened for a child; an unavailable guardian denies, fail closed.\n- **Ask**: a call the role may delegate runs. A held call is raised as an\n approval prompt in the parent's UI (`agent::approval:`) when the\n host is an interactive TUI; the worker waits visibly (`waiting for user`)\n and the person's answer is routed back to it, whether the parent turn is\n idle or itself awaiting an approval. Hosts that cannot prompt deny with the\n reason.\n- **Full Access**: calls run, including destructive detached work — the\n Auto-Review floor is skipped exactly as it is for the parent turn. The\n execution envelope still applies and never widens.\n\nRole posture and the execution envelope are checked before and after this\ngate and never widen. Every decision a person did not make at a prompt is\nwritten to the audit log and to the child's transcript as a one-line note\n(`Auto-Review allowed 'bash' (low risk, model guardian): …`), visible when\nthe worker is focused.\n\n## Small-Screen Status Behavior\n\nWhen terminal height is constrained, the status area compacts first so header/chat/composer/footer remain visible:\n\n- Loading and queued status rows are budgeted by available height.\n- Queued previews collapse to compact summaries when full previews do not fit.\n- `/queue` workflows remain available; compact status only affects rendering density.\n\n## Workspace Boundary and Trust Mode\n\nBy default, file tools are restricted to the `--workspace` directory. Enable trust mode to allow file access outside the workspace:\n\n```text\n/trust on\n```\n\nBare `/trust` (like `/trust status`) only *reports* the current setting — it\ndoes not enable anything. Use `/trust off` to restrict access again.\n\nFull Access enables trust mode automatically.\n\n## MCP Behavior\n\nMCP tools are exposed as `mcp__` and use the same approval flow as\nbuilt-in tools. Read-only MCP helpers may auto-run in Ask and Auto-Review when\npolicy permits; MCP tools with possible side effects require approval. Full\nAccess does not bypass hard policy holds.\n\nA tool's own MCP annotations count only as far as their source is trusted. A\ntool from a reviewed, enabled plugin that declares `readOnlyHint: true` runs\nlike the built-in read helpers, with no prompt; the same claim from any other\nserver is ignored. A tool that declares `destructiveHint: true` never gets\nthat relaxation, even from a reviewed plugin, and its approval card says the\nserver marked it destructive. Full Access still runs it without a prompt, like\nany other tool that would ask.\n\nSee `MCP.md`.\n\n## Related CLI Flags\n\nRun `codewhale --help` for the canonical list. Common flags:\n\n- `-p, --prompt `: one-shot prompt mode (prints and exits)\n- `codewhale exec --auto --output-format stream-json `: run the tool-backed non-interactive agent and emit one JSON object per line for harnesses and backend wrappers. Exit codes: `0` on success, `1` for genuine task/agent failures, `75` (`EX_TEMPFAIL`) when the turn ended on a retryable infrastructure failure (provider/transport `network`/`timeout` after all in-session retries) so harnesses can tell a retryable infra exit apart from a task failure; the terminal stream `metadata` event's `error_category` carries the same classification\n- `codewhale exec --prompt-file ` / `cat prompt.txt | codewhale exec --prompt-file -`: read the prompt from a file or stdin instead of argv, for prompts past the OS per-argument limit (~128 KiB on Linux). Conflicts with a positional prompt, and a positional `-` is literal prompt text; `--prompt-file -` is refused with `--parent-death-watch`, which owns stdin\n- `codewhale exec --resume ` / `--session-id `: continue a saved session non-interactively\n- `codewhale exec --continue `: continue the most recent saved session for this workspace non-interactively\n- `codewhale fork ` / `codewhale fork --last`: copy a saved session into a new sibling session; forked sessions retain additive parent-session metadata and show that lineage in session listings\n- `--model `: when using the `codewhale` facade, forward a DeepSeek model override to the TUI\n- `--workspace `: workspace root for file tools\n- `-r, --resume `: resume a saved session\n- `-c, --continue`: resume the most recent session in this workspace\n- `--max-subagents `: clamp to `1..=128`\n- `--mouse-capture` / `--no-mouse-capture`: opt in or out of internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. Mouse capture is enabled by default on non-Windows terminals and on Windows Terminal/ConEmu/Cmder so drag selection copies only transcript text, removes visual wrap-column line breaks from paragraphs, and stays scoped to the transcript pane; hold Shift while dragging or use `--no-mouse-capture` for raw terminal selection. It defaults off on legacy Windows console (CMD without `WT_SESSION` / `ConEmuPID`) and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where the terminal advertises mouse support but forwards SGR mouse events as raw text (#878, #898). Use `--mouse-capture` to opt in anywhere it's defaulted off. Raw terminal selection may cross the right workbar and include visual wraps because the terminal, not the TUI, owns the selection.\n- `--profile `: select config profile\n- `--config `: config file path\n- `-v, --verbose`: verbose logging\n\n## Branching and Rollback\n\nCodewhale has three related but intentionally separate recovery paths:\n\n- `codewhale fork ` creates a new saved session from an existing saved\n conversation and records the source session id. This is the safe way to\n explore a different answer path without overwriting the original session.\n- Esc-Esc backtrack rewinds the live transcript to a previous user prompt and\n restores that prompt into the composer for editing. It does not change\n files; `/undo` afterwards puts back the files that request changed.\n- `/restore` and the `revert_turn` tool restore workspace files from side-git\n snapshots. `/restore list [N]` lists more snapshot options before choosing a\n rollback point. They do not rewrite conversation history.\n\nA Pi-style in-file tree browser is a larger UI/data-model project. v0.8.40\nships the bounded fork/backtrack primitives and explicit lineage metadata.\n" } ] }, @@ -107,8 +107,8 @@ "sources": [ { "path": "docs/TOOL_SURFACE.md", - "sha256": "ecfb9e76e13225dabd3313b89c40c8bc904beb8be1d88e370151df72a90bc61a", - "content": "# Tool surface\n\n> 阅读简体中文版:[zh_hans/TOOL_SURFACE.md](zh_hans/TOOL_SURFACE.md)。\n\nThis document describes the current model-facing tool contract. The v0.9.1\ncutover that produced it is recorded in `docs/RUNTIME_SIMPLIFICATION_DESIGN.md`;\nread the workspace version from `Cargo.toml`, not from this line. The registry\nremains larger than the first-turn catalog so\nsaved transcripts can replay and uncommon capabilities can be loaded on demand.\nThe model should learn one canonical name for each common operation.\n\nImplementation sources:\n\n- `crates/tui/src/core/engine/tool_catalog.rs` owns the eager/deferred catalog.\n- `crates/tui/src/tools/registry.rs` registers canonical tools and hidden aliases.\n- `crates/tui/src/tools/{file,file_tool,shell}.rs` own the small foreground\n primitive behavior and schemas; the other native tools remain searchable.\n- `docs/RUNTIME_SIMPLIFICATION_DESIGN.md` records the v0.9.1 cutover and receipt.\n\n## Default-active contract\n\nNew turns start with twelve eager native names plus synthetic `tool_search`:\n\n1. `read`\n2. `write`\n3. `edit`\n4. `file_search`\n5. `bash`\n6. `agent`\n7. `workflow`\n8. `todo_write`\n9. `create_goal`\n10. `get_goal`\n11. `update_goal`\n12. `load_skill`\n13. `tool_search` (synthetic, always active)\n\nThe twelve native names are `DEFAULT_ACTIVE_NATIVE_TOOLS` in\n`crates/tui/src/core/engine/tool_catalog.rs`, pinned by\n`default_active_contract_keeps_discovery_and_core_tools_eager`. An authority\nboundary may remove `agent` at the maximum child depth, but route size alone\nmust not change this core vocabulary.\n\nThe direct schemas deliberately stay small:\n\n| Tool | Input | Purpose |\n|---|---|---|\n| `read` | `path`, optional `offset`, optional `limit` | Read a bounded file window with explicit continuation or truncation notices. |\n| `write` | `path`, `content` | Create or replace a file. |\n| `edit` | `path`, `edits` | Apply one or more unambiguous text replacements against one original snapshot. |\n| `bash` | `command`, optional `timeout`, optional `description` | Run one cancellable foreground shell command and return a bounded tail. The optional description is display-only. |\n| `agent` | delegated task and optional scope/context controls | Start or inspect focused child work. |\n| `workflow` | plan/script/source_path plus run controls | Coordinate multi-agent phases with dependencies and completion checks. |\n| `todo_write` | complete replacement list of `{content, status}` items | Keep optional, agent-owned progress notes for genuinely multi-step work. |\n| `create_goal` | objective plus optional budget | Start the session goal the turn works toward. |\n| `get_goal` | none | Read the active goal and its progress. |\n| `update_goal` | terminal status | Mark the goal complete or blocked. |\n| `tool_search` | `query`, optional matching controls | Discover policy-allowed deferred tools and add selected schemas to this conversation's toolbox. |\n\nMode is an authority decision, not a synonym system. Plan, Work, and Operate\nuse the same primitive identities. Plan centrally refuses `write`, `edit`, and\n`bash`; Work and Operate still pass those calls through approval, sandbox,\ntrusted-path, repository-law, and managed-policy gates. Full Access changes\nordinary approval behavior but does not bypass hard safety or repository law.\n\n`update_plan` remains registered only for saved-artifact compatibility and is\nnot model-visible. `tasks`, `Git`, `Run`, `Web`, `remember`, and other\nspecialized capabilities are searchable rather than first-turn ceremony.\n\nA Bash description appears as a muted, bounded note under the command on the\nTUI shell card, including on automatic runs. It never replaces the command and\nis excluded from execution, exact approval keys and Auto-Review input. The\noriginal description remains in the recorded tool input and exports. Approval\nprompts, `/jobs`, the activity inspector, ACP and other Runtime clients keep\nshowing the command; this change adds no description row to those surfaces.\n\n## Local code execution\n\n`code_execution` (Python) and `js_execution` (Node.js) use the same\npermission-aware local launcher as workspace task gates and test runs. Ordinary\napproval retains the effective session policy. To retry a denied exact call\nwith wider permissions, supply `sandbox_permissions` (`workspace-write` or\n`danger-full-access`) and a nonempty `justification`; Ask mode requires explicit\none-shot user approval. The approved policy affects only that call.\n\nBoth tools read source from stdin, return stdout/stderr/return_code, and keep\nthe existing timeout and process-tree cleanup. They are not persistent REPLs;\ntracebacks refer to stdin rather than a temporary script path. The separate\nRLM/REPL kernel is not changed by this launcher integration.\n\nPlatform/backend limits of the shared launcher still apply: no available local\nwrapper means workspace-write remains unenforced, while read-only is refused.\nAn external sandbox session cannot use these local interpreter tools and must\nuse its shell execution path. See [sandbox limits](SANDBOX.md).\n\n## Deferred and dynamic tools\n\n`Web` is conditional and deferred. It is discoverable through `tool_search`\nonly when the active policy and runtime backend permit it. Read-only\nchildren retain its read-only search/fetch evidence path; read-only authority\ndoes not mean \"unable to research.\"\n\nThe durable `github`, `automation`, and `rlm` action families are also deferred\nby default. `rlm` owns `open`, `eval`, `configure`, and `close` actions for a\npersistent local Python session (a subprocess with a scrubbed environment, not\nan OS sandbox). Inline ```` ```repl ```` fences in a reply run in the same kind of\nkernel only when `code_execution` is on the turn's surface (never in Plan mode),\nonly when the fence opens its own line, and only after `code_execution`'s\napproval under the session posture. Feature-gated native tools may be added to\nthe active or deferred catalog only when their implementation and host\ndependencies are available.\n\nMCP tools are dynamic. Successfully connected servers register names such as\n`mcp__` from `~/.codewhale/mcp.json`; a failed or disabled server\nmust not be presented as available. MCP and plugin tools are deferred unless a\nuser explicitly names them in `[tools].always_load`.\n\nFor an unstarted configured server, an MCP-focused `tool_search` first\nperforms bounded discovery under the current turn's server/tool ceiling.\nIt searches real server-provided schemas after connection, rather than\ninventing `mcp_*` definitions. A search inside `execute_tools` describes\nthose schemas without activating them; a direct search uses the existing\nbounded activation cache. Ordinary unrelated searches leave optional servers\nunstarted. The CLI's standalone MCP inspection commands own a separate pool.\n\n### Code mode (`execute_tools`)\n\n`execute_tools` is engine-injected, alongside the synthetic interpreter tools.\nIt runs a JavaScript program whose only host surface is\n`await tools.call(name, args)`, and it is the default way to compose several\ntool calls — MCP and plugin tools included — without round-tripping every\nintermediate result through the conversation. It is hidden from Plan mode and\nrefused under a worker authority envelope.\n\n- **One gate.** In a session turn every nested call is sent back to the turn\n loop and planned exactly like a direct call: deny/allow lists, preparation\n (MCP `readOnlyHint`/`destructiveHint`), `tool_call_before` hooks, ask-rules,\n Auto-Review, repository law, and the Computer Use consent refusal. MCP calls\n run through the session MCP pool. Approving the program grants nothing, so\n `execute_tools` itself is auto-approved. If the permission posture changes\n while a program runs, its remaining nested calls are refused (an approved\n call survives only an equal or broader posture, as for a direct call) and\n the model retries them under the new posture.\n- **Approvals suspend the program.** A nested call that needs approval raises\n the normal approval card (named `execute_tools program call: ...`) and the\n program waits; allow resumes it, deny fails only that nested call as an\n exception the program can catch. Time spent waiting on a decision does not\n count against the program's run deadline, which is the turn's remaining\n wall clock.\n- **Receipts.** The result lists every nested call with its decision (`auto`,\n `approved`, `denied`, `refused`) and status (`ok`, `failed`, `refused`,\n `in_flight`). A program that hits its deadline still returns the receipt;\n `in_flight` calls were cancelled and may have partially run. Each nested\n result is `{content, metadata, truncated}`; an oversized result keeps that\n shape, and `truncated` names the original size and the spillover file with\n the full output.\n- **Discovery without re-pinning.** Inside a program,\n `tools.call('tool_search', {query})` returns matching deferred tools with\n their input schemas and does not activate them, so the request's tool array\n and the session-pinned prefix do not change.\n- **Stays direct:** `agent`, `workflow`, `request_user_input`, nested\n `execute_tools`, interactive shells, sandbox escalation, Computer Use\n consent and scripts, and MCP sign-in (`mcp__authenticate`).\n\nCode mode is on by default (`[features] code_mode = true`), which makes\n`execute_tools` eager from the first request; direct tools and `tool_search`\nstay available either way. Set `code_mode = false` (or run with\n`--disable code_mode`) to go back to deferring `execute_tools` behind\n`tool_search`. The flag is session configuration, so the prompt prefix stays\nstable within a session. Without an engine turn (sub-agents), a program keeps\nthe conservative profile: read-only, auto-approved native calls only, no MCP.\n\n### Conversation toolbox cache\n\nA successful search activation is remembered by name for the current\nconversation. The cache holds at most eight deferred names and 16 KiB of\nserialized schemas, evicts least-recently-used entries, and revalidates every\nentry against the current catalog and policy before advertising it again. A\nsession sync clears it. The cache cannot resurrect a removed, denied, or\nnewly-eager tool.\n\nEach subagent gets its own policy-filtered deferred catalog, always-present\n`tool_search`, and bounded activation cache. Forked messages and instructions\nremain in context, but the child cache starts empty and discovers tools locally;\nneither forked context nor a cache can become a discovery allowlist. A child can\nstill search every tool its own authority permits, including Web search/fetch\nfor read-only research roles.\n\n## Inspect the model-client request tool payload\n\nRun `/tools` after a model turn to inspect a bounded projection of the exact\ntool field in the latest prepared model-client request. `/tools json` emits the\nsame evidence as bounded machine-readable JSON. Both formats open in a pager;\nthey are not copied into transcript history. `/tool-studio` remains a human-\ncommand compatibility alias; it is not a model tool.\n\nThe snapshot distinguishes an absent tool field from a present empty array. It\nreports the exact model-client tool JSON byte count and SHA-256 digest only when\nmeasurement fits the one-MiB inspection bound; larger payloads stay unavailable.\nProvider adapters may transform, sanitize, or omit those fields while building\na provider-specific wire body, so `/tools` marks provider delivery and the wire\npayload unavailable. Capture and rendering are bounded: retained schemas,\ndescriptions, caller lists, catalog rows, turn IDs, and payload measurement all\ncarry explicit truncation, omission, or unavailable receipts. The snapshot stays\nin memory only for the current session and is replaced on each prepared request.\n\nProvider, model, approval, registry provenance, and runtime capability metadata\nare not fields in the request tool schema. `/tools` therefore reports them as\nunavailable instead of joining against mutable state or inferring values. Use\nthe separate route and permission receipts for those facts.\n\n## Modes and permission postures\n\nModes and permission postures are separate controls:\n\n- **Plan** keeps the stable primitive vocabulary but centrally refuses shell\n execution and file mutation.\n- **Work** is ordinary interactive execution.\n- **Operate** uses the same direct-tool authority as Work. Small work stays\n direct; multi-step delegation uses a compact Workflow plan with dependencies,\n bounded scopes, and completion evidence. Fleet manages the same sub-agents\n and roles. One bounded, independent task can use a direct agent; `followup`\n reuses that agent for continued work.\n- **Ask**, **Auto-Review**, and **Full Access** control approval behavior within\n an action-capable mode. They never widen Plan into write or shell access.\n\nSee `docs/MODES.md` for the full mode and posture contract.\n\n## Compatibility names\n\nThe model-facing contract is the lowercase core above. Saved v0.9.x\ntranscripts and protocol clients may still call exact hidden compatibility\nnames such as `File`, `Bash`, and the older single-operation file names. Those\nnames never enter a new model catalog or `tool_search` result.\n\nCompatibility is execution compatibility, not fuzzy aliasing: an exact legacy\ncall must reach the handler for its legacy schema. It must not be rewritten\ninto a small lowercase primitive whose input shape is different. Unknown or\nretired names still fail closed instead of guessing a destination.\n\nSpecialized native families such as `Git`, `Run`, and `Web` are not aliases for\nthe lowercase core. They remain real, policy-filtered deferred tools and are\nloaded through `tool_search` when needed.\n\n## Long-running work\n\n`bash` runs one cancellable foreground command. It does not carry background,\nTTY, wait, interact, or cancel action fields. Stateful process and terminal\ncontrol is specialized functionality that must be discovered explicitly; it\ndoes not enlarge the first-turn shell schema.\n\nUse `tasks` when the work itself needs a durable lifecycle, structured gates,\nartifacts, replayable timelines, or a stable task id. Large tool results should\nremain behind bounded handles or artifacts instead of being copied wholesale\ninto the parent transcript.\n\n## Parallel fan-out\n\nThe sub-agent capacity source of truth is\n`crates/tui/src/config/subagent_limits.rs`:\n\n- default configured concurrency: **64**;\n- maximum configured concurrency: **128**;\n- maximum admitted running-plus-queued work: **1024**.\n\nThese are capacity ceilings, not advice to dispatch every available slot. A\nmanager should use the smallest useful fan-out, preserve a single owner for\nfan-in, and verify worker receipts before reporting combined completion.\n\nRLM child-query batching is a different, cheaper cost class. Its\n`sub_query_batch` helper accepts 1–16 one-shot children inside a live `rlm`\nsession; it is not a substitute for tool-carrying `agent` workers.\n\n## Human inspection: `/tools` (`/tool-studio`)\n\n`/tools` renders a **read-only, bounded human projection** of the tool field of\nthe request that was prepared for one `(turn, step)`. It is not a second\nregistry and not an execution surface.\n\n**The seam.** The snapshot is built in `crates/tui/src/core/engine/turn_loop.rs`\nimmediately after `MessageRequest` is constructed, from `request.tools` — the\nsame value the model client is handed. The engine resolves the surrounding\nper-turn data once in `engine.rs` (`ToolSurfaceContext`: flattened registry\nfacts, the MCP pool's own server attribution, the engine-injected catalog names,\nand the resolved model client's receipt) and passes it as plain data, so the\nper-step seam never re-locks the MCP pool or holds a tool object.\n\n**Turn and step identity.** The tool set can differ between steps of a turn, so\neach snapshot is stamped with turn id and step and each seam emits its own. The\nTUI keeps only the latest (`SessionState.last_tool_request_snapshot`). Before\nthe first seam there is no snapshot and `/tools` says so rather than rebuilding\na registry in the UI.\n\nTwo kinds of fact are kept apart:\n\n- **Wire facts** come from the prepared request: name, description, schema,\n `defer_loading` / `strict` / `allowed_callers` / `cache_control`, byte\n accounting, and the catalog digest.\n- **Surface facts** come from the `ToolSurfaceContext`: provenance\n (`builtin` / `plugin` / `mcp` / `synthetic` / `unknown`), MCP server identity,\n declared capabilities, declared approval requirement, and model visibility.\n\nContract:\n\n- **One digest.** `active_tool_catalog_sha256`\n (`crates/tui/src/core/engine/preview.rs`) is the single definition of the\n active-tool-catalog hash. The request manifest publishes it as\n `ToolSurfaceFacts::active_tool_catalog_sha256` and `/tools` reports the same\n value for the same prepared request; neither surface keeps a hash of its own.\n- **Nothing is guessed.** MCP server identity is shown only when the real pool\n attributed that exact model tool name. `McpPool::mcp_model_tool_name` is the\n single definition shared by the model catalog and the human attribution, and\n an ambiguous name (two servers colliding on one model name) resolves to no\n server. Synthetic provenance comes from\n `default_synthetic_catalog_tool_names`, which is asserted against the engine's\n own `is_synthetic_catalog_tool` predicate. A transmitted tool with no registry\n entry reports `capabilities: unknown`, never \"none\".\n- **Provider availability follows the resolved client.** It comes from\n `Engine::tool_surface_provider_receipt`, never from \"a tool registry exists\".\n With no client the receipt is `unavailable` even when the registry is full.\n- **Unknown shrinks, it does not vanish.** `unavailable_for_this_request` always\n contains `provider_wire_payload`: nothing on this path observes what the\n provider adapter finally transmits. It additionally contains `provider` and\n `model` without a resolved client, and `provenance` / `capabilities` /\n `approval` when no surface context was captured.\n- **Absent stays distinct from empty.** A request with no tools field is not a\n request with an empty tools array; an unresolved field is `unknown` with a\n reason, not a default.\n- **Bounded.** Rendering is capped by tool count (32), name, description, schema\n bytes, allowed-caller count, and a payload measurement bound, each with an\n explicit truncation or omission receipt. Registered tools that this request\n does *not* carry are reported as a bounded name list plus an exact count\n rather than expanding the projection.\n- **Inert.** The snapshot lives beside the transcript, never in\n `session.messages`, so it cannot enter a model request or perturb the\n provider's prefix cache. It never executes a tool, never reads credentials,\n never reorders the catalog, and is never registered as a model-callable tool.\n- **Delivery is never claimed.** The capture happens before connection setup, so\n `delivery_status` stays `unknown`.\n\n## Release verification\n\nDo not infer the public surface from handler function names. Verify the model\ncatalog and alias visibility at the exact candidate SHA:\n\n```bash\npython3 scripts/measure-runtime-contract.py\ncargo test -p codewhale-tui --lib --locked core::engine::tests::default_active_contract_keeps_discovery_and_core_tools_eager -- --exact\ncargo test -p codewhale-tui --lib --locked tools::file_tool::tests::primitive_schemas_are_separate_and_small_contract_shaped -- --exact\ncargo test -p codewhale-tui --lib --locked tools::shell::tests::lowercase_bash_schema_is_small_contract -- --exact\ncargo test --locked -p codewhale-tui --lib core::engine::tests::print_mode_tool_catalog_metrics -- --ignored --exact --nocapture\n```\n\nCheck the test names against the source before trusting a green run: `cargo test`\nexits 0 with \"0 passed; N filtered out\" when a filter matches nothing, so a\nmisspelled filter is indistinguishable from a pass. Each `--exact` command\nabove must report `1 passed` (the ignored metrics test reports `1 passed`\nonly because `--ignored` selects it); `0 passed` means the filter matched\nnothing and the check did not run.\n\nThe provider-free receipt must report the twelve default-active names listed\nabove. A separate repository-wide tool count may include deferred, dynamic,\nfeature-gated, and compatibility-only registrations; it is not the number of\ntools placed in the first-turn model catalog.\n" + "sha256": "0c8f808ce7ac45604113729ce4a5e7ade82a59115032f2f3fe5317cb54d34307", + "content": "# Tool surface\n\n> 阅读简体中文版:[zh_hans/TOOL_SURFACE.md](zh_hans/TOOL_SURFACE.md)。\n\nThis document describes the current model-facing tool contract. The v0.9.1\ncutover that produced it is recorded in `docs/RUNTIME_SIMPLIFICATION_DESIGN.md`;\nread the workspace version from `Cargo.toml`, not from this line. The registry\nremains larger than the first-turn catalog so\nsaved transcripts can replay and uncommon capabilities can be loaded on demand.\nThe model should learn one canonical name for each common operation.\n\nImplementation sources:\n\n- `crates/tui/src/core/engine/tool_catalog.rs` owns the eager/deferred catalog.\n- `crates/tui/src/tools/registry.rs` registers canonical tools and hidden aliases.\n- `crates/tui/src/tools/{file,file_tool,shell}.rs` own the small foreground\n primitive behavior and schemas; the other native tools remain searchable.\n- `docs/RUNTIME_SIMPLIFICATION_DESIGN.md` records the v0.9.1 cutover and receipt.\n\n## Default-active contract\n\nNew turns start with twelve eager native names plus synthetic `tool_search`:\n\n1. `read`\n2. `write`\n3. `edit`\n4. `file_search`\n5. `bash`\n6. `agent`\n7. `workflow`\n8. `todo_write`\n9. `create_goal`\n10. `get_goal`\n11. `update_goal`\n12. `load_skill`\n13. `tool_search` (synthetic, always active)\n\nThe twelve native names are `DEFAULT_ACTIVE_NATIVE_TOOLS` in\n`crates/tui/src/core/engine/tool_catalog.rs`, pinned by\n`default_active_contract_keeps_discovery_and_core_tools_eager`. An authority\nboundary may remove `agent` at the maximum child depth, but route size alone\nmust not change this core vocabulary.\n\nThe direct schemas deliberately stay small:\n\n| Tool | Input | Purpose |\n|---|---|---|\n| `read` | `path`, optional `offset`, optional `limit` | Read a bounded file window with explicit continuation or truncation notices. |\n| `write` | `path`, `content` | Create or replace a file. |\n| `edit` | `path`, `edits` | Apply one or more unambiguous text replacements against one original snapshot. |\n| `file_search` | `query`, optional `path`, `limit`, `extensions`, `exclude` | Find files by name, path fragment or glob and return bounded, ranked matches. Respects ignore files; `exclude` replaces default exclusions. |\n| `bash` | `command`, optional `timeout`, optional `description` | Run one cancellable foreground shell command and return a bounded tail. The optional description is display-only. |\n| `agent` | delegated task and optional scope/context controls | Start or inspect focused child work. |\n| `workflow` | plan/script/source_path plus run controls | Coordinate multi-agent phases with dependencies and completion checks. |\n| `todo_write` | complete replacement list of `{content, status}` items | Keep optional, agent-owned progress notes for genuinely multi-step work. |\n| `create_goal` | objective plus optional budget | Start the session goal the turn works toward. |\n| `get_goal` | none | Read the active goal and its progress. |\n| `update_goal` | terminal status | Mark the goal complete or blocked. |\n| `load_skill` | optional `name`, `query` | Load a named skill's `SKILL.md` body and companion-file list; omit `name` or use `name=\"list\"` to list eligible skills, or use `query` to search names and descriptions. |\n| `tool_search` | `query`, optional matching controls | Discover policy-allowed deferred tools and add selected schemas to this conversation's toolbox. |\n\nMode is an authority decision, not a synonym system. Plan, Work, and Operate\nuse the same primitive identities. Plan centrally refuses `write`, `edit`, and\n`bash`; Work and Operate still pass those calls through approval, sandbox,\ntrusted-path, repository-law, and managed-policy gates. Full Access changes\nordinary approval behavior but does not bypass hard safety or repository law.\n\n`update_plan` remains registered only for saved-artifact compatibility and is\nnot model-visible. `tasks`, `Git`, `Run`, `Web`, `remember`, and other\nspecialized capabilities are searchable rather than first-turn ceremony.\n\nA Bash description appears as a muted, bounded note under the command on the\nTUI shell card, including on automatic runs. It never replaces the command and\nis excluded from execution, exact approval keys and Auto-Review input. The\noriginal description remains in the recorded tool input and exports. Approval\nprompts, `/jobs`, the activity inspector, ACP and other Runtime clients keep\nshowing the command; this change adds no description row to those surfaces.\n\n## Local code execution\n\n`code_execution` (Python) and `js_execution` (Node.js) use the same\npermission-aware local launcher as workspace task gates and test runs. Ordinary\napproval retains the effective session policy. To retry a denied exact call\nwith wider permissions, supply `sandbox_permissions` (`workspace-write` or\n`danger-full-access`) and a nonempty `justification`; Ask mode requires explicit\none-shot user approval. The approved policy affects only that call.\n\nBoth tools read source from stdin, return stdout/stderr/return_code, and keep\nthe existing timeout and process-tree cleanup. They are not persistent REPLs;\ntracebacks refer to stdin rather than a temporary script path. The separate\nRLM/REPL kernel is not changed by this launcher integration.\n\nPlatform/backend limits of the shared launcher still apply: no available local\nwrapper means workspace-write remains unenforced, while read-only is refused.\nAn external sandbox session cannot use these local interpreter tools and must\nuse its shell execution path. See [sandbox limits](SANDBOX.md).\n\n## Deferred and dynamic tools\n\n`Web` is conditional and deferred. It is discoverable through `tool_search`\nonly when the active policy and runtime backend permit it. Read-only\nchildren retain its read-only search/fetch evidence path; read-only authority\ndoes not mean \"unable to research.\"\n\nThe durable `github`, `automation`, and `rlm` action families are also deferred\nby default. `rlm` owns `open`, `eval`, `configure`, and `close` actions for a\npersistent local Python session (a subprocess with a scrubbed environment, not\nan OS sandbox). Inline ```` ```repl ```` fences in a reply run in the same kind of\nkernel only when `code_execution` is on the turn's surface (never in Plan mode),\nonly when the fence opens its own line, and only after `code_execution`'s\napproval under the session posture. Feature-gated native tools may be added to\nthe active or deferred catalog only when their implementation and host\ndependencies are available.\n\nMCP tools are dynamic. Successfully connected servers register names such as\n`mcp__` from `~/.codewhale/mcp.json`; a failed or disabled server\nmust not be presented as available. MCP and plugin tools are deferred unless a\nuser explicitly names them in `[tools].always_load`.\n\nFor an unstarted configured server, an MCP-focused `tool_search` first\nperforms bounded discovery under the current turn's server/tool ceiling.\nIt searches real server-provided schemas after connection, rather than\ninventing `mcp_*` definitions. A search inside `execute_tools` describes\nthose schemas without activating them; a direct search uses the existing\nbounded activation cache. Ordinary unrelated searches leave optional servers\nunstarted. The CLI's standalone MCP inspection commands own a separate pool.\n\n### Code mode (`execute_tools`)\n\n`execute_tools` is engine-injected, alongside the synthetic interpreter tools.\nIt runs a JavaScript program whose only host surface is\n`await tools.call(name, args)`, and it is the default way to compose several\ntool calls — MCP and plugin tools included — without round-tripping every\nintermediate result through the conversation. It is hidden from Plan mode and\nrefused under a worker authority envelope.\n\n- **One gate.** In a session turn every nested call is sent back to the turn\n loop and planned exactly like a direct call: deny/allow lists, preparation\n (MCP `readOnlyHint`/`destructiveHint`), `tool_call_before` hooks, ask-rules,\n Auto-Review, repository law, and the Computer Use consent refusal. MCP calls\n run through the session MCP pool. Approving the program grants nothing, so\n `execute_tools` itself is auto-approved. If the permission posture changes\n while a program runs, its remaining nested calls are refused (an approved\n call survives only an equal or broader posture, as for a direct call) and\n the model retries them under the new posture.\n- **Approvals suspend the program.** A nested call that needs approval raises\n the normal approval card (named `execute_tools program call: ...`) and the\n program waits; allow resumes it, deny fails only that nested call as an\n exception the program can catch. Time spent waiting on a decision does not\n count against the program's run deadline, which is the turn's remaining\n wall clock.\n- **Receipts.** The result lists every nested call with its decision (`auto`,\n `approved`, `denied`, `refused`) and status (`ok`, `failed`, `refused`,\n `in_flight`). A program that hits its deadline still returns the receipt;\n `in_flight` calls were cancelled and may have partially run. Each nested\n result is `{content, metadata, truncated}`; an oversized result keeps that\n shape, and `truncated` names the original size and the spillover file with\n the full output.\n- **Discovery without re-pinning.** Inside a program,\n `tools.call('tool_search', {query})` returns matching deferred tools with\n their input schemas and does not activate them, so the request's tool array\n and the session-pinned prefix do not change.\n- **Stays direct:** `agent`, `workflow`, `request_user_input`, nested\n `execute_tools`, interactive shells, sandbox escalation, Computer Use\n consent and scripts, and MCP sign-in (`mcp__authenticate`).\n\nCode mode is on by default (`[features] code_mode = true`), which makes\n`execute_tools` eager from the first request; direct tools and `tool_search`\nstay available either way. Set `code_mode = false` (or run with\n`--disable code_mode`) to go back to deferring `execute_tools` behind\n`tool_search`. The flag is session configuration, so the prompt prefix stays\nstable within a session. Without an engine turn (sub-agents), a program keeps\nthe conservative profile: read-only, auto-approved native calls only, no MCP.\n\n### Conversation toolbox cache\n\nA successful search activation is remembered by name for the current\nconversation. The cache holds at most eight deferred names and 16 KiB of\nserialized schemas, evicts least-recently-used entries, and revalidates every\nentry against the current catalog and policy before advertising it again. A\nsession sync clears it. The cache cannot resurrect a removed, denied, or\nnewly-eager tool.\n\nEach subagent gets its own policy-filtered deferred catalog, always-present\n`tool_search`, and bounded activation cache. Forked messages and instructions\nremain in context, but the child cache starts empty and discovers tools locally;\nneither forked context nor a cache can become a discovery allowlist. A child can\nstill search every tool its own authority permits, including Web search/fetch\nfor read-only research roles.\n\n## Inspect the model-client request tool payload\n\nRun `/tools` after a model turn to inspect a bounded projection of the exact\ntool field in the latest prepared model-client request. `/tools json` emits the\nsame evidence as bounded machine-readable JSON. Both formats open in a pager;\nthey are not copied into transcript history. `/tool-studio` remains a human-\ncommand compatibility alias; it is not a model tool.\n\nThe snapshot distinguishes an absent tool field from a present empty array. It\nreports the exact model-client tool JSON byte count and SHA-256 digest only when\nmeasurement fits the one-MiB inspection bound; larger payloads stay unavailable.\nProvider adapters may transform, sanitize, or omit those fields while building\na provider-specific wire body, so `/tools` marks provider delivery and the wire\npayload unavailable. Capture and rendering are bounded: retained schemas,\ndescriptions, caller lists, catalog rows, turn IDs, and payload measurement all\ncarry explicit truncation, omission, or unavailable receipts. The snapshot stays\nin memory only for the current session and is replaced on each prepared request.\n\nProvider, model, approval, registry provenance, and runtime capability metadata\nare not fields in the request tool schema. `/tools` therefore reports them as\nunavailable instead of joining against mutable state or inferring values. Use\nthe separate route and permission receipts for those facts.\n\n## Modes and permission postures\n\nModes and permission postures are separate controls:\n\n- **Plan** keeps the stable primitive vocabulary but centrally refuses shell\n execution and file mutation.\n- **Work** is ordinary interactive execution.\n- **Operate** uses the same direct-tool authority as Work. Small work stays\n direct; multi-step delegation uses a compact Workflow plan with dependencies,\n bounded scopes, and completion evidence. Fleet manages the same sub-agents\n and roles. One bounded, independent task can use a direct agent; `followup`\n reuses that agent for continued work.\n- **Ask**, **Auto-Review**, and **Full Access** control approval behavior within\n an action-capable mode. They never widen Plan into write or shell access.\n\nSee `docs/MODES.md` for the full mode and posture contract.\n\n## Compatibility names\n\nThe model-facing contract is the lowercase core above. Saved v0.9.x\ntranscripts and protocol clients may still call exact hidden compatibility\nnames such as `File`, `Bash`, and the older single-operation file names. Those\nnames never enter a new model catalog or `tool_search` result.\n\nCompatibility is execution compatibility, not fuzzy aliasing: an exact legacy\ncall must reach the handler for its legacy schema. It must not be rewritten\ninto a small lowercase primitive whose input shape is different. Unknown or\nretired names still fail closed instead of guessing a destination.\n\nSpecialized native families such as `Git`, `Run`, and `Web` are not aliases for\nthe lowercase core. They remain real, policy-filtered deferred tools and are\nloaded through `tool_search` when needed.\n\n## Long-running work\n\n`bash` runs one cancellable foreground command. It does not carry background,\nTTY, wait, interact, or cancel action fields. Stateful process and terminal\ncontrol is specialized functionality that must be discovered explicitly; it\ndoes not enlarge the first-turn shell schema.\n\nUse `tasks` when the work itself needs a durable lifecycle, structured gates,\nartifacts, replayable timelines, or a stable task id. Large tool results should\nremain behind bounded handles or artifacts instead of being copied wholesale\ninto the parent transcript.\n\n## Parallel fan-out\n\nThe sub-agent capacity source of truth is\n`crates/tui/src/config/subagent_limits.rs`:\n\n- default configured concurrency: **64**;\n- maximum configured concurrency: **128**;\n- maximum admitted running-plus-queued work: **1024**.\n\nThese are capacity ceilings, not advice to dispatch every available slot. A\nmanager should use the smallest useful fan-out, preserve a single owner for\nfan-in, and verify worker receipts before reporting combined completion.\n\nRLM child-query batching is a different, cheaper cost class. Its\n`sub_query_batch` helper accepts 1–16 one-shot children inside a live `rlm`\nsession; it is not a substitute for tool-carrying `agent` workers.\n\n## Human inspection: `/tools` (`/tool-studio`)\n\n`/tools` renders a **read-only, bounded human projection** of the tool field of\nthe request that was prepared for one `(turn, step)`. It is not a second\nregistry and not an execution surface.\n\n**The seam.** The snapshot is built in `crates/tui/src/core/engine/turn_loop.rs`\nimmediately after `MessageRequest` is constructed, from `request.tools` — the\nsame value the model client is handed. The engine resolves the surrounding\nper-turn data once in `engine.rs` (`ToolSurfaceContext`: flattened registry\nfacts, the MCP pool's own server attribution, the engine-injected catalog names,\nand the resolved model client's receipt) and passes it as plain data, so the\nper-step seam never re-locks the MCP pool or holds a tool object.\n\n**Turn and step identity.** The tool set can differ between steps of a turn, so\neach snapshot is stamped with turn id and step and each seam emits its own. The\nTUI keeps only the latest (`SessionState.last_tool_request_snapshot`). Before\nthe first seam there is no snapshot and `/tools` says so rather than rebuilding\na registry in the UI.\n\nTwo kinds of fact are kept apart:\n\n- **Wire facts** come from the prepared request: name, description, schema,\n `defer_loading` / `strict` / `allowed_callers` / `cache_control`, byte\n accounting, and the catalog digest.\n- **Surface facts** come from the `ToolSurfaceContext`: provenance\n (`builtin` / `plugin` / `mcp` / `synthetic` / `unknown`), MCP server identity,\n declared capabilities, declared approval requirement, and model visibility.\n\nContract:\n\n- **One digest.** `active_tool_catalog_sha256`\n (`crates/tui/src/core/engine/preview.rs`) is the single definition of the\n active-tool-catalog hash. The request manifest publishes it as\n `ToolSurfaceFacts::active_tool_catalog_sha256` and `/tools` reports the same\n value for the same prepared request; neither surface keeps a hash of its own.\n- **Nothing is guessed.** MCP server identity is shown only when the real pool\n attributed that exact model tool name. `McpPool::mcp_model_tool_name` is the\n single definition shared by the model catalog and the human attribution, and\n an ambiguous name (two servers colliding on one model name) resolves to no\n server. Synthetic provenance comes from\n `default_synthetic_catalog_tool_names`, which is asserted against the engine's\n own `is_synthetic_catalog_tool` predicate. A transmitted tool with no registry\n entry reports `capabilities: unknown`, never \"none\".\n- **Provider availability follows the resolved client.** It comes from\n `Engine::tool_surface_provider_receipt`, never from \"a tool registry exists\".\n With no client the receipt is `unavailable` even when the registry is full.\n- **Unknown shrinks, it does not vanish.** `unavailable_for_this_request` always\n contains `provider_wire_payload`: nothing on this path observes what the\n provider adapter finally transmits. It additionally contains `provider` and\n `model` without a resolved client, and `provenance` / `capabilities` /\n `approval` when no surface context was captured.\n- **Absent stays distinct from empty.** A request with no tools field is not a\n request with an empty tools array; an unresolved field is `unknown` with a\n reason, not a default.\n- **Bounded.** Rendering is capped by tool count (32), name, description, schema\n bytes, allowed-caller count, and a payload measurement bound, each with an\n explicit truncation or omission receipt. Registered tools that this request\n does *not* carry are reported as a bounded name list plus an exact count\n rather than expanding the projection.\n- **Inert.** The snapshot lives beside the transcript, never in\n `session.messages`, so it cannot enter a model request or perturb the\n provider's prefix cache. It never executes a tool, never reads credentials,\n never reorders the catalog, and is never registered as a model-callable tool.\n- **Delivery is never claimed.** The capture happens before connection setup, so\n `delivery_status` stays `unknown`.\n\n## Release verification\n\nDo not infer the public surface from handler function names. Verify the model\ncatalog and alias visibility at the exact candidate SHA:\n\n```bash\npython3 scripts/measure-runtime-contract.py\ncargo test -p codewhale-tui --lib --locked core::engine::tests::default_active_contract_keeps_discovery_and_core_tools_eager -- --exact\ncargo test -p codewhale-tui --lib --locked tools::file_tool::tests::primitive_schemas_are_separate_and_small_contract_shaped -- --exact\ncargo test -p codewhale-tui --lib --locked tools::shell::tests::lowercase_bash_schema_is_small_contract -- --exact\ncargo test --locked -p codewhale-tui --lib core::engine::tests::print_mode_tool_catalog_metrics -- --ignored --exact --nocapture\n```\n\nCheck the test names against the source before trusting a green run: `cargo test`\nexits 0 with \"0 passed; N filtered out\" when a filter matches nothing, so a\nmisspelled filter is indistinguishable from a pass. Each `--exact` command\nabove must report `1 passed` (the ignored metrics test reports `1 passed`\nonly because `--ignored` selects it); `0 passed` means the filter matched\nnothing and the check did not run.\n\nThe provider-free receipt must report the twelve default-active names listed\nabove. A separate repository-wide tool count may include deferred, dynamic,\nfeature-gated, and compatibility-only registrations; it is not the number of\ntools placed in the first-turn model catalog.\n" }, { "path": "docs/TOOL_LIFECYCLE.md", @@ -357,8 +357,8 @@ }, { "path": "docs/RELEASE_CHECKLIST.md", - "sha256": "b131f758e280d3f037b408ddaddd79db723ed1a6bba7c6296e7480138e1b379e", - "content": "# Release Checklist\n\nFollow this sequence from the canonical `/Volumes/VIXinSSD/CW/codewhale`\ncheckout on `main`. Agent-owned preparation lands as verified slices on `main`;\nit requires no release branch, worktree, nested clone or preparation PR.\nContributor PRs still land as PRs. Treat any unchecked required gate as a release\nblocker.\n\nFor deeper context on the underlying tools (preflight scripts, npm smoke,\npublish-crates), see [`RELEASE_RUNBOOK.md`](RELEASE_RUNBOOK.md).\nFor larger milestone releases, add any version-specific acceptance matrix to\nthe existing release packet before tagging; use it for provider routes, feature\ngates, GUI/runtime smoke, remote-workbench decisions, and credit hygiene that the\ngeneric checklist does not enumerate. Record its candidate SHA, owners, local\ncheck results, hosted run URLs, artifact inventory, install/provider receipts,\nknown limitations and publication approval. These proofs are separate.\n\n## 0. Reconcile the canonical checkout\n\n- [ ] Inspect the branch, status, upstream and alternate checkouts before editing:\n ```bash\n git branch --show-current\n git status --short --branch\n git log -5 --oneline\n git worktree list\n git fetch origin main\n git log --left-right --oneline main...origin/main\n ```\n- [ ] The canonical checkout is on `main`. Recover stranded work only after\n preserving dirty/untracked files and comparing commits with current\n `origin/main`. Keep a recoverable backup and unresolved inventory; do not\n reset away work, stash it indefinitely or create another development lane.\n- [ ] Partition files among active writers. Check status before staging and\n stage only owned paths. Historical branches/checkouts are inventoried;\n deleting them requires explicit Hunter approval even if a helper calls\n their tips safe to delete.\n\n## 1. CHANGELOG entry exists for the version\n\n- [ ] `CHANGELOG.md` has a `## [X.Y.Z] - YYYY-MM-DD` heading at the top\n- [ ] The entry credits every external contributor, harvested PR author,\n linked issue reporter, reproduction/log provider, reviewer, and\n verification helper whose work materially shaped this version. Get the\n commit list with:\n ```\n git log vPREV..HEAD --no-merges --format=\"%h %an <%ae> %s\" \\\n | grep -v ''\n ```\n For each contributor, link both their display name and (when known)\n `@github-handle`. Then inspect linked issues and harvested PRs so\n reporters/helpers are not lost just because they did not author commits.\n Keep `docs/CONTRIBUTORS.md` and `web/lib/release-credits.ts` consistent.\n- [ ] The entry uses the Keep a Changelog headers — `Added`, `Changed`,\n `Fixed`, `Security`, `Removed`, `Deprecated`. Add `Known issues` only\n if there is something material the user must work around.\n- [ ] The entry mentions all referenced issue/PR numbers as `#NNNN` so the\n auto-linker on GitHub picks them up.\n- [ ] Run `scripts/sync-changelog.sh` to regenerate `crates/tui/CHANGELOG.md`\n (the recent-releases slice embedded in the binary for `/change`). Do\n not edit that file by hand, and do not copy the full root changelog\n into it — older entries live in `docs/CHANGELOG_ARCHIVE.md`.\n- [ ] Run `scripts/release/check-feature-release-notes.sh vPREV HEAD`. Every\n issue-linked `feat` commit must leave a receipt in `CHANGELOG.md` or the\n archive; the Version drift CI gate runs the same check with full history.\n\n## 2. Version pins are in sync\n\n- [ ] Use `./scripts/release/prepare-release.sh X.Y.Z` when version pins or\n generated sources need preparation. Reuse a valid preparation receipt\n when those sources are already current. The helper bumps the\n workspace version, every per-crate dependency pin, the npm wrapper\n (`version` + `codewhaleBinaryVersion`), Runtime SDK, VS Code extension\n and lock, remote-smoke default, public source-candidate facts, and README\n install-tag examples; it refreshes the Cargo/npm locks, regenerates\n `crates/tui/CHANGELOG.md` and `web/lib/facts.generated.ts`, and ends\n by running the version and OHOS gates. Write the CHANGELOG entry\n **before** running it. The helper is safe to rerun at the requested\n workspace version; it skips replacements but refreshes both generated\n files and reruns the gates.\n- [ ] `npm/deepseek-tui/package.json` remains private/compatibility-only and\n is **not** bumped or published.\n- [ ] `./scripts/release/check-versions.sh` reports\n `Version state OK: workspace=X.Y.Z, npm=X.Y.Z, npm-binary=X.Y.Z, lockfile in sync.`\n- [ ] `./scripts/release/check-ohos-deps.sh` reports that the OpenHarmony\n Windows linker keeps the target/sysroot flags, the target enables the\n `rquickjs-sys` bindgen edge, and its graph does not pull the unsupported\n `nix` 0.28/0.29, `portable-pty`, `starlark`, `arboard`, or `keyring`\n crates.\n\n## 3. Verify and land preparation slices\n\n- [ ] Run the existing checks appropriate to each changed surface once; batch\n coherent Rust edits before compiling. Web changes use\n `npm test && npm run check:web` from the repository root. Record actual\n results and failures in commit messages; hosted CI owns exhaustive\n workspace and platform coverage.\n- [ ] Commit verified owned paths directly to `main` and push normally. Resolve\n push rejection with the other writer while preserving dirty work; never\n force-push. Reuse valid receipts instead of rebuilding merely to commit\n or push. Existing contributor PRs retain their review and merge process.\n- [ ] The live milestone and PR queue no longer contain work intended for this\n version:\n ```\n gh issue list --repo codewhale-hq/CodeWhale --milestone \"vX.Y.Z\" --state open\n gh pr list --repo codewhale-hq/CodeWhale --state open --limit 100\n ```\n- [ ] Any remaining same-theme work is explicitly retargeted to a later\n version or called out as a known issue. Do not freeze/tag while still\n planning to merge more same-version fixes.\n- [ ] The release tag does not already point at an older source SHA, or the\n maintainer has deliberately chosen to publish exactly that older SHA:\n ```\n git ls-remote origin refs/heads/main refs/tags/vX.Y.Z\n gh release view vX.Y.Z --repo codewhale-hq/CodeWhale\n ./scripts/release/check-published.sh X.Y.Z\n ```\n- [ ] If `vX.Y.Z` exists with no GitHub Release/packages and `main` has moved\n on, stop. Choose one of: publish the existing tag as-is, bump the later\n work to the next patch version, or explicitly approve deleting/recreating\n the unpublished tag. Publishing older source after canonical `main` has\n advanced needs a separately authorized recovery plan; do not silently\n move tags or switch the shared checkout. An absent release/unpublished\n packages are expected at this stage, not permission to alter a tag.\n- [ ] Freeze the final source: all intended work is committed and pushed;\n clean local `HEAD`, `main` and freshly fetched `origin/main` agree.\n Record the full 40-character SHA/version and coordinate with shared\n writers to preserve that source through packaging and publication.\n- [ ] `./scripts/release/check-versions.sh --require-dated-release` and\n `python3 scripts/check-contributor-credit.py` pass for the candidate.\n- [ ] `./scripts/release/publish-crates.sh dry-run` passes for the frozen\n candidate. Cargo 1.90+ verifies every release tarball and its real size\n without uploading. CI/RC does not replace this check. Retain the receipt;\n do not repeat it for unchanged source just to commit or push. Publish mode\n necessarily verifies the packages again before uploading.\n\n## 4. Exact-SHA full CI and release candidate\n\n- [ ] Dispatch full CI and the build-only release candidate against frozen\n `main` with the same independent SHA guard:\n ```bash\n candidate_sha=\"$(git rev-parse HEAD)\"\n gh workflow run ci.yml --ref main -f expected_sha=\"${candidate_sha}\"\n gh workflow run release-candidate.yml --ref main -f expected_sha=\"${candidate_sha}\"\n ```\n- [ ] Both runs resolve to that SHA and all required jobs execute and succeed.\n A green workflow badge alone is insufficient: ordinary main-push CI can\n delegate Linux work to CNB and omit some smoke coverage. Manual full CI\n forces the release gates on Linux, macOS and Windows.\n- [ ] The candidate's Parity job succeeds, and its artifact jobs report all seven\n targets and the complete 34-file asset inventory, including Android\n arm64, Windows arm64, `codew`, the NSIS installer, archives, checksum\n manifests, and seven compatibility-only `codewhale-tui-*` release\n filenames that are not installed commands. The packed npm wrapper installs\n against those assets and runs its delegated entrypoints. Keep run URLs and\n required evidence before the Actions artifacts expire; these are not a\n public release.\n- [ ] Complete the version-specific acceptance packet. Cross-building Android\n does not prove a real Termux/device session. Local install, actual provider\n calls and customer acceptance need separate receipts; provider spend still\n requires authorization. Required unproven acceptance blocks release;\n disclose an explicitly accepted limitation as such.\n- [ ] If source changes or `main` moves before tagging, deliberately choose the\n new candidate and replace affected proofs. Tag only a SHA with its exact\n CI/RC evidence. Do not duplicate hosted exhaustive gates locally.\n\n## 5. Publication approval\n\n- [ ] Hunter explicitly approves the exact version/SHA and publication targets:\n tag, GitHub Release, npm, GHCR, CNB release tag and legacy Homebrew\n automation, plus Cargo or other downstream publication where intended.\n Source readiness and build-only CI/RC do not authorize a tag, registry\n write, deployment or public launch.\n\n## 6. Tag and publish the approved source\n\n- [ ] Fetch `origin/main` again and verify that clean canonical `main`,\n `origin/main` and the approved SHA still agree.\n- [ ] The release source is reachable from `main`:\n `./scripts/release/ensure-release-on-main.sh HEAD`\n- [ ] Create `vX.Y.Z` from the final `main` SHA using the **Create release tag**\n workflow, or create and push a signed local tag:\n `git tag -s vX.Y.Z -m \"vX.Y.Z\" && git push origin vX.Y.Z`\n Tagging can start public automation immediately. If no run appears,\n first exclude a queued/active tag-triggered run, then dispatch only the\n existing tag: `gh workflow run release.yml --ref vX.Y.Z -f version=X.Y.Z`.\n Do not dispatch `release.yml` from `main` or start duplicate runs.\n- [ ] The Release workflow succeeds: it repeats parity/build gates, verifies\n the draft asset inventory before GitHub publication, then runs dependent\n npm, GHCR, CNB and Homebrew publication. It requires an exact-SHA green\n RC receipt and does not publish Cargo crates.\n- [ ] The public GitHub Release assets are proven to match the tag commit\n before publishing Cargo or npm:\n ```\n git fetch origin tag vX.Y.Z\n ./scripts/release/verify-release-assets.sh X.Y.Z\n ./scripts/release/require-release-tag-checkout.sh X.Y.Z\n ```\n This checks the local tag, remote tag, successful Release workflow SHA,\n full binary/archive/installer asset set, and both checksum manifests. If\n it fails, stop publication and follow the runbook's recovery gates.\n Publish from the same clean canonical checkout on `main` when `HEAD`\n matches the immutable remote tag. Do not create a detached worktree,\n switch release lanes or force-fetch a conflicting tag. If `main` advanced,\n coordinate with Hunter for a new candidate or separately authorized\n recovery plan.\n- [ ] Publish Cargo manually within the approved scope:\n `./scripts/release/publish-crates.sh publish`. It verifies every package,\n skips already-published versions and waits for each new dependency to\n appear. npm normally uses Trusted Publishing; manual recovery keeps the\n same clean-source and public-asset guards.\n\n## 7. Verify public results\n\n- [ ] The live GitHub Release body has its own `## Contributors` or\n `## Credits` section; do not rely on \"see CHANGELOG\" alone. Verify with:\n ```\n gh release view vX.Y.Z --repo codewhale-hq/CodeWhale --json body \\\n --jq '.body | test(\"## (Contributors|Credits)\")'\n ```\n- [ ] `npm view codewhale@X.Y.Z version codewhaleBinaryVersion --json`\n reports the new version on the npm registry.\n- [ ] `npm view deepseek-tui deprecated` is non-empty. The legacy npm package\n is deprecated and must not receive an `X.Y.Z` publish.\n- [ ] Distribution channels are canonical-first: the website install page\n (codewhale.net/install) shows Codewhale-native commands first (`npm install -g\n codewhale`, `curl .../install.sh | sh`); Homebrew is labeled as legacy\n compatibility; the shell installer uses codewhale-native names as documented\n in `docs/REBRAND.md#homebrew`.\n- [ ] Every release crate has the new version on crates.io; there is no\n automated Cargo publish job. Partial publication is incomplete.\n- [ ] `ghcr.io/codewhale-hq/codewhale:vX.Y.Z` and `:latest` are updated.\n- [ ] The CNB release tag and approved downstream results are verified\n separately; a successful GitHub Release does not prove them.\n- [ ] The final registry verification passes:\n ```\n ./scripts/release/check-published.sh X.Y.Z\n ```\n- [ ] Published install/upgrade smoke has its own receipt; candidate installs\n do not prove public delivery. Deployment/customer acceptance remain\n separate gates where required.\n\n## 8. Handoff and tracking\n\n- [ ] Edit the GitHub release notes to expand any CVE-style or attack\n details within the approved publication scope.\n- [ ] Re-run the GitHub Release body check after any release-workflow rerun;\n workflows can overwrite notes and accidentally remove contributor credit.\n- [ ] Note any deferred items in the next release's tracking issue.\n- [ ] Close any issues that this release fixed.\n- [ ] Update existing tracking with evidence; agents do not post GitHub\n issue/PR comments. Handle the existing `sync-release-record` workflow's\n metadata proposal after publication: it currently opens a bot PR, not a\n required preparation branch. A source record does not prove deployment.\n- [ ] Report canonical path, branch, SHA, upstream state and dirty/stranded\n work. Historical branch/checkout removal needs explicit cleanup approval\n and does not block a release whose source is already reconciled.\n\n---\n\nIf a step fails, **fix the underlying cause** rather than skipping it. Pre-commit\nhooks, signing, and CI are all here to catch real problems. Do not skip hooks or\nsigning, force-push shared refs, replace public assets or move release tags to\nmake the checklist look green.\n" + "sha256": "48264ce4cbd3429546ee8ad9dbd28df565a8d3ab814d5a2c77d6450f96b82d2c", + "content": "# Release Checklist\n\nFollow this sequence from the canonical `/Volumes/VIXinSSD/CW/codewhale`\ncheckout on `main`. Agent-owned preparation lands as verified slices on `main`;\nit requires no release branch, worktree, nested clone or preparation PR.\nContributor PRs still land as PRs. Treat any unchecked required gate as a release\nblocker.\n\nFor deeper context on the underlying tools (preflight scripts, npm smoke,\npublish-crates), see [`RELEASE_RUNBOOK.md`](RELEASE_RUNBOOK.md).\nFor larger milestone releases, add any version-specific acceptance matrix to\nthe existing release packet before tagging; use it for provider routes, feature\ngates, GUI/runtime smoke, remote-workbench decisions, and credit hygiene that the\ngeneric checklist does not enumerate. Record its candidate SHA, owners, local\ncheck results, hosted run URLs, artifact inventory, install/provider receipts,\nknown limitations and publication approval. These proofs are separate.\n\n## 0. Reconcile the canonical checkout\n\n- [ ] Inspect the branch, status, upstream and alternate checkouts before editing:\n ```bash\n git branch --show-current\n git status --short --branch\n git log -5 --oneline\n git worktree list\n git fetch origin main\n git log --left-right --oneline main...origin/main\n ```\n- [ ] The canonical checkout is on `main`. Recover stranded work only after\n preserving dirty/untracked files and comparing commits with current\n `origin/main`. Keep a recoverable backup and unresolved inventory; do not\n reset away work, stash it indefinitely or create another development lane.\n- [ ] Partition files among active writers. Check status before staging and\n stage only owned paths. Historical branches/checkouts are inventoried;\n deleting them requires explicit Hunter approval even if a helper calls\n their tips safe to delete.\n\n## 1. CHANGELOG entry exists for the version\n\n- [ ] `CHANGELOG.md` has a `## [X.Y.Z] - YYYY-MM-DD` heading at the top\n- [ ] The entry credits every external contributor, harvested PR author,\n linked issue reporter, reproduction/log provider, reviewer, and\n verification helper whose work materially shaped this version. Get the\n commit list with:\n ```\n git log vPREV..HEAD --no-merges --format=\"%h %an <%ae> %s\" \\\n | grep -v ''\n ```\n For each contributor, link both their display name and (when known)\n `@github-handle`. Then inspect linked issues and harvested PRs so\n reporters/helpers are not lost just because they did not author commits.\n Keep `docs/CONTRIBUTORS.md` and `web/lib/release-credits.ts` consistent.\n- [ ] The entry uses the Keep a Changelog headers — `Added`, `Changed`,\n `Fixed`, `Security`, `Removed`, `Deprecated`. Add `Known issues` only\n if there is something material the user must work around.\n- [ ] The entry mentions all referenced issue/PR numbers as `#NNNN` so the\n auto-linker on GitHub picks them up.\n- [ ] Run `scripts/sync-changelog.sh` to regenerate `crates/tui/CHANGELOG.md`\n (the recent-releases slice embedded in the binary for `/change`). Do\n not edit that file by hand, and do not copy the full root changelog\n into it — older entries live in `docs/CHANGELOG_ARCHIVE.md`.\n- [ ] Run `scripts/release/check-feature-release-notes.sh vPREV HEAD`. Every\n issue-linked `feat` commit must leave a receipt in `CHANGELOG.md` or the\n archive; the Version drift CI gate runs the same check with full history.\n\n## 2. Version pins are in sync\n\n- [ ] Use `./scripts/release/prepare-release.sh X.Y.Z` when version pins or\n generated sources need preparation. Reuse a valid preparation receipt\n when those sources are already current. The helper bumps the\n workspace version, every per-crate dependency pin, the npm wrapper\n (`version` + `codewhaleBinaryVersion`), Runtime SDK, VS Code extension\n and lock, remote-smoke default, public source-candidate facts, and README\n install-tag examples; it refreshes the Cargo/npm locks, regenerates\n `crates/tui/CHANGELOG.md` and `web/lib/facts.generated.ts`, and ends\n by running the version and OHOS gates. Write the CHANGELOG entry\n **before** running it. The helper is safe to rerun at the requested\n workspace version; it skips replacements but refreshes both generated\n files and reruns the gates.\n- [ ] `npm/deepseek-tui/package.json` remains private/compatibility-only and\n is **not** bumped or published.\n- [ ] `./scripts/release/check-versions.sh` reports\n `Version state OK: workspace=X.Y.Z, npm=X.Y.Z, npm-binary=X.Y.Z, lockfile in sync.`\n- [ ] `./scripts/release/check-ohos-deps.sh` reports that the OpenHarmony\n Windows linker keeps the target/sysroot flags, the target enables the\n `rquickjs-sys` bindgen edge, and its graph does not pull the unsupported\n `nix` 0.28/0.29, `portable-pty`, `starlark`, `arboard`, or `keyring`\n crates.\n\n## 3. Verify and land preparation slices\n\n- [ ] Run the existing checks appropriate to each changed surface once; batch\n coherent Rust edits before compiling. Web changes use\n `npm test && npm run check:web` from the repository root. Record actual\n results and failures in commit messages; hosted CI owns exhaustive\n workspace and platform coverage.\n- [ ] Commit verified owned paths directly to `main` and push normally. Resolve\n push rejection with the other writer while preserving dirty work; never\n force-push. Reuse valid receipts instead of rebuilding merely to commit\n or push. Existing contributor PRs retain their review and merge process.\n- [ ] The live milestone and PR queue no longer contain work intended for this\n version:\n ```\n gh issue list --repo codewhale-hq/CodeWhale --milestone \"vX.Y.Z\" --state open\n gh pr list --repo codewhale-hq/CodeWhale --state open --limit 100\n ```\n- [ ] Any remaining same-theme work is explicitly retargeted to a later\n version or called out as a known issue. Do not freeze/tag while still\n planning to merge more same-version fixes.\n- [ ] The release tag does not already point at an older source SHA, or the\n maintainer has deliberately chosen to publish exactly that older SHA:\n ```\n git ls-remote origin refs/heads/main refs/tags/vX.Y.Z\n gh release view vX.Y.Z --repo codewhale-hq/CodeWhale\n ./scripts/release/check-published.sh X.Y.Z\n ```\n- [ ] If `vX.Y.Z` exists with no GitHub Release/packages and `main` has moved\n on, stop. Choose one of: publish the existing tag as-is, bump the later\n work to the next patch version, or explicitly approve deleting/recreating\n the unpublished tag. Publishing older source after canonical `main` has\n advanced needs a separately authorized recovery plan; do not silently\n move tags or switch the shared checkout. An absent release/unpublished\n packages are expected at this stage, not permission to alter a tag.\n- [ ] Freeze the final source: all intended work is committed and pushed;\n clean local `HEAD`, `main` and freshly fetched `origin/main` agree.\n Record the full 40-character SHA/version and coordinate with shared\n writers to preserve that source through packaging and publication.\n- [ ] `./scripts/release/check-versions.sh --require-dated-release` and\n `python3 scripts/check-contributor-credit.py` pass for the candidate.\n- [ ] `./scripts/release/publish-crates.sh dry-run` passes for the frozen\n candidate. Cargo 1.90+ verifies every release tarball and its real size\n without uploading. CI/RC does not replace this check. Retain the receipt;\n do not repeat it for unchanged source just to commit or push. Publish mode\n necessarily verifies the packages again before uploading.\n\n## 4. Exact-SHA full CI and release candidate\n\n- [ ] Dispatch full CI and the build-only release candidate against frozen\n `main` with the same independent SHA guard:\n ```bash\n candidate_sha=\"$(git rev-parse HEAD)\"\n gh workflow run ci.yml --ref main -f expected_sha=\"${candidate_sha}\"\n gh workflow run release-candidate.yml --ref main -f expected_sha=\"${candidate_sha}\"\n ```\n- [ ] Both runs resolve to that SHA and all required jobs execute and succeed.\n A green workflow badge alone is insufficient: ordinary main-push CI can\n delegate Linux work to CNB and omit some smoke coverage. Manual full CI\n forces the release gates on Linux, macOS and Windows.\n- [ ] The candidate's Parity job succeeds, and its artifact jobs report all seven\n targets and the complete 34-file asset inventory, including Android\n arm64, Windows arm64, `codew`, the NSIS installer, archives, checksum\n manifests, and seven compatibility-only `codewhale-tui-*` release\n filenames that are not installed commands. The packed npm wrapper installs\n against those assets and runs its delegated entrypoints. Keep run URLs and\n required evidence before the Actions artifacts expire; these are not a\n public release.\n- [ ] Windows static import receipts cover x64/ARM64 CLI, shim and compatibility\n assets, any optional host, and the NSIS setup plus its actual embedded PE\n plugins. Verify the selected architecture profile and its conservative\n limits in [ENVIRONMENTS.md](ENVIRONMENTS.md#windows-support-and-release-qualification).\n Preserve the generated candidate Uninstall.exe import/SHA receipt. Static\n import/profile PASS does not replace final release startup/install on the\n promised oldest x64 OS, native ARM64 package acceptance, or packaged/native\n app owner-crash recovery. Record or explicitly resolve these remaining\n runtime gates before tagging.\n- [ ] Complete the version-specific acceptance packet. Cross-building Android\n does not prove a real Termux/device session. Local install, actual provider\n calls and customer acceptance need separate receipts; provider spend still\n requires authorization. Required unproven acceptance blocks release;\n disclose an explicitly accepted limitation as such.\n- [ ] If source changes or `main` moves before tagging, deliberately choose the\n new candidate and replace affected proofs. Tag only a SHA with its exact\n CI/RC evidence. Do not duplicate hosted exhaustive gates locally.\n\n## 5. Publication approval\n\n- [ ] Hunter explicitly approves the exact version/SHA and publication targets:\n tag, GitHub Release, npm, GHCR, CNB release tag and legacy Homebrew\n automation, plus Cargo or other downstream publication where intended.\n Source readiness and build-only CI/RC do not authorize a tag, registry\n write, deployment or public launch.\n\n## 6. Tag and publish the approved source\n\n- [ ] Fetch `origin/main` again and verify that clean canonical `main`,\n `origin/main` and the approved SHA still agree.\n- [ ] The release source is reachable from `main`:\n `./scripts/release/ensure-release-on-main.sh HEAD`\n- [ ] Create `vX.Y.Z` from the final `main` SHA using the **Create release tag**\n workflow, or create and push a signed local tag:\n `git tag -s vX.Y.Z -m \"vX.Y.Z\" && git push origin vX.Y.Z`\n Tagging can start public automation immediately. If no run appears,\n first exclude a queued/active tag-triggered run, then dispatch only the\n existing tag: `gh workflow run release.yml --ref vX.Y.Z -f version=X.Y.Z`.\n Do not dispatch `release.yml` from `main` or start duplicate runs.\n- [ ] The Release workflow succeeds: it repeats parity/build gates, verifies\n the draft asset inventory before GitHub publication, then runs dependent\n npm, GHCR, CNB and Homebrew publication. It requires an exact-SHA green\n RC receipt and does not publish Cargo crates.\n- [ ] The public GitHub Release assets are proven to match the tag commit\n before publishing Cargo or npm:\n ```\n git fetch origin tag vX.Y.Z\n ./scripts/release/verify-release-assets.sh X.Y.Z\n ./scripts/release/require-release-tag-checkout.sh X.Y.Z\n ```\n This checks the local tag, remote tag, successful Release workflow SHA,\n full binary/archive/installer asset set, and both checksum manifests. If\n it fails, stop publication and follow the runbook's recovery gates.\n Publish from the same clean canonical checkout on `main` when `HEAD`\n matches the immutable remote tag. Do not create a detached worktree,\n switch release lanes or force-fetch a conflicting tag. If `main` advanced,\n coordinate with Hunter for a new candidate or separately authorized\n recovery plan.\n- [ ] Publish Cargo manually within the approved scope:\n `./scripts/release/publish-crates.sh publish`. It verifies every package,\n skips already-published versions and waits for each new dependency to\n appear. npm normally uses Trusted Publishing; manual recovery keeps the\n same clean-source and public-asset guards.\n\n## 7. Verify public results\n\n- [ ] The live GitHub Release body has its own `## Contributors` or\n `## Credits` section; do not rely on \"see CHANGELOG\" alone. Verify with:\n ```\n gh release view vX.Y.Z --repo codewhale-hq/CodeWhale --json body \\\n --jq '.body | test(\"## (Contributors|Credits)\")'\n ```\n- [ ] `npm view codewhale@X.Y.Z version codewhaleBinaryVersion --json`\n reports the new version on the npm registry.\n- [ ] `npm view deepseek-tui deprecated` is non-empty. The legacy npm package\n is deprecated and must not receive an `X.Y.Z` publish.\n- [ ] Distribution channels are canonical-first: the website install page\n (codewhale.net/install) shows Codewhale-native commands first (`npm install -g\n codewhale`, `curl .../install.sh | sh`); Homebrew is labeled as legacy\n compatibility; the shell installer uses codewhale-native names as documented\n in `docs/REBRAND.md#homebrew`.\n- [ ] Every release crate has the new version on crates.io; there is no\n automated Cargo publish job. Partial publication is incomplete.\n- [ ] `ghcr.io/codewhale-hq/codewhale:vX.Y.Z` and `:latest` are updated.\n- [ ] The CNB release tag and approved downstream results are verified\n separately; a successful GitHub Release does not prove them.\n- [ ] The final registry verification passes:\n ```\n ./scripts/release/check-published.sh X.Y.Z\n ```\n- [ ] Published install/upgrade smoke has its own receipt; candidate installs\n do not prove public delivery. Deployment/customer acceptance remain\n separate gates where required.\n\n## 8. Handoff and tracking\n\n- [ ] Edit the GitHub release notes to expand any CVE-style or attack\n details within the approved publication scope.\n- [ ] Re-run the GitHub Release body check after any release-workflow rerun;\n workflows can overwrite notes and accidentally remove contributor credit.\n- [ ] Note any deferred items in the next release's tracking issue.\n- [ ] Close any issues that this release fixed.\n- [ ] Update existing tracking with evidence and follow the current contributor\n communication rules in [AGENTS.md](../AGENTS.md). Handle the existing\n `sync-release-record` workflow's\n metadata proposal after publication: it currently opens a bot PR, not a\n required preparation branch. A source record does not prove deployment.\n- [ ] Report canonical path, branch, SHA, upstream state and dirty/stranded\n work. Historical branch/checkout removal needs explicit cleanup approval\n and does not block a release whose source is already reconciled.\n\n---\n\nIf a step fails, **fix the underlying cause** rather than skipping it. Pre-commit\nhooks, signing, and CI are all here to catch real problems. Do not skip hooks or\nsigning, force-push shared refs, replace public assets or move release tags to\nmake the checklist look green.\n" } ] }, @@ -373,8 +373,8 @@ }, { "path": "docs/MODES.md", - "sha256": "7026b81d483cfaf911f6467f2ef318c7eb51dd84bc8c0aaf0788f3e53138269d", - "content": "# Modes and Permission Postures\n\n> 阅读简体中文版:[zh_hans/MODES.md](zh_hans/MODES.md)\n\nCodewhale has three related concepts:\n\n- **TUI mode**: what kind of visible interaction you're in (Plan/Work/Operate).\n- **Permission posture**: how aggressively the UI asks before executing tools.\n- **Workflow**: named steps that coordinate sub-agents, with dependencies and\n results, available in any TUI mode.\n\nModel selection is separate. `--model auto` and `/model auto` route each turn to\na concrete model and thinking level; they are not TUI modes and are not part of\nthe `Tab` cycle.\n\nWorkflow runs named steps through the same sub-agent runtime; Fleet manages\ntheir saved roles and model assignments. A workflow adds ordering, result\nhandoffs, gates and a progress view. The active mode and permission posture\nstill control what each step may execute.\n\nBefore assigning steps, query `agent(action=\"roster\")` for the saved Fleet\nmodels and roles. A plan child can select a listed model with `model`, or use\nthe saved `role`/`profile` assignment. An Exact Fleet fixes each member's route.\n\n## TUI Modes\n\nPress `Tab` to complete composer menus or cycle through the visible modes\nwhen the composer is empty: **Plan → Work → Operate → Plan**. `Tab` never sends\nor queues composer text; use `Enter` to send or queue it.\nPress `Shift+Tab` to cycle permission posture (Ask → Auto-Review → Full Access).\nEvery change of effective permission writes a `Permissions changed to …` line\nin the transcript.\nPress `Ctrl+T` to cycle reasoning effort.\nRun `/mode` to open the mode picker, or switch directly with `/mode work`,\n`/mode plan`, or `/mode operate`.\n\n- **Plan**: design-first prompting. The stable primitive names remain familiar, but the runtime centrally refuses file mutation and shell execution. Read-only inspection and policy-allowed research, including deferred Web search/fetch, remain available. When a Plan turn successfully finishes a nonempty response, including a prose-only plan, the TUI asks how to continue: Work (Ask), Work (Auto-Review), or keep planning. Work sends the exact approved response as a normal follow-up and preserves that turn's existing To-dos, or adds one pending plan item without replacing unrelated work.\n- **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `bash`, `agent`, `workflow`, and `todo_write`, plus `create_goal`, `get_goal`, and `update_goal` so goal controls are available without discovery. The agent calls `create_goal` when a request is a durable objective; the host never infers a goal from wording, and the contract must not tell the model otherwise. Approval, sandbox, repository law, and managed policy decide what may execute.\n- **Operate**: Work at full strength, the \"ultra\" mode. Same tools and authority as Work; the difference is how they are used. Each substantive request becomes a goal (`create_goal`), the plan stays visible (`todo_write`), multi-part work runs through `workflow` and parallel `agent` workers, non-trivial changes get an independent reviewer before they are called done, long commands keep running in the background, and recurring or watch-type work is offered as an `automation` (created after approval). It keeps going until the goal is verified, blocked on you, or paused.\n\n`Act` and `/mode act` remain compatibility aliases for Work. Saved settings\nstill normalize to the internal value `agent`.\n\n### Tool availability by mode\n\n| Tool family | Plan | Work | Operate |\n|:---|:---:|:---:|:---:|\n| `read` and policy-allowed deferred research tools | yes | yes | yes |\n| `write` and `edit` | visible names; execution denied | approval- and policy-gated | same as Work |\n| `bash` | visible name; execution denied | approval- and policy-gated | same as Work; delegation is preferred when parallelism or isolation helps |\n| `agent` | yes, subject to child-depth authority | yes, subject to child-depth authority | yes, subject to child-depth authority |\n| Deferred native, MCP, and plugin tools | discoverable through `tool_search` when policy permits | same | same |\n| Paid or external-service tools | follows permission posture | follows permission posture | follows permission posture |\n| Access outside the workspace root | explicit trusted paths only | only through trusted paths or trust mode | same trusted-path/trust policy as Work; fleet profiles never widen it |\n\nOperate changes scheduling emphasis, not authority. It neither adds a\nmode-specific tool denial nor bypasses the active approval, sandbox, shell,\nask-rule, repository-law, or managed-policy boundary. Plan remains the\nmode-specific execution boundary for shell and write-capable tools; that\nauthority difference does not require a different primitive vocabulary.\n\n### Operate loop (one screen)\n\n```text\nUser message\n → small / chat / one-file? → parent does it (Work-equivalent tools)\n → multi-step work? → goal → named steps + dependencies + completion checks\n → Workflow phases → independent sub-agents in parallel\n → collect results → check evidence → hand off to the next phase\n → missing required result? → stop dependent work and repair the step\n → one independent task? → one direct sub-agent\n → parent integrates results and reports completed, blocked and next steps\n```\n\nLifecycle claims stay exact: dispatched ≠ settled ≠ verified.\n\n`allow_shell` controls whether `bash` can execute; it does not rename the tool\nor make mode the approval authority. Durable tasks and automation keep\nconservative omitted-field defaults and receive shell authority only when their\nsettings explicitly grant it. Stateful terminal/background controls are\nspecialized deferred tools rather than fields on the small foreground `bash`\nschema. Full Access changes the permission posture: the deterministic\nAuto-Review floor applies in the reviewing postures and is skipped under Full\nAccess, while repository-policy holds remain authoritative.\n\nAction-capable modes can discover the deferred `rlm` family through\n`tool_search`; its `open`, `eval`, `configure`, and `close` actions own persistent\nRLM sessions. The legacy split `rlm_*` spellings remain replay-only aliases.\nInside an RLM Python REPL, `sub_query_batch` fans out 1-16 cheap parallel child\ncalls pinned to `deepseek-v4-flash`.\n\nThe fast `deepseek-v4-flash` / thinking-off path is called Fin in the product\nlanguage. Fin is a seam for routing, summaries, cheap child calls, and\ncoordination work; it does not change approval behavior.\n\nThe orchestration controls remain available without taking over the starting\nscreen: `/auto` turns on Auto-Review so the agent just works, `/goal` keeps one\nobjective across turns, and `/workflow` prepares a repeatable ordered or\nfan-out workflow. They are directly callable and searchable through the full\ncommand palette, but are not pinned to the starter slash menu, idle welcome,\nfooter, or default Hotbar. A bare `/` instead opens the small task-oriented\nstarter set; use `/help` or the command palette for the complete inventory.\n\n`/goal ` sets a session objective with an optional token budget and\nkeeps active objectives visible as Work context. The agent may also create the\ngoal itself when a direct request describes a verifiable end state that will\ntake more than one turn (\"until the tests pass\", \"make X work end to end\"); it\nthen shows one receipt line and you can `/goal pause` or `/goal clear` it. Bare\n`/goal` shows progress (state, elapsed, continuations, and how to continue when\nno turn is running); with no goal and no conversation yet it prints usage.\n`/goal pause` stops goal continuation without changing the objective, `/goal\nresume` resumes and sends the objective back into the turn, `/goal complete`\nmarks it done, `/goal blocked` marks it blocked, and `/goal clear` removes it. Goal state does not change the active TUI mode,\npermission posture, or model route. This remains distinct from `--model auto`, which\nonly controls model and thinking selection.\n\nWorkflow builds on the same separation: a goal can ask the agent to keep\nworking, while Workflow supplies the repeatable workflow/progress surface for\nlarge fanout. In the UI, a Workflow run should be shown as an overlay on the\nmain screen, not as another mode beside Plan, Work, and Operate.\n\nApp-server clients can persist a thread-scoped goal with `thread/goal/set`, read\nit with `thread/goal/get`, and clear it with `thread/goal/clear`. That persisted\nrecord carries `active`, `paused`, `blocked`, `usage_limited`, `budget_limited`,\nor `complete` status plus token/time accounting fields for clients that need\nthread resume semantics.\n\n## Mode Persistence\n\nChoosing a mode interactively also sets the mode a fresh session starts in.\nTab/Shift+Tab cycling, the `Alt+A` / `Alt+P` / `Alt+Y` shortcuts, the hotbar's\nPlan/Work/Operate actions, and `/mode` all write `default_mode` to\n`~/.codewhale/settings.toml`, so switching to Operate survives a restart. The\nwrite happens off the event loop; if it fails, the TUI says so in a warning\ntoast rather than reverting silently on the next launch.\n\nMode, thinking level, and the model picker share one serialized writer, so the\nselection you made last is the one on disk — a burst of Tab presses cannot end\nup persisting whichever write happened to finish last — and a mode write never\nrolls back an unrelated key such as `default_model`.\n\nTwo paths deliberately do **not** rewrite the startup default: restoring a saved\nsession (which re-installs the mode that session was in) and a mode change\nrefused because a turn is in flight. The legacy `yolo` entry point installs Work\nplus Full Access, and `agent` is what it persists — `yolo` is a permission\nalias, never a startup mode.\n\nRe-selecting the mode you are already in is not a no-op. After a restored\nsession the live mode and `default_mode` routinely disagree, so choosing the\nlive mode again is how you make it durable; Codewhale confirms with a\n\"saved as startup default\" receipt rather than reporting \"already in that mode\".\n\nWhile a turn is running, every change to the live route is refused — mode,\nmodel, thinking level, and provider — no matter which surface you use. That\nnow includes the slash surfaces (`/mode`, `/model`, `/config `,\n`/config preset`), which are reachable mid-turn. Press\nEsc to interrupt first. The restart-only `default_mode` key is exempt, because\nit does not touch the running turn.\n\nCodewhale writes `settings.toml` under a lock that spans processes, and replaces\nthe file atomically, so a second Codewhale instance on the same home directory\ncannot lose your selection or read a half-written file. At exit, queued writes\nare flushed before the terminal is restored; anything that failed is printed on\nthe way out instead of disappearing with the alternate screen.\n\n## Compatibility Notes\n\n- Older settings files with `default_mode = \"normal\"` still load as `agent`; saving rewrites the normalized value.\n\n## Escape Key Behavior\n\n`Esc` is a cancel stack, not a mode switch.\n\n- Close slash menus or transient UI first.\n- Cancel the active request if a turn is running.\n- Discard a queued draft if the composer is empty.\n- Clear the current input if text is present.\n- Otherwise it is a no-op.\n\n## Permission Posture\n\nPermission posture controls tool approval and whether a turn may pause for a\nmissing user decision. It is one layer of the full\n[authorization order](AUTHORIZATION_ORDER.md), not a bypass for tool admission,\nrepository law, or sandbox enforcement. Cycle it with `Shift+Tab`, or edit it\nat runtime:\n\n```text\n/config\n# edit the approval_mode row to: suggest | auto | never\n```\n\nLegacy note: `/set approval_mode ...` was retired in favor of `/config`.\n\n- `suggest` (**Ask**, default): tool approvals may interrupt, and Codewhale asks\n when an unresolved user choice materially changes authority, cost, scope, or\n outcome.\n- `auto` (**Auto-Review**): reviews tool calls automatically. Deliberate user\n questions remain available in interactive sessions through `request_user_input`;\n a question parks the turn until answered, canceled, or its configured timeout\n expires. Headless `exec` withholds that tool because it has no responder.\n Tool safety holds remain separate from user questions. Two layers decide approvals. The\n **deterministic floor** (configured block rules plus the built-in safety\n floor) allows proven-safe calls and hard-blocks publish-like actions and\n destructive background/headless work; it is never model-reviewed. Fallback\n holds — calls the deterministic engine could not prove safe — escalate to a\n one-shot **model guardian** (v0.9.8) that returns risk, allow/deny, and a\n rationale. The guardian sees the exact held call and deterministic\n observations in separate JSON fields; conversation history, skill\n instructions, attachments, and expanded model context are excluded. It does\n not infer user intent or compute a generic user-intent score.\n High or critical risk cannot auto-run even if the model says allow. It has no\n tools, remembers no rules, and denies rather than truncates an oversized\n exact call. Exactly one reviewer request is made; incomplete or malformed\n output, timeout, cancellation, or provider failure fails closed. Headless\n adapters use the deterministic-only tier. Repo-law\n holds that explicitly require a person block in Auto-Review rather than\n opening a hidden approval modal.\n\nThe LLM reviewer is closest to OpenAI Codex's experimental Auto-Review at\ncommit [`6fc6b9d6d2580d62622fc9884b5f5707f6505a5e`](https://github.com/openai/codex/tree/6fc6b9d6d2580d62622fc9884b5f5707f6505a5e).\nCodex's [guardian entry point](https://github.com/openai/codex/blob/6fc6b9d6d2580d62622fc9884b5f5707f6505a5e/codex-rs/core/src/guardian/mod.rs)\nreconstructs conversation context and runs a dedicated review session.\nCodewhale deliberately adopts only the exact-action structured decision,\n90-second deadline, and fail-closed result. It does not copy Codex's transcript\nreconstruction, user-authorization score, reviewer tools, retries, persistent\nreview session, or denial ledger.\n\nKimi Code at commit\n[`1414d4602898f406e540b23342cb18db23ff9efc`](https://github.com/MoonshotAI/kimi-code/tree/1414d4602898f406e540b23342cb18db23ff9efc)\nalso has no LLM reviewer. Its ordered\n[permission policy](https://github.com/MoonshotAI/kimi-code/blob/1414d4602898f406e540b23342cb18db23ff9efc/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicyService.ts)\napplies explicit deny rules and then its\n[Auto policy](https://github.com/MoonshotAI/kimi-code/blob/1414d4602898f406e540b23342cb18db23ff9efc/packages/agent-core-v2/src/agent/permissionPolicy/policies/auto-mode-approve.ts)\nreturns `approve` directly. Codewhale uses the deterministic floor and guardian\ndescribed above, while keeping deliberate user questions available.\n\nThe sandbox and escalation baseline is grounded in DeepSeek Harness\n`0.1.0-rc.5` at\ncommit [`47f943859bef60e4160492346772ded9b24f765a`](https://github.com/deepseek-ai/deepseek-harness/tree/47f943859bef60e4160492346772ded9b24f765a):\nits [sandbox contract](https://github.com/deepseek-ai/deepseek-harness/blob/47f943859bef60e4160492346772ded9b24f765a/docs/subsystems/sandbox.md)\ndefines per-call `read-only`, `workspace-write`, and `danger-full-access`\nboundaries and forbids silent unconfined fallback; its\n[approval contract](https://github.com/deepseek-ai/deepseek-harness/blob/47f943859bef60e4160492346772ded9b24f765a/docs/subsystems/approval.md)\ngrants only `allowed-once` and fails closed on rejection, cancellation, or an\nunavailable answerer; and its\n[sandbox result contract](https://github.com/deepseek-ai/deepseek-harness/blob/47f943859bef60e4160492346772ded9b24f765a/packages/shell/bash-sandbox/README.md)\ntells the model to retry a denied command exactly once with the narrowest wider\nmode plus a justification. DeepSeek Harness does not add an LLM reviewer to\nthat path. Codewhale's autonomous posture adds only the single stateless\nguardian request described above; deterministic hard blocks remain\nnon-bypassable in the reviewing postures.\n- `bypass` (**Full Access**): ordinary tool calls do not show approval prompts,\n while deliberate user questions remain available. The deterministic\n Auto-Review floor is skipped — publish-like commands, destructive\n background/headless work, and the Windows npm-launcher platform hold do not\n strand a call the person already granted. Non-bypassable registered holds\n auto-approve instead of opening a contradictory modal. Repository-law and\n managed-policy holds fail closed as hard blocks instead of contradicting\n Full Access with an approval modal.\n- `never`: blocks any tool that is not considered safe/read-only; deliberate\n user questions remain available.\n\nThe effective posture and its question discipline are projected into every\nturn from the same runtime authority that gates tools. A mode/posture change is\ntherefore visible to the next turn. Untrusted runtime-generated input is\nnarrowed before metadata is built and cannot invent approval authority. An\nexplicit Full Access sub-agent handoff preserves the parent's standing posture\nso ordinary child work does not begin prompting again.\n\n### Children (sub-agents and fleet workers)\n\nChildren inherit the session posture faithfully rather than a bare\nauto-approve bit:\n\n- **Auto-Review**: a worker's held call goes through the same deterministic\n policy (proven-safe calls run; publish-like and destructive background work\n is hard-blocked) and, for holds it cannot prove safe, the same one-shot\n model guardian using the child's own session client. No prompt is ever\n opened for a child; an unavailable guardian denies, fail closed.\n- **Ask**: a call the role may delegate runs. A held call is raised as an\n approval prompt in the parent's UI (`agent::approval:`) when the\n host is an interactive TUI; the worker waits visibly (`waiting for user`)\n and the person's answer is routed back to it, whether the parent turn is\n idle or itself awaiting an approval. Hosts that cannot prompt deny with the\n reason.\n- **Full Access**: calls run, including destructive detached work — the\n Auto-Review floor is skipped exactly as it is for the parent turn. The\n execution envelope still applies and never widens.\n\nRole posture and the execution envelope are checked before and after this\ngate and never widen. Every decision a person did not make at a prompt is\nwritten to the audit log and to the child's transcript as a one-line note\n(`Auto-Review allowed 'bash' (low risk, model guardian): …`), visible when\nthe worker is focused.\n\n## Small-Screen Status Behavior\n\nWhen terminal height is constrained, the status area compacts first so header/chat/composer/footer remain visible:\n\n- Loading and queued status rows are budgeted by available height.\n- Queued previews collapse to compact summaries when full previews do not fit.\n- `/queue` workflows remain available; compact status only affects rendering density.\n\n## Workspace Boundary and Trust Mode\n\nBy default, file tools are restricted to the `--workspace` directory. Enable trust mode to allow file access outside the workspace:\n\n```text\n/trust on\n```\n\nBare `/trust` (like `/trust status`) only *reports* the current setting — it\ndoes not enable anything. Use `/trust off` to restrict access again.\n\nFull Access enables trust mode automatically.\n\n## MCP Behavior\n\nMCP tools are exposed as `mcp__` and use the same approval flow as\nbuilt-in tools. Read-only MCP helpers may auto-run in Ask and Auto-Review when\npolicy permits; MCP tools with possible side effects require approval. Full\nAccess does not bypass hard policy holds.\n\nA tool's own MCP annotations count only as far as their source is trusted. A\ntool from a reviewed, enabled plugin that declares `readOnlyHint: true` runs\nlike the built-in read helpers, with no prompt; the same claim from any other\nserver is ignored. A tool that declares `destructiveHint: true` never gets\nthat relaxation, even from a reviewed plugin, and its approval card says the\nserver marked it destructive. Full Access still runs it without a prompt, like\nany other tool that would ask.\n\nSee `MCP.md`.\n\n## Related CLI Flags\n\nRun `codewhale --help` for the canonical list. Common flags:\n\n- `-p, --prompt `: one-shot prompt mode (prints and exits)\n- `codewhale exec --auto --output-format stream-json `: run the tool-backed non-interactive agent and emit one JSON object per line for harnesses and backend wrappers. Exit codes: `0` on success, `1` for genuine task/agent failures, `75` (`EX_TEMPFAIL`) when the turn ended on a retryable infrastructure failure (provider/transport `network`/`timeout` after all in-session retries) so harnesses can tell a retryable infra exit apart from a task failure; the terminal stream `metadata` event's `error_category` carries the same classification\n- `codewhale exec --prompt-file ` / `cat prompt.txt | codewhale exec --prompt-file -`: read the prompt from a file or stdin instead of argv, for prompts past the OS per-argument limit (~128 KiB on Linux). Conflicts with a positional prompt, and a positional `-` is literal prompt text; `--prompt-file -` is refused with `--parent-death-watch`, which owns stdin\n- `codewhale exec --resume ` / `--session-id `: continue a saved session non-interactively\n- `codewhale exec --continue `: continue the most recent saved session for this workspace non-interactively\n- `codewhale fork ` / `codewhale fork --last`: copy a saved session into a new sibling session; forked sessions retain additive parent-session metadata and show that lineage in session listings\n- `--model `: when using the `codewhale` facade, forward a DeepSeek model override to the TUI\n- `--workspace `: workspace root for file tools\n- `-r, --resume `: resume a saved session\n- `-c, --continue`: resume the most recent session in this workspace\n- `--max-subagents `: clamp to `1..=128`\n- `--mouse-capture` / `--no-mouse-capture`: opt in or out of internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. Mouse capture is enabled by default on non-Windows terminals and on Windows Terminal/ConEmu/Cmder so drag selection copies only transcript text, removes visual wrap-column line breaks from paragraphs, and stays scoped to the transcript pane; hold Shift while dragging or use `--no-mouse-capture` for raw terminal selection. It defaults off on legacy Windows console (CMD without `WT_SESSION` / `ConEmuPID`) and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where the terminal advertises mouse support but forwards SGR mouse events as raw text (#878, #898). Use `--mouse-capture` to opt in anywhere it's defaulted off. Raw terminal selection may cross the right workbar and include visual wraps because the terminal, not the TUI, owns the selection.\n- `--profile `: select config profile\n- `--config `: config file path\n- `-v, --verbose`: verbose logging\n\n## Branching and Rollback\n\nCodewhale has three related but intentionally separate recovery paths:\n\n- `codewhale fork ` creates a new saved session from an existing saved\n conversation and records the source session id. This is the safe way to\n explore a different answer path without overwriting the original session.\n- Esc-Esc backtrack rewinds the live transcript to a previous user prompt and\n restores that prompt into the composer for editing. It does not change\n files; `/undo` afterwards puts back the files that request changed.\n- `/restore` and the `revert_turn` tool restore workspace files from side-git\n snapshots. `/restore list [N]` lists more snapshot options before choosing a\n rollback point. They do not rewrite conversation history.\n\nA Pi-style in-file tree browser is a larger UI/data-model project. v0.8.40\nships the bounded fork/backtrack primitives and explicit lineage metadata.\n" + "sha256": "2beb562e60c861e8d48781eb57e854c4d1a5177e8cc2f179eadc80a45edbbb84", + "content": "# Modes and Permission Postures\n\n> 阅读简体中文版:[zh_hans/MODES.md](zh_hans/MODES.md)\n\nCodewhale has three related concepts:\n\n- **TUI mode**: what kind of visible interaction you're in (Plan/Work/Operate).\n- **Permission posture**: how aggressively the UI asks before executing tools.\n- **Workflow**: named steps that coordinate sub-agents, with dependencies and\n results, available in any TUI mode.\n\nModel selection is separate. `--model auto` and `/model auto` route each turn to\na concrete model and thinking level; they are not TUI modes and are not part of\nthe `Tab` cycle.\n\nWorkflow runs named steps through the same sub-agent runtime; Fleet manages\ntheir saved roles and model assignments. A workflow adds ordering, result\nhandoffs, gates and a progress view. The active mode and permission posture\nstill control what each step may execute.\n\nBefore assigning steps, query `agent(action=\"roster\")` for the saved Fleet\nmodels and roles. A plan child can select a listed model with `model`, or use\nthe saved `role`/`profile` assignment. An Exact Fleet fixes each member's route.\n\n## TUI Modes\n\nPress `Tab` to complete composer menus or cycle through the visible modes\nwhen the composer is empty: **Plan → Work → Operate → Plan**. `Tab` never sends\nor queues composer text; use `Enter` to send or queue it.\nPress `Shift+Tab` to cycle permission posture (Ask → Auto-Review → Full Access).\nEvery change of effective permission writes a `Permissions changed to …` line\nin the transcript.\nPress `Ctrl+T` to cycle reasoning effort.\nRun `/mode` to open the mode picker, or switch directly with `/mode work`,\n`/mode plan`, or `/mode operate`.\n\n- **Plan**: design-first prompting. The stable primitive names remain familiar, but the runtime centrally refuses file mutation and shell execution. Read-only inspection and policy-allowed research, including deferred Web search/fetch, remain available. When a Plan turn successfully finishes a nonempty response, including a prose-only plan, the TUI asks how to continue: Work (Ask), Work (Auto-Review), or keep planning. Work sends the exact approved response as a normal follow-up and preserves that turn's existing To-dos, or adds one pending plan item without replacing unrelated work.\n- **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `file_search`, `bash`, `agent`, `workflow`, `todo_write`, and `load_skill`, plus `create_goal`, `get_goal`, and `update_goal` so goal controls are available without discovery. The agent calls `create_goal` when a request is a durable objective; the host never infers a goal from wording, and the contract must not tell the model otherwise. Approval, sandbox, repository law, and managed policy decide what may execute.\n- **Operate**: Work at full strength, the \"ultra\" mode. Same tools and authority as Work; the difference is how they are used. Each substantive request becomes a goal (`create_goal`), the plan stays visible (`todo_write`), multi-part work runs through `workflow` and parallel `agent` workers, non-trivial changes get an independent reviewer before they are called done, long commands keep running in the background, and recurring or watch-type work is offered as an `automation` (created after approval). It keeps going until the goal is verified, blocked on you, or paused.\n\n`Act` and `/mode act` remain compatibility aliases for Work. Saved settings\nstill normalize to the internal value `agent`.\n\n### Tool availability by mode\n\n| Tool family | Plan | Work | Operate |\n|:---|:---:|:---:|:---:|\n| `read` and policy-allowed deferred research tools | yes | yes | yes |\n| `write` and `edit` | visible names; execution denied | approval- and policy-gated | same as Work |\n| `bash` | visible name; execution denied | approval- and policy-gated | same as Work; delegation is preferred when parallelism or isolation helps |\n| `agent` | yes, subject to child-depth authority | yes, subject to child-depth authority | yes, subject to child-depth authority |\n| Deferred native, MCP, and plugin tools | discoverable through `tool_search` when policy permits | same | same |\n| Paid or external-service tools | follows permission posture | follows permission posture | follows permission posture |\n| Access outside the workspace root | explicit trusted paths only | only through trusted paths or trust mode | same trusted-path/trust policy as Work; fleet profiles never widen it |\n\nOperate changes scheduling emphasis, not authority. It neither adds a\nmode-specific tool denial nor bypasses the active approval, sandbox, shell,\nask-rule, repository-law, or managed-policy boundary. Plan remains the\nmode-specific execution boundary for shell and write-capable tools; that\nauthority difference does not require a different primitive vocabulary.\n\n### Operate loop (one screen)\n\n```text\nUser message\n → small / chat / one-file? → parent does it (Work-equivalent tools)\n → multi-step work? → goal → named steps + dependencies + completion checks\n → Workflow phases → independent sub-agents in parallel\n → collect results → check evidence → hand off to the next phase\n → missing required result? → stop dependent work and repair the step\n → one independent task? → one direct sub-agent\n → parent integrates results and reports completed, blocked and next steps\n```\n\nLifecycle claims stay exact: dispatched ≠ settled ≠ verified.\n\n`allow_shell` controls whether `bash` can execute; it does not rename the tool\nor make mode the approval authority. Durable tasks and automation keep\nconservative omitted-field defaults and receive shell authority only when their\nsettings explicitly grant it. Stateful terminal/background controls are\nspecialized deferred tools rather than fields on the small foreground `bash`\nschema. Full Access changes the permission posture: the deterministic\nAuto-Review floor applies in the reviewing postures and is skipped under Full\nAccess, while repository-policy holds remain authoritative.\n\nAction-capable modes can discover the deferred `rlm` family through\n`tool_search`; its `open`, `eval`, `configure`, and `close` actions own persistent\nRLM sessions. The legacy split `rlm_*` spellings remain replay-only aliases.\nInside an RLM Python REPL, `sub_query_batch` fans out 1-16 cheap parallel child\ncalls pinned to `deepseek-v4-flash`.\n\nThe fast `deepseek-v4-flash` / thinking-off path is called Fin in the product\nlanguage. Fin is a seam for routing, summaries, cheap child calls, and\ncoordination work; it does not change approval behavior.\n\nThe orchestration controls remain available without taking over the starting\nscreen: `/auto` turns on Auto-Review so the agent just works, `/goal` keeps one\nobjective across turns, and `/workflow` prepares a repeatable ordered or\nfan-out workflow. They are directly callable and searchable through the full\ncommand palette, but are not pinned to the starter slash menu, idle welcome,\nfooter, or default Hotbar. A bare `/` instead opens the small task-oriented\nstarter set; use `/help` or the command palette for the complete inventory.\n\n`/goal ` sets a session objective with an optional token budget and\nkeeps active objectives visible as Work context. The agent may also create the\ngoal itself when a direct request describes a verifiable end state that will\ntake more than one turn (\"until the tests pass\", \"make X work end to end\"); it\nthen shows one receipt line and you can `/goal pause` or `/goal clear` it. Bare\n`/goal` shows progress (state, elapsed, continuations, and how to continue when\nno turn is running); with no goal and no conversation yet it prints usage.\n`/goal pause` stops goal continuation without changing the objective, `/goal\nresume` resumes and sends the objective back into the turn, `/goal complete`\nmarks it done, `/goal blocked` marks it blocked, and `/goal clear` removes it. Goal state does not change the active TUI mode,\npermission posture, or model route. This remains distinct from `--model auto`, which\nonly controls model and thinking selection.\n\nWorkflow builds on the same separation: a goal can ask the agent to keep\nworking, while Workflow supplies the repeatable workflow/progress surface for\nlarge fanout. In the UI, a Workflow run should be shown as an overlay on the\nmain screen, not as another mode beside Plan, Work, and Operate.\n\nApp-server clients can persist a thread-scoped goal with `thread/goal/set`, read\nit with `thread/goal/get`, and clear it with `thread/goal/clear`. That persisted\nrecord carries `active`, `paused`, `blocked`, `usage_limited`, `budget_limited`,\nor `complete` status plus token/time accounting fields for clients that need\nthread resume semantics.\n\n## Mode Persistence\n\nChoosing a mode interactively also sets the mode a fresh session starts in.\nTab/Shift+Tab cycling, the `Alt+A` / `Alt+P` / `Alt+Y` shortcuts, the hotbar's\nPlan/Work/Operate actions, and `/mode` all write `default_mode` to\n`~/.codewhale/settings.toml`, so switching to Operate survives a restart. The\nwrite happens off the event loop; if it fails, the TUI says so in a warning\ntoast rather than reverting silently on the next launch.\n\nMode, thinking level, and the model picker share one serialized writer, so the\nselection you made last is the one on disk — a burst of Tab presses cannot end\nup persisting whichever write happened to finish last — and a mode write never\nrolls back an unrelated key such as `default_model`.\n\nTwo paths deliberately do **not** rewrite the startup default: restoring a saved\nsession (which re-installs the mode that session was in) and a mode change\nrefused because a turn is in flight. The legacy `yolo` entry point installs Work\nplus Full Access, and `agent` is what it persists — `yolo` is a permission\nalias, never a startup mode.\n\nRe-selecting the mode you are already in is not a no-op. After a restored\nsession the live mode and `default_mode` routinely disagree, so choosing the\nlive mode again is how you make it durable; Codewhale confirms with a\n\"saved as startup default\" receipt rather than reporting \"already in that mode\".\n\nWhile a turn is running, every change to the live route is refused — mode,\nmodel, thinking level, and provider — no matter which surface you use. That\nnow includes the slash surfaces (`/mode`, `/model`, `/config `,\n`/config preset`), which are reachable mid-turn. Press\nEsc to interrupt first. The restart-only `default_mode` key is exempt, because\nit does not touch the running turn.\n\nCodewhale writes `settings.toml` under a lock that spans processes, and replaces\nthe file atomically, so a second Codewhale instance on the same home directory\ncannot lose your selection or read a half-written file. At exit, queued writes\nare flushed before the terminal is restored; anything that failed is printed on\nthe way out instead of disappearing with the alternate screen.\n\n## Compatibility Notes\n\n- Older settings files with `default_mode = \"normal\"` still load as `agent`; saving rewrites the normalized value.\n\n## Escape Key Behavior\n\n`Esc` is a cancel stack, not a mode switch.\n\n- Close slash menus or transient UI first.\n- Cancel the active request if a turn is running.\n- Discard a queued draft if the composer is empty.\n- Clear the current input if text is present.\n- Otherwise it is a no-op.\n\n## Permission Posture\n\nPermission posture controls tool approval and whether a turn may pause for a\nmissing user decision. It is one layer of the full\n[authorization order](AUTHORIZATION_ORDER.md), not a bypass for tool admission,\nrepository law, or sandbox enforcement. Cycle it with `Shift+Tab`, or edit it\nat runtime:\n\n```text\n/config\n# edit the approval_mode row to: suggest | auto | never\n```\n\nLegacy note: `/set approval_mode ...` was retired in favor of `/config`.\n\n- `suggest` (**Ask**, default): tool approvals may interrupt, and Codewhale asks\n when an unresolved user choice materially changes authority, cost, scope, or\n outcome.\n- `auto` (**Auto-Review**): reviews tool calls automatically. Deliberate user\n questions remain available in interactive sessions through `request_user_input`;\n a question parks the turn until answered, canceled, or its configured timeout\n expires. Headless `exec` withholds that tool because it has no responder.\n Tool safety holds remain separate from user questions. Two layers decide approvals. The\n **deterministic floor** (configured block rules plus the built-in safety\n floor) allows proven-safe calls and hard-blocks publish-like actions and\n destructive background/headless work; it is never model-reviewed. Fallback\n holds — calls the deterministic engine could not prove safe — escalate to a\n one-shot **model guardian** (v0.9.8) that returns risk, allow/deny, and a\n rationale. The guardian sees the exact held call and deterministic\n observations in separate JSON fields; conversation history, skill\n instructions, attachments, and expanded model context are excluded. It does\n not infer user intent or compute a generic user-intent score.\n High or critical risk cannot auto-run even if the model says allow. It has no\n tools, remembers no rules, and denies rather than truncates an oversized\n exact call. Exactly one reviewer request is made; incomplete or malformed\n output, timeout, cancellation, or provider failure fails closed. Headless\n adapters use the deterministic-only tier. Repo-law\n holds that explicitly require a person block in Auto-Review rather than\n opening a hidden approval modal.\n\nThe LLM reviewer is closest to OpenAI Codex's experimental Auto-Review at\ncommit [`6fc6b9d6d2580d62622fc9884b5f5707f6505a5e`](https://github.com/openai/codex/tree/6fc6b9d6d2580d62622fc9884b5f5707f6505a5e).\nCodex's [guardian entry point](https://github.com/openai/codex/blob/6fc6b9d6d2580d62622fc9884b5f5707f6505a5e/codex-rs/core/src/guardian/mod.rs)\nreconstructs conversation context and runs a dedicated review session.\nCodewhale deliberately adopts only the exact-action structured decision,\n90-second deadline, and fail-closed result. It does not copy Codex's transcript\nreconstruction, user-authorization score, reviewer tools, retries, persistent\nreview session, or denial ledger.\n\nKimi Code at commit\n[`1414d4602898f406e540b23342cb18db23ff9efc`](https://github.com/MoonshotAI/kimi-code/tree/1414d4602898f406e540b23342cb18db23ff9efc)\nalso has no LLM reviewer. Its ordered\n[permission policy](https://github.com/MoonshotAI/kimi-code/blob/1414d4602898f406e540b23342cb18db23ff9efc/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicyService.ts)\napplies explicit deny rules and then its\n[Auto policy](https://github.com/MoonshotAI/kimi-code/blob/1414d4602898f406e540b23342cb18db23ff9efc/packages/agent-core-v2/src/agent/permissionPolicy/policies/auto-mode-approve.ts)\nreturns `approve` directly. Codewhale uses the deterministic floor and guardian\ndescribed above, while keeping deliberate user questions available.\n\nThe sandbox and escalation baseline is grounded in DeepSeek Harness\n`0.1.0-rc.5` at\ncommit [`47f943859bef60e4160492346772ded9b24f765a`](https://github.com/deepseek-ai/deepseek-harness/tree/47f943859bef60e4160492346772ded9b24f765a):\nits [sandbox contract](https://github.com/deepseek-ai/deepseek-harness/blob/47f943859bef60e4160492346772ded9b24f765a/docs/subsystems/sandbox.md)\ndefines per-call `read-only`, `workspace-write`, and `danger-full-access`\nboundaries and forbids silent unconfined fallback; its\n[approval contract](https://github.com/deepseek-ai/deepseek-harness/blob/47f943859bef60e4160492346772ded9b24f765a/docs/subsystems/approval.md)\ngrants only `allowed-once` and fails closed on rejection, cancellation, or an\nunavailable answerer; and its\n[sandbox result contract](https://github.com/deepseek-ai/deepseek-harness/blob/47f943859bef60e4160492346772ded9b24f765a/packages/shell/bash-sandbox/README.md)\ntells the model to retry a denied command exactly once with the narrowest wider\nmode plus a justification. DeepSeek Harness does not add an LLM reviewer to\nthat path. Codewhale's autonomous posture adds only the single stateless\nguardian request described above; deterministic hard blocks remain\nnon-bypassable in the reviewing postures.\n- `bypass` (**Full Access**): ordinary tool calls do not show approval prompts,\n while deliberate user questions remain available. The deterministic\n Auto-Review floor is skipped — publish-like commands, destructive\n background/headless work, and the Windows npm-launcher platform hold do not\n strand a call the person already granted. Non-bypassable registered holds\n auto-approve instead of opening a contradictory modal. Repository-law and\n managed-policy holds fail closed as hard blocks instead of contradicting\n Full Access with an approval modal.\n- `never`: blocks any tool that is not considered safe/read-only; deliberate\n user questions remain available.\n\nThe effective posture and its question discipline are projected into every\nturn from the same runtime authority that gates tools. A mode/posture change is\ntherefore visible to the next turn. Untrusted runtime-generated input is\nnarrowed before metadata is built and cannot invent approval authority. An\nexplicit Full Access sub-agent handoff preserves the parent's standing posture\nso ordinary child work does not begin prompting again.\n\n### Children (sub-agents and fleet workers)\n\nChildren inherit the session posture faithfully rather than a bare\nauto-approve bit:\n\n- **Auto-Review**: a worker's held call goes through the same deterministic\n policy (proven-safe calls run; publish-like and destructive background work\n is hard-blocked) and, for holds it cannot prove safe, the same one-shot\n model guardian using the child's own session client. No prompt is ever\n opened for a child; an unavailable guardian denies, fail closed.\n- **Ask**: a call the role may delegate runs. A held call is raised as an\n approval prompt in the parent's UI (`agent::approval:`) when the\n host is an interactive TUI; the worker waits visibly (`waiting for user`)\n and the person's answer is routed back to it, whether the parent turn is\n idle or itself awaiting an approval. Hosts that cannot prompt deny with the\n reason.\n- **Full Access**: calls run, including destructive detached work — the\n Auto-Review floor is skipped exactly as it is for the parent turn. The\n execution envelope still applies and never widens.\n\nRole posture and the execution envelope are checked before and after this\ngate and never widen. Every decision a person did not make at a prompt is\nwritten to the audit log and to the child's transcript as a one-line note\n(`Auto-Review allowed 'bash' (low risk, model guardian): …`), visible when\nthe worker is focused.\n\n## Small-Screen Status Behavior\n\nWhen terminal height is constrained, the status area compacts first so header/chat/composer/footer remain visible:\n\n- Loading and queued status rows are budgeted by available height.\n- Queued previews collapse to compact summaries when full previews do not fit.\n- `/queue` workflows remain available; compact status only affects rendering density.\n\n## Workspace Boundary and Trust Mode\n\nBy default, file tools are restricted to the `--workspace` directory. Enable trust mode to allow file access outside the workspace:\n\n```text\n/trust on\n```\n\nBare `/trust` (like `/trust status`) only *reports* the current setting — it\ndoes not enable anything. Use `/trust off` to restrict access again.\n\nFull Access enables trust mode automatically.\n\n## MCP Behavior\n\nMCP tools are exposed as `mcp__` and use the same approval flow as\nbuilt-in tools. Read-only MCP helpers may auto-run in Ask and Auto-Review when\npolicy permits; MCP tools with possible side effects require approval. Full\nAccess does not bypass hard policy holds.\n\nA tool's own MCP annotations count only as far as their source is trusted. A\ntool from a reviewed, enabled plugin that declares `readOnlyHint: true` runs\nlike the built-in read helpers, with no prompt; the same claim from any other\nserver is ignored. A tool that declares `destructiveHint: true` never gets\nthat relaxation, even from a reviewed plugin, and its approval card says the\nserver marked it destructive. Full Access still runs it without a prompt, like\nany other tool that would ask.\n\nSee `MCP.md`.\n\n## Related CLI Flags\n\nRun `codewhale --help` for the canonical list. Common flags:\n\n- `-p, --prompt `: one-shot prompt mode (prints and exits)\n- `codewhale exec --auto --output-format stream-json `: run the tool-backed non-interactive agent and emit one JSON object per line for harnesses and backend wrappers. Exit codes: `0` on success, `1` for genuine task/agent failures, `75` (`EX_TEMPFAIL`) when the turn ended on a retryable infrastructure failure (provider/transport `network`/`timeout` after all in-session retries) so harnesses can tell a retryable infra exit apart from a task failure; the terminal stream `metadata` event's `error_category` carries the same classification\n- `codewhale exec --prompt-file ` / `cat prompt.txt | codewhale exec --prompt-file -`: read the prompt from a file or stdin instead of argv, for prompts past the OS per-argument limit (~128 KiB on Linux). Conflicts with a positional prompt, and a positional `-` is literal prompt text; `--prompt-file -` is refused with `--parent-death-watch`, which owns stdin\n- `codewhale exec --resume ` / `--session-id `: continue a saved session non-interactively\n- `codewhale exec --continue `: continue the most recent saved session for this workspace non-interactively\n- `codewhale fork ` / `codewhale fork --last`: copy a saved session into a new sibling session; forked sessions retain additive parent-session metadata and show that lineage in session listings\n- `--model `: when using the `codewhale` facade, forward a DeepSeek model override to the TUI\n- `--workspace `: workspace root for file tools\n- `-r, --resume `: resume a saved session\n- `-c, --continue`: resume the most recent session in this workspace\n- `--max-subagents `: clamp to `1..=128`\n- `--mouse-capture` / `--no-mouse-capture`: opt in or out of internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. Mouse capture is enabled by default on non-Windows terminals and on Windows Terminal/ConEmu/Cmder so drag selection copies only transcript text, removes visual wrap-column line breaks from paragraphs, and stays scoped to the transcript pane; hold Shift while dragging or use `--no-mouse-capture` for raw terminal selection. It defaults off on legacy Windows console (CMD without `WT_SESSION` / `ConEmuPID`) and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where the terminal advertises mouse support but forwards SGR mouse events as raw text (#878, #898). Use `--mouse-capture` to opt in anywhere it's defaulted off. Raw terminal selection may cross the right workbar and include visual wraps because the terminal, not the TUI, owns the selection.\n- `--profile `: select config profile\n- `--config `: config file path\n- `-v, --verbose`: verbose logging\n\n## Branching and Rollback\n\nCodewhale has three related but intentionally separate recovery paths:\n\n- `codewhale fork ` creates a new saved session from an existing saved\n conversation and records the source session id. This is the safe way to\n explore a different answer path without overwriting the original session.\n- Esc-Esc backtrack rewinds the live transcript to a previous user prompt and\n restores that prompt into the composer for editing. It does not change\n files; `/undo` afterwards puts back the files that request changed.\n- `/restore` and the `revert_turn` tool restore workspace files from side-git\n snapshots. `/restore list [N]` lists more snapshot options before choosing a\n rollback point. They do not rewrite conversation history.\n\nA Pi-style in-file tree browser is a larger UI/data-model project. v0.8.40\nships the bounded fork/backtrack primitives and explicit lineage metadata.\n" }, { "path": "docs/public-surface-facts.json", @@ -400,8 +400,8 @@ "sources": [ { "path": "docs/TOOL_SURFACE.md", - "sha256": "ecfb9e76e13225dabd3313b89c40c8bc904beb8be1d88e370151df72a90bc61a", - "content": "# Tool surface\n\n> 阅读简体中文版:[zh_hans/TOOL_SURFACE.md](zh_hans/TOOL_SURFACE.md)。\n\nThis document describes the current model-facing tool contract. The v0.9.1\ncutover that produced it is recorded in `docs/RUNTIME_SIMPLIFICATION_DESIGN.md`;\nread the workspace version from `Cargo.toml`, not from this line. The registry\nremains larger than the first-turn catalog so\nsaved transcripts can replay and uncommon capabilities can be loaded on demand.\nThe model should learn one canonical name for each common operation.\n\nImplementation sources:\n\n- `crates/tui/src/core/engine/tool_catalog.rs` owns the eager/deferred catalog.\n- `crates/tui/src/tools/registry.rs` registers canonical tools and hidden aliases.\n- `crates/tui/src/tools/{file,file_tool,shell}.rs` own the small foreground\n primitive behavior and schemas; the other native tools remain searchable.\n- `docs/RUNTIME_SIMPLIFICATION_DESIGN.md` records the v0.9.1 cutover and receipt.\n\n## Default-active contract\n\nNew turns start with twelve eager native names plus synthetic `tool_search`:\n\n1. `read`\n2. `write`\n3. `edit`\n4. `file_search`\n5. `bash`\n6. `agent`\n7. `workflow`\n8. `todo_write`\n9. `create_goal`\n10. `get_goal`\n11. `update_goal`\n12. `load_skill`\n13. `tool_search` (synthetic, always active)\n\nThe twelve native names are `DEFAULT_ACTIVE_NATIVE_TOOLS` in\n`crates/tui/src/core/engine/tool_catalog.rs`, pinned by\n`default_active_contract_keeps_discovery_and_core_tools_eager`. An authority\nboundary may remove `agent` at the maximum child depth, but route size alone\nmust not change this core vocabulary.\n\nThe direct schemas deliberately stay small:\n\n| Tool | Input | Purpose |\n|---|---|---|\n| `read` | `path`, optional `offset`, optional `limit` | Read a bounded file window with explicit continuation or truncation notices. |\n| `write` | `path`, `content` | Create or replace a file. |\n| `edit` | `path`, `edits` | Apply one or more unambiguous text replacements against one original snapshot. |\n| `bash` | `command`, optional `timeout`, optional `description` | Run one cancellable foreground shell command and return a bounded tail. The optional description is display-only. |\n| `agent` | delegated task and optional scope/context controls | Start or inspect focused child work. |\n| `workflow` | plan/script/source_path plus run controls | Coordinate multi-agent phases with dependencies and completion checks. |\n| `todo_write` | complete replacement list of `{content, status}` items | Keep optional, agent-owned progress notes for genuinely multi-step work. |\n| `create_goal` | objective plus optional budget | Start the session goal the turn works toward. |\n| `get_goal` | none | Read the active goal and its progress. |\n| `update_goal` | terminal status | Mark the goal complete or blocked. |\n| `tool_search` | `query`, optional matching controls | Discover policy-allowed deferred tools and add selected schemas to this conversation's toolbox. |\n\nMode is an authority decision, not a synonym system. Plan, Work, and Operate\nuse the same primitive identities. Plan centrally refuses `write`, `edit`, and\n`bash`; Work and Operate still pass those calls through approval, sandbox,\ntrusted-path, repository-law, and managed-policy gates. Full Access changes\nordinary approval behavior but does not bypass hard safety or repository law.\n\n`update_plan` remains registered only for saved-artifact compatibility and is\nnot model-visible. `tasks`, `Git`, `Run`, `Web`, `remember`, and other\nspecialized capabilities are searchable rather than first-turn ceremony.\n\nA Bash description appears as a muted, bounded note under the command on the\nTUI shell card, including on automatic runs. It never replaces the command and\nis excluded from execution, exact approval keys and Auto-Review input. The\noriginal description remains in the recorded tool input and exports. Approval\nprompts, `/jobs`, the activity inspector, ACP and other Runtime clients keep\nshowing the command; this change adds no description row to those surfaces.\n\n## Local code execution\n\n`code_execution` (Python) and `js_execution` (Node.js) use the same\npermission-aware local launcher as workspace task gates and test runs. Ordinary\napproval retains the effective session policy. To retry a denied exact call\nwith wider permissions, supply `sandbox_permissions` (`workspace-write` or\n`danger-full-access`) and a nonempty `justification`; Ask mode requires explicit\none-shot user approval. The approved policy affects only that call.\n\nBoth tools read source from stdin, return stdout/stderr/return_code, and keep\nthe existing timeout and process-tree cleanup. They are not persistent REPLs;\ntracebacks refer to stdin rather than a temporary script path. The separate\nRLM/REPL kernel is not changed by this launcher integration.\n\nPlatform/backend limits of the shared launcher still apply: no available local\nwrapper means workspace-write remains unenforced, while read-only is refused.\nAn external sandbox session cannot use these local interpreter tools and must\nuse its shell execution path. See [sandbox limits](SANDBOX.md).\n\n## Deferred and dynamic tools\n\n`Web` is conditional and deferred. It is discoverable through `tool_search`\nonly when the active policy and runtime backend permit it. Read-only\nchildren retain its read-only search/fetch evidence path; read-only authority\ndoes not mean \"unable to research.\"\n\nThe durable `github`, `automation`, and `rlm` action families are also deferred\nby default. `rlm` owns `open`, `eval`, `configure`, and `close` actions for a\npersistent local Python session (a subprocess with a scrubbed environment, not\nan OS sandbox). Inline ```` ```repl ```` fences in a reply run in the same kind of\nkernel only when `code_execution` is on the turn's surface (never in Plan mode),\nonly when the fence opens its own line, and only after `code_execution`'s\napproval under the session posture. Feature-gated native tools may be added to\nthe active or deferred catalog only when their implementation and host\ndependencies are available.\n\nMCP tools are dynamic. Successfully connected servers register names such as\n`mcp__` from `~/.codewhale/mcp.json`; a failed or disabled server\nmust not be presented as available. MCP and plugin tools are deferred unless a\nuser explicitly names them in `[tools].always_load`.\n\nFor an unstarted configured server, an MCP-focused `tool_search` first\nperforms bounded discovery under the current turn's server/tool ceiling.\nIt searches real server-provided schemas after connection, rather than\ninventing `mcp_*` definitions. A search inside `execute_tools` describes\nthose schemas without activating them; a direct search uses the existing\nbounded activation cache. Ordinary unrelated searches leave optional servers\nunstarted. The CLI's standalone MCP inspection commands own a separate pool.\n\n### Code mode (`execute_tools`)\n\n`execute_tools` is engine-injected, alongside the synthetic interpreter tools.\nIt runs a JavaScript program whose only host surface is\n`await tools.call(name, args)`, and it is the default way to compose several\ntool calls — MCP and plugin tools included — without round-tripping every\nintermediate result through the conversation. It is hidden from Plan mode and\nrefused under a worker authority envelope.\n\n- **One gate.** In a session turn every nested call is sent back to the turn\n loop and planned exactly like a direct call: deny/allow lists, preparation\n (MCP `readOnlyHint`/`destructiveHint`), `tool_call_before` hooks, ask-rules,\n Auto-Review, repository law, and the Computer Use consent refusal. MCP calls\n run through the session MCP pool. Approving the program grants nothing, so\n `execute_tools` itself is auto-approved. If the permission posture changes\n while a program runs, its remaining nested calls are refused (an approved\n call survives only an equal or broader posture, as for a direct call) and\n the model retries them under the new posture.\n- **Approvals suspend the program.** A nested call that needs approval raises\n the normal approval card (named `execute_tools program call: ...`) and the\n program waits; allow resumes it, deny fails only that nested call as an\n exception the program can catch. Time spent waiting on a decision does not\n count against the program's run deadline, which is the turn's remaining\n wall clock.\n- **Receipts.** The result lists every nested call with its decision (`auto`,\n `approved`, `denied`, `refused`) and status (`ok`, `failed`, `refused`,\n `in_flight`). A program that hits its deadline still returns the receipt;\n `in_flight` calls were cancelled and may have partially run. Each nested\n result is `{content, metadata, truncated}`; an oversized result keeps that\n shape, and `truncated` names the original size and the spillover file with\n the full output.\n- **Discovery without re-pinning.** Inside a program,\n `tools.call('tool_search', {query})` returns matching deferred tools with\n their input schemas and does not activate them, so the request's tool array\n and the session-pinned prefix do not change.\n- **Stays direct:** `agent`, `workflow`, `request_user_input`, nested\n `execute_tools`, interactive shells, sandbox escalation, Computer Use\n consent and scripts, and MCP sign-in (`mcp__authenticate`).\n\nCode mode is on by default (`[features] code_mode = true`), which makes\n`execute_tools` eager from the first request; direct tools and `tool_search`\nstay available either way. Set `code_mode = false` (or run with\n`--disable code_mode`) to go back to deferring `execute_tools` behind\n`tool_search`. The flag is session configuration, so the prompt prefix stays\nstable within a session. Without an engine turn (sub-agents), a program keeps\nthe conservative profile: read-only, auto-approved native calls only, no MCP.\n\n### Conversation toolbox cache\n\nA successful search activation is remembered by name for the current\nconversation. The cache holds at most eight deferred names and 16 KiB of\nserialized schemas, evicts least-recently-used entries, and revalidates every\nentry against the current catalog and policy before advertising it again. A\nsession sync clears it. The cache cannot resurrect a removed, denied, or\nnewly-eager tool.\n\nEach subagent gets its own policy-filtered deferred catalog, always-present\n`tool_search`, and bounded activation cache. Forked messages and instructions\nremain in context, but the child cache starts empty and discovers tools locally;\nneither forked context nor a cache can become a discovery allowlist. A child can\nstill search every tool its own authority permits, including Web search/fetch\nfor read-only research roles.\n\n## Inspect the model-client request tool payload\n\nRun `/tools` after a model turn to inspect a bounded projection of the exact\ntool field in the latest prepared model-client request. `/tools json` emits the\nsame evidence as bounded machine-readable JSON. Both formats open in a pager;\nthey are not copied into transcript history. `/tool-studio` remains a human-\ncommand compatibility alias; it is not a model tool.\n\nThe snapshot distinguishes an absent tool field from a present empty array. It\nreports the exact model-client tool JSON byte count and SHA-256 digest only when\nmeasurement fits the one-MiB inspection bound; larger payloads stay unavailable.\nProvider adapters may transform, sanitize, or omit those fields while building\na provider-specific wire body, so `/tools` marks provider delivery and the wire\npayload unavailable. Capture and rendering are bounded: retained schemas,\ndescriptions, caller lists, catalog rows, turn IDs, and payload measurement all\ncarry explicit truncation, omission, or unavailable receipts. The snapshot stays\nin memory only for the current session and is replaced on each prepared request.\n\nProvider, model, approval, registry provenance, and runtime capability metadata\nare not fields in the request tool schema. `/tools` therefore reports them as\nunavailable instead of joining against mutable state or inferring values. Use\nthe separate route and permission receipts for those facts.\n\n## Modes and permission postures\n\nModes and permission postures are separate controls:\n\n- **Plan** keeps the stable primitive vocabulary but centrally refuses shell\n execution and file mutation.\n- **Work** is ordinary interactive execution.\n- **Operate** uses the same direct-tool authority as Work. Small work stays\n direct; multi-step delegation uses a compact Workflow plan with dependencies,\n bounded scopes, and completion evidence. Fleet manages the same sub-agents\n and roles. One bounded, independent task can use a direct agent; `followup`\n reuses that agent for continued work.\n- **Ask**, **Auto-Review**, and **Full Access** control approval behavior within\n an action-capable mode. They never widen Plan into write or shell access.\n\nSee `docs/MODES.md` for the full mode and posture contract.\n\n## Compatibility names\n\nThe model-facing contract is the lowercase core above. Saved v0.9.x\ntranscripts and protocol clients may still call exact hidden compatibility\nnames such as `File`, `Bash`, and the older single-operation file names. Those\nnames never enter a new model catalog or `tool_search` result.\n\nCompatibility is execution compatibility, not fuzzy aliasing: an exact legacy\ncall must reach the handler for its legacy schema. It must not be rewritten\ninto a small lowercase primitive whose input shape is different. Unknown or\nretired names still fail closed instead of guessing a destination.\n\nSpecialized native families such as `Git`, `Run`, and `Web` are not aliases for\nthe lowercase core. They remain real, policy-filtered deferred tools and are\nloaded through `tool_search` when needed.\n\n## Long-running work\n\n`bash` runs one cancellable foreground command. It does not carry background,\nTTY, wait, interact, or cancel action fields. Stateful process and terminal\ncontrol is specialized functionality that must be discovered explicitly; it\ndoes not enlarge the first-turn shell schema.\n\nUse `tasks` when the work itself needs a durable lifecycle, structured gates,\nartifacts, replayable timelines, or a stable task id. Large tool results should\nremain behind bounded handles or artifacts instead of being copied wholesale\ninto the parent transcript.\n\n## Parallel fan-out\n\nThe sub-agent capacity source of truth is\n`crates/tui/src/config/subagent_limits.rs`:\n\n- default configured concurrency: **64**;\n- maximum configured concurrency: **128**;\n- maximum admitted running-plus-queued work: **1024**.\n\nThese are capacity ceilings, not advice to dispatch every available slot. A\nmanager should use the smallest useful fan-out, preserve a single owner for\nfan-in, and verify worker receipts before reporting combined completion.\n\nRLM child-query batching is a different, cheaper cost class. Its\n`sub_query_batch` helper accepts 1–16 one-shot children inside a live `rlm`\nsession; it is not a substitute for tool-carrying `agent` workers.\n\n## Human inspection: `/tools` (`/tool-studio`)\n\n`/tools` renders a **read-only, bounded human projection** of the tool field of\nthe request that was prepared for one `(turn, step)`. It is not a second\nregistry and not an execution surface.\n\n**The seam.** The snapshot is built in `crates/tui/src/core/engine/turn_loop.rs`\nimmediately after `MessageRequest` is constructed, from `request.tools` — the\nsame value the model client is handed. The engine resolves the surrounding\nper-turn data once in `engine.rs` (`ToolSurfaceContext`: flattened registry\nfacts, the MCP pool's own server attribution, the engine-injected catalog names,\nand the resolved model client's receipt) and passes it as plain data, so the\nper-step seam never re-locks the MCP pool or holds a tool object.\n\n**Turn and step identity.** The tool set can differ between steps of a turn, so\neach snapshot is stamped with turn id and step and each seam emits its own. The\nTUI keeps only the latest (`SessionState.last_tool_request_snapshot`). Before\nthe first seam there is no snapshot and `/tools` says so rather than rebuilding\na registry in the UI.\n\nTwo kinds of fact are kept apart:\n\n- **Wire facts** come from the prepared request: name, description, schema,\n `defer_loading` / `strict` / `allowed_callers` / `cache_control`, byte\n accounting, and the catalog digest.\n- **Surface facts** come from the `ToolSurfaceContext`: provenance\n (`builtin` / `plugin` / `mcp` / `synthetic` / `unknown`), MCP server identity,\n declared capabilities, declared approval requirement, and model visibility.\n\nContract:\n\n- **One digest.** `active_tool_catalog_sha256`\n (`crates/tui/src/core/engine/preview.rs`) is the single definition of the\n active-tool-catalog hash. The request manifest publishes it as\n `ToolSurfaceFacts::active_tool_catalog_sha256` and `/tools` reports the same\n value for the same prepared request; neither surface keeps a hash of its own.\n- **Nothing is guessed.** MCP server identity is shown only when the real pool\n attributed that exact model tool name. `McpPool::mcp_model_tool_name` is the\n single definition shared by the model catalog and the human attribution, and\n an ambiguous name (two servers colliding on one model name) resolves to no\n server. Synthetic provenance comes from\n `default_synthetic_catalog_tool_names`, which is asserted against the engine's\n own `is_synthetic_catalog_tool` predicate. A transmitted tool with no registry\n entry reports `capabilities: unknown`, never \"none\".\n- **Provider availability follows the resolved client.** It comes from\n `Engine::tool_surface_provider_receipt`, never from \"a tool registry exists\".\n With no client the receipt is `unavailable` even when the registry is full.\n- **Unknown shrinks, it does not vanish.** `unavailable_for_this_request` always\n contains `provider_wire_payload`: nothing on this path observes what the\n provider adapter finally transmits. It additionally contains `provider` and\n `model` without a resolved client, and `provenance` / `capabilities` /\n `approval` when no surface context was captured.\n- **Absent stays distinct from empty.** A request with no tools field is not a\n request with an empty tools array; an unresolved field is `unknown` with a\n reason, not a default.\n- **Bounded.** Rendering is capped by tool count (32), name, description, schema\n bytes, allowed-caller count, and a payload measurement bound, each with an\n explicit truncation or omission receipt. Registered tools that this request\n does *not* carry are reported as a bounded name list plus an exact count\n rather than expanding the projection.\n- **Inert.** The snapshot lives beside the transcript, never in\n `session.messages`, so it cannot enter a model request or perturb the\n provider's prefix cache. It never executes a tool, never reads credentials,\n never reorders the catalog, and is never registered as a model-callable tool.\n- **Delivery is never claimed.** The capture happens before connection setup, so\n `delivery_status` stays `unknown`.\n\n## Release verification\n\nDo not infer the public surface from handler function names. Verify the model\ncatalog and alias visibility at the exact candidate SHA:\n\n```bash\npython3 scripts/measure-runtime-contract.py\ncargo test -p codewhale-tui --lib --locked core::engine::tests::default_active_contract_keeps_discovery_and_core_tools_eager -- --exact\ncargo test -p codewhale-tui --lib --locked tools::file_tool::tests::primitive_schemas_are_separate_and_small_contract_shaped -- --exact\ncargo test -p codewhale-tui --lib --locked tools::shell::tests::lowercase_bash_schema_is_small_contract -- --exact\ncargo test --locked -p codewhale-tui --lib core::engine::tests::print_mode_tool_catalog_metrics -- --ignored --exact --nocapture\n```\n\nCheck the test names against the source before trusting a green run: `cargo test`\nexits 0 with \"0 passed; N filtered out\" when a filter matches nothing, so a\nmisspelled filter is indistinguishable from a pass. Each `--exact` command\nabove must report `1 passed` (the ignored metrics test reports `1 passed`\nonly because `--ignored` selects it); `0 passed` means the filter matched\nnothing and the check did not run.\n\nThe provider-free receipt must report the twelve default-active names listed\nabove. A separate repository-wide tool count may include deferred, dynamic,\nfeature-gated, and compatibility-only registrations; it is not the number of\ntools placed in the first-turn model catalog.\n" + "sha256": "0c8f808ce7ac45604113729ce4a5e7ade82a59115032f2f3fe5317cb54d34307", + "content": "# Tool surface\n\n> 阅读简体中文版:[zh_hans/TOOL_SURFACE.md](zh_hans/TOOL_SURFACE.md)。\n\nThis document describes the current model-facing tool contract. The v0.9.1\ncutover that produced it is recorded in `docs/RUNTIME_SIMPLIFICATION_DESIGN.md`;\nread the workspace version from `Cargo.toml`, not from this line. The registry\nremains larger than the first-turn catalog so\nsaved transcripts can replay and uncommon capabilities can be loaded on demand.\nThe model should learn one canonical name for each common operation.\n\nImplementation sources:\n\n- `crates/tui/src/core/engine/tool_catalog.rs` owns the eager/deferred catalog.\n- `crates/tui/src/tools/registry.rs` registers canonical tools and hidden aliases.\n- `crates/tui/src/tools/{file,file_tool,shell}.rs` own the small foreground\n primitive behavior and schemas; the other native tools remain searchable.\n- `docs/RUNTIME_SIMPLIFICATION_DESIGN.md` records the v0.9.1 cutover and receipt.\n\n## Default-active contract\n\nNew turns start with twelve eager native names plus synthetic `tool_search`:\n\n1. `read`\n2. `write`\n3. `edit`\n4. `file_search`\n5. `bash`\n6. `agent`\n7. `workflow`\n8. `todo_write`\n9. `create_goal`\n10. `get_goal`\n11. `update_goal`\n12. `load_skill`\n13. `tool_search` (synthetic, always active)\n\nThe twelve native names are `DEFAULT_ACTIVE_NATIVE_TOOLS` in\n`crates/tui/src/core/engine/tool_catalog.rs`, pinned by\n`default_active_contract_keeps_discovery_and_core_tools_eager`. An authority\nboundary may remove `agent` at the maximum child depth, but route size alone\nmust not change this core vocabulary.\n\nThe direct schemas deliberately stay small:\n\n| Tool | Input | Purpose |\n|---|---|---|\n| `read` | `path`, optional `offset`, optional `limit` | Read a bounded file window with explicit continuation or truncation notices. |\n| `write` | `path`, `content` | Create or replace a file. |\n| `edit` | `path`, `edits` | Apply one or more unambiguous text replacements against one original snapshot. |\n| `file_search` | `query`, optional `path`, `limit`, `extensions`, `exclude` | Find files by name, path fragment or glob and return bounded, ranked matches. Respects ignore files; `exclude` replaces default exclusions. |\n| `bash` | `command`, optional `timeout`, optional `description` | Run one cancellable foreground shell command and return a bounded tail. The optional description is display-only. |\n| `agent` | delegated task and optional scope/context controls | Start or inspect focused child work. |\n| `workflow` | plan/script/source_path plus run controls | Coordinate multi-agent phases with dependencies and completion checks. |\n| `todo_write` | complete replacement list of `{content, status}` items | Keep optional, agent-owned progress notes for genuinely multi-step work. |\n| `create_goal` | objective plus optional budget | Start the session goal the turn works toward. |\n| `get_goal` | none | Read the active goal and its progress. |\n| `update_goal` | terminal status | Mark the goal complete or blocked. |\n| `load_skill` | optional `name`, `query` | Load a named skill's `SKILL.md` body and companion-file list; omit `name` or use `name=\"list\"` to list eligible skills, or use `query` to search names and descriptions. |\n| `tool_search` | `query`, optional matching controls | Discover policy-allowed deferred tools and add selected schemas to this conversation's toolbox. |\n\nMode is an authority decision, not a synonym system. Plan, Work, and Operate\nuse the same primitive identities. Plan centrally refuses `write`, `edit`, and\n`bash`; Work and Operate still pass those calls through approval, sandbox,\ntrusted-path, repository-law, and managed-policy gates. Full Access changes\nordinary approval behavior but does not bypass hard safety or repository law.\n\n`update_plan` remains registered only for saved-artifact compatibility and is\nnot model-visible. `tasks`, `Git`, `Run`, `Web`, `remember`, and other\nspecialized capabilities are searchable rather than first-turn ceremony.\n\nA Bash description appears as a muted, bounded note under the command on the\nTUI shell card, including on automatic runs. It never replaces the command and\nis excluded from execution, exact approval keys and Auto-Review input. The\noriginal description remains in the recorded tool input and exports. Approval\nprompts, `/jobs`, the activity inspector, ACP and other Runtime clients keep\nshowing the command; this change adds no description row to those surfaces.\n\n## Local code execution\n\n`code_execution` (Python) and `js_execution` (Node.js) use the same\npermission-aware local launcher as workspace task gates and test runs. Ordinary\napproval retains the effective session policy. To retry a denied exact call\nwith wider permissions, supply `sandbox_permissions` (`workspace-write` or\n`danger-full-access`) and a nonempty `justification`; Ask mode requires explicit\none-shot user approval. The approved policy affects only that call.\n\nBoth tools read source from stdin, return stdout/stderr/return_code, and keep\nthe existing timeout and process-tree cleanup. They are not persistent REPLs;\ntracebacks refer to stdin rather than a temporary script path. The separate\nRLM/REPL kernel is not changed by this launcher integration.\n\nPlatform/backend limits of the shared launcher still apply: no available local\nwrapper means workspace-write remains unenforced, while read-only is refused.\nAn external sandbox session cannot use these local interpreter tools and must\nuse its shell execution path. See [sandbox limits](SANDBOX.md).\n\n## Deferred and dynamic tools\n\n`Web` is conditional and deferred. It is discoverable through `tool_search`\nonly when the active policy and runtime backend permit it. Read-only\nchildren retain its read-only search/fetch evidence path; read-only authority\ndoes not mean \"unable to research.\"\n\nThe durable `github`, `automation`, and `rlm` action families are also deferred\nby default. `rlm` owns `open`, `eval`, `configure`, and `close` actions for a\npersistent local Python session (a subprocess with a scrubbed environment, not\nan OS sandbox). Inline ```` ```repl ```` fences in a reply run in the same kind of\nkernel only when `code_execution` is on the turn's surface (never in Plan mode),\nonly when the fence opens its own line, and only after `code_execution`'s\napproval under the session posture. Feature-gated native tools may be added to\nthe active or deferred catalog only when their implementation and host\ndependencies are available.\n\nMCP tools are dynamic. Successfully connected servers register names such as\n`mcp__` from `~/.codewhale/mcp.json`; a failed or disabled server\nmust not be presented as available. MCP and plugin tools are deferred unless a\nuser explicitly names them in `[tools].always_load`.\n\nFor an unstarted configured server, an MCP-focused `tool_search` first\nperforms bounded discovery under the current turn's server/tool ceiling.\nIt searches real server-provided schemas after connection, rather than\ninventing `mcp_*` definitions. A search inside `execute_tools` describes\nthose schemas without activating them; a direct search uses the existing\nbounded activation cache. Ordinary unrelated searches leave optional servers\nunstarted. The CLI's standalone MCP inspection commands own a separate pool.\n\n### Code mode (`execute_tools`)\n\n`execute_tools` is engine-injected, alongside the synthetic interpreter tools.\nIt runs a JavaScript program whose only host surface is\n`await tools.call(name, args)`, and it is the default way to compose several\ntool calls — MCP and plugin tools included — without round-tripping every\nintermediate result through the conversation. It is hidden from Plan mode and\nrefused under a worker authority envelope.\n\n- **One gate.** In a session turn every nested call is sent back to the turn\n loop and planned exactly like a direct call: deny/allow lists, preparation\n (MCP `readOnlyHint`/`destructiveHint`), `tool_call_before` hooks, ask-rules,\n Auto-Review, repository law, and the Computer Use consent refusal. MCP calls\n run through the session MCP pool. Approving the program grants nothing, so\n `execute_tools` itself is auto-approved. If the permission posture changes\n while a program runs, its remaining nested calls are refused (an approved\n call survives only an equal or broader posture, as for a direct call) and\n the model retries them under the new posture.\n- **Approvals suspend the program.** A nested call that needs approval raises\n the normal approval card (named `execute_tools program call: ...`) and the\n program waits; allow resumes it, deny fails only that nested call as an\n exception the program can catch. Time spent waiting on a decision does not\n count against the program's run deadline, which is the turn's remaining\n wall clock.\n- **Receipts.** The result lists every nested call with its decision (`auto`,\n `approved`, `denied`, `refused`) and status (`ok`, `failed`, `refused`,\n `in_flight`). A program that hits its deadline still returns the receipt;\n `in_flight` calls were cancelled and may have partially run. Each nested\n result is `{content, metadata, truncated}`; an oversized result keeps that\n shape, and `truncated` names the original size and the spillover file with\n the full output.\n- **Discovery without re-pinning.** Inside a program,\n `tools.call('tool_search', {query})` returns matching deferred tools with\n their input schemas and does not activate them, so the request's tool array\n and the session-pinned prefix do not change.\n- **Stays direct:** `agent`, `workflow`, `request_user_input`, nested\n `execute_tools`, interactive shells, sandbox escalation, Computer Use\n consent and scripts, and MCP sign-in (`mcp__authenticate`).\n\nCode mode is on by default (`[features] code_mode = true`), which makes\n`execute_tools` eager from the first request; direct tools and `tool_search`\nstay available either way. Set `code_mode = false` (or run with\n`--disable code_mode`) to go back to deferring `execute_tools` behind\n`tool_search`. The flag is session configuration, so the prompt prefix stays\nstable within a session. Without an engine turn (sub-agents), a program keeps\nthe conservative profile: read-only, auto-approved native calls only, no MCP.\n\n### Conversation toolbox cache\n\nA successful search activation is remembered by name for the current\nconversation. The cache holds at most eight deferred names and 16 KiB of\nserialized schemas, evicts least-recently-used entries, and revalidates every\nentry against the current catalog and policy before advertising it again. A\nsession sync clears it. The cache cannot resurrect a removed, denied, or\nnewly-eager tool.\n\nEach subagent gets its own policy-filtered deferred catalog, always-present\n`tool_search`, and bounded activation cache. Forked messages and instructions\nremain in context, but the child cache starts empty and discovers tools locally;\nneither forked context nor a cache can become a discovery allowlist. A child can\nstill search every tool its own authority permits, including Web search/fetch\nfor read-only research roles.\n\n## Inspect the model-client request tool payload\n\nRun `/tools` after a model turn to inspect a bounded projection of the exact\ntool field in the latest prepared model-client request. `/tools json` emits the\nsame evidence as bounded machine-readable JSON. Both formats open in a pager;\nthey are not copied into transcript history. `/tool-studio` remains a human-\ncommand compatibility alias; it is not a model tool.\n\nThe snapshot distinguishes an absent tool field from a present empty array. It\nreports the exact model-client tool JSON byte count and SHA-256 digest only when\nmeasurement fits the one-MiB inspection bound; larger payloads stay unavailable.\nProvider adapters may transform, sanitize, or omit those fields while building\na provider-specific wire body, so `/tools` marks provider delivery and the wire\npayload unavailable. Capture and rendering are bounded: retained schemas,\ndescriptions, caller lists, catalog rows, turn IDs, and payload measurement all\ncarry explicit truncation, omission, or unavailable receipts. The snapshot stays\nin memory only for the current session and is replaced on each prepared request.\n\nProvider, model, approval, registry provenance, and runtime capability metadata\nare not fields in the request tool schema. `/tools` therefore reports them as\nunavailable instead of joining against mutable state or inferring values. Use\nthe separate route and permission receipts for those facts.\n\n## Modes and permission postures\n\nModes and permission postures are separate controls:\n\n- **Plan** keeps the stable primitive vocabulary but centrally refuses shell\n execution and file mutation.\n- **Work** is ordinary interactive execution.\n- **Operate** uses the same direct-tool authority as Work. Small work stays\n direct; multi-step delegation uses a compact Workflow plan with dependencies,\n bounded scopes, and completion evidence. Fleet manages the same sub-agents\n and roles. One bounded, independent task can use a direct agent; `followup`\n reuses that agent for continued work.\n- **Ask**, **Auto-Review**, and **Full Access** control approval behavior within\n an action-capable mode. They never widen Plan into write or shell access.\n\nSee `docs/MODES.md` for the full mode and posture contract.\n\n## Compatibility names\n\nThe model-facing contract is the lowercase core above. Saved v0.9.x\ntranscripts and protocol clients may still call exact hidden compatibility\nnames such as `File`, `Bash`, and the older single-operation file names. Those\nnames never enter a new model catalog or `tool_search` result.\n\nCompatibility is execution compatibility, not fuzzy aliasing: an exact legacy\ncall must reach the handler for its legacy schema. It must not be rewritten\ninto a small lowercase primitive whose input shape is different. Unknown or\nretired names still fail closed instead of guessing a destination.\n\nSpecialized native families such as `Git`, `Run`, and `Web` are not aliases for\nthe lowercase core. They remain real, policy-filtered deferred tools and are\nloaded through `tool_search` when needed.\n\n## Long-running work\n\n`bash` runs one cancellable foreground command. It does not carry background,\nTTY, wait, interact, or cancel action fields. Stateful process and terminal\ncontrol is specialized functionality that must be discovered explicitly; it\ndoes not enlarge the first-turn shell schema.\n\nUse `tasks` when the work itself needs a durable lifecycle, structured gates,\nartifacts, replayable timelines, or a stable task id. Large tool results should\nremain behind bounded handles or artifacts instead of being copied wholesale\ninto the parent transcript.\n\n## Parallel fan-out\n\nThe sub-agent capacity source of truth is\n`crates/tui/src/config/subagent_limits.rs`:\n\n- default configured concurrency: **64**;\n- maximum configured concurrency: **128**;\n- maximum admitted running-plus-queued work: **1024**.\n\nThese are capacity ceilings, not advice to dispatch every available slot. A\nmanager should use the smallest useful fan-out, preserve a single owner for\nfan-in, and verify worker receipts before reporting combined completion.\n\nRLM child-query batching is a different, cheaper cost class. Its\n`sub_query_batch` helper accepts 1–16 one-shot children inside a live `rlm`\nsession; it is not a substitute for tool-carrying `agent` workers.\n\n## Human inspection: `/tools` (`/tool-studio`)\n\n`/tools` renders a **read-only, bounded human projection** of the tool field of\nthe request that was prepared for one `(turn, step)`. It is not a second\nregistry and not an execution surface.\n\n**The seam.** The snapshot is built in `crates/tui/src/core/engine/turn_loop.rs`\nimmediately after `MessageRequest` is constructed, from `request.tools` — the\nsame value the model client is handed. The engine resolves the surrounding\nper-turn data once in `engine.rs` (`ToolSurfaceContext`: flattened registry\nfacts, the MCP pool's own server attribution, the engine-injected catalog names,\nand the resolved model client's receipt) and passes it as plain data, so the\nper-step seam never re-locks the MCP pool or holds a tool object.\n\n**Turn and step identity.** The tool set can differ between steps of a turn, so\neach snapshot is stamped with turn id and step and each seam emits its own. The\nTUI keeps only the latest (`SessionState.last_tool_request_snapshot`). Before\nthe first seam there is no snapshot and `/tools` says so rather than rebuilding\na registry in the UI.\n\nTwo kinds of fact are kept apart:\n\n- **Wire facts** come from the prepared request: name, description, schema,\n `defer_loading` / `strict` / `allowed_callers` / `cache_control`, byte\n accounting, and the catalog digest.\n- **Surface facts** come from the `ToolSurfaceContext`: provenance\n (`builtin` / `plugin` / `mcp` / `synthetic` / `unknown`), MCP server identity,\n declared capabilities, declared approval requirement, and model visibility.\n\nContract:\n\n- **One digest.** `active_tool_catalog_sha256`\n (`crates/tui/src/core/engine/preview.rs`) is the single definition of the\n active-tool-catalog hash. The request manifest publishes it as\n `ToolSurfaceFacts::active_tool_catalog_sha256` and `/tools` reports the same\n value for the same prepared request; neither surface keeps a hash of its own.\n- **Nothing is guessed.** MCP server identity is shown only when the real pool\n attributed that exact model tool name. `McpPool::mcp_model_tool_name` is the\n single definition shared by the model catalog and the human attribution, and\n an ambiguous name (two servers colliding on one model name) resolves to no\n server. Synthetic provenance comes from\n `default_synthetic_catalog_tool_names`, which is asserted against the engine's\n own `is_synthetic_catalog_tool` predicate. A transmitted tool with no registry\n entry reports `capabilities: unknown`, never \"none\".\n- **Provider availability follows the resolved client.** It comes from\n `Engine::tool_surface_provider_receipt`, never from \"a tool registry exists\".\n With no client the receipt is `unavailable` even when the registry is full.\n- **Unknown shrinks, it does not vanish.** `unavailable_for_this_request` always\n contains `provider_wire_payload`: nothing on this path observes what the\n provider adapter finally transmits. It additionally contains `provider` and\n `model` without a resolved client, and `provenance` / `capabilities` /\n `approval` when no surface context was captured.\n- **Absent stays distinct from empty.** A request with no tools field is not a\n request with an empty tools array; an unresolved field is `unknown` with a\n reason, not a default.\n- **Bounded.** Rendering is capped by tool count (32), name, description, schema\n bytes, allowed-caller count, and a payload measurement bound, each with an\n explicit truncation or omission receipt. Registered tools that this request\n does *not* carry are reported as a bounded name list plus an exact count\n rather than expanding the projection.\n- **Inert.** The snapshot lives beside the transcript, never in\n `session.messages`, so it cannot enter a model request or perturb the\n provider's prefix cache. It never executes a tool, never reads credentials,\n never reorders the catalog, and is never registered as a model-callable tool.\n- **Delivery is never claimed.** The capture happens before connection setup, so\n `delivery_status` stays `unknown`.\n\n## Release verification\n\nDo not infer the public surface from handler function names. Verify the model\ncatalog and alias visibility at the exact candidate SHA:\n\n```bash\npython3 scripts/measure-runtime-contract.py\ncargo test -p codewhale-tui --lib --locked core::engine::tests::default_active_contract_keeps_discovery_and_core_tools_eager -- --exact\ncargo test -p codewhale-tui --lib --locked tools::file_tool::tests::primitive_schemas_are_separate_and_small_contract_shaped -- --exact\ncargo test -p codewhale-tui --lib --locked tools::shell::tests::lowercase_bash_schema_is_small_contract -- --exact\ncargo test --locked -p codewhale-tui --lib core::engine::tests::print_mode_tool_catalog_metrics -- --ignored --exact --nocapture\n```\n\nCheck the test names against the source before trusting a green run: `cargo test`\nexits 0 with \"0 passed; N filtered out\" when a filter matches nothing, so a\nmisspelled filter is indistinguishable from a pass. Each `--exact` command\nabove must report `1 passed` (the ignored metrics test reports `1 passed`\nonly because `--ignored` selects it); `0 passed` means the filter matched\nnothing and the check did not run.\n\nThe provider-free receipt must report the twelve default-active names listed\nabove. A separate repository-wide tool count may include deferred, dynamic,\nfeature-gated, and compatibility-only registrations; it is not the number of\ntools placed in the first-turn model catalog.\n" }, { "path": "docs/RUNTIME_SIMPLIFICATION_DESIGN.md", From 2750a5d124cb545b2badc48e1097c3f9338421d2 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Tue, 13 Oct 2026 06:48:40 -0700 Subject: [PATCH 19/26] fix(release): audit current crate archives and exclude installed dependencies Cargo now reports a separate build_directory. Read archive sizes there, with its older target_directory fallback, so dev-cargo preflight cannot measure stale canonical target/package archives. Exclude extension-host/node_modules explicitly: four ignored dist files otherwise entered a clean TUI package and marked its publication provenance dirty. Verification on macOS, source cd15db8301 plus these two owned paths: - npm test: Node 540 passed; 0 failed; 7 skipped. Vitest 772 passed. Combined 1312 passed; 0 failed; 7 skipped. - npm run check:web: PASS. - Existing crate-size check through dev-cargo: all 2 requested current archives under 10485760 bytes; TUI 9.00 MiB, CLI 0.35 MiB. - cargo package -p codewhale-tui --list --locked --allow-dirty: 956 members; no extension-host/node_modules entries. - git diff --check: PASS. No production Rust changed. Prior no-upload 29-crate verification built every tarball successfully, but its size report inspected stale files and TUI included ignored dependencies. Strict clean-commit no-upload verification and final Windows packaged/CI acceptance remain gates; this commit does not claim them. Refs #6910 --- crates/tui/Cargo.toml | 1 + scripts/release/check-crate-sizes.py | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 4a06ed13a6..e50534f041 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -47,6 +47,7 @@ exclude = [ "assets/mark-*.png", "assets/skills-catalog-matrix.json", "extension-host/test/**", + "extension-host/node_modules/**", "extension-host/build.mjs", "extension-host/compile-host.mjs", "extension-host/package-lock.json", diff --git a/scripts/release/check-crate-sizes.py b/scripts/release/check-crate-sizes.py index 17ff06f4fb..ce274055e6 100644 --- a/scripts/release/check-crate-sizes.py +++ b/scripts/release/check-crate-sizes.py @@ -6,9 +6,9 @@ crates, then codewhale-tui was refused at 11.93 MiB compressed before its dependent codewhale-cli could follow. The packaging step already builds every tarball (`cargo publish --dry-run` stages them under -`target/package/tmp-registry/`, `cargo package` writes `target/package/`); -this checks those files so the refusal happens before the first real upload, -at dry-run time as well as publish time. +Cargo's build-directory `package/tmp-registry/`, `cargo package` writes +`package/` there); this checks those files so the refusal happens before the +first real upload, at dry-run time as well as publish time. Usage: check-crate-sizes.py [ ...] """ @@ -45,7 +45,7 @@ def main() -> int: print(metadata.stderr, file=sys.stderr) return 1 parsed = json.loads(metadata.stdout) - target_directory = Path(parsed["target_directory"]) + target_directory = Path(parsed.get("build_directory") or parsed["target_directory"]) versions = {package["name"]: package["version"] for package in parsed["packages"]} failures: list[str] = [] From 7bfde0d7b9b0feb84c71249476e6d266155817d1 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Tue, 13 Oct 2026 06:55:56 -0700 Subject: [PATCH 20/26] docs(release): credit junction and proxy fixture contributions Document the merged contributor sub-agent state fix with its actual existing-target junction scope. Credit SparkofSpike for #6929 and #6949, and Lstarsky0 for #6971 in the 0.10.2 contributor list. Regenerate the embedded changelog from the same source. Verification on macOS, source410d8310ca plus these two changelog paths: - npm test: Node540 passed;0 failed;7 skipped; Vitest772 passed. Combined1312 passed;0 failed;7 skipped. - npm run check:web: PASS. - sync-changelog.sh --check and git diff --check: PASS. #6949 final PR head hosted CI: Windows18301 passed/27 skipped, macOS19035 passed/33 skipped, Ubuntu19032 passed/34 skipped. Both Windows junction cases and owner_crash_reentry passed. Final candidate CI, dangling-target, native/package and oldest-Windows acceptance are separate remaining gates. No tests were added in this documentation slice. Refs #6949 Refs #6971 --- CHANGELOG.md | 8 ++++++-- crates/tui/CHANGELOG.md | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 039e5a06f0..30f380ae70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -407,6 +407,10 @@ launcher guidance. (Refs #6955, thanks @7jrxt42BxFZo4iAnN4CX). - A Codewhale home reached through a symlink or Windows junction keeps saving artifacts and compacting (#6947). +- Sub-agent state and transcripts also save through a relocated Codewhale + home when its junction target already exists. Containment checks use the + same resolved state root throughout the operation (Refs #6949, thanks + @SparkofSpike). - A workflow gate that blocks its own role is rejected when the plan is admitted instead of deadlocking the run (#6945). - Auto-Review gives its guardian an output budget a reasoning model can think @@ -570,10 +574,10 @@ launcher guidance. ### Contributors - **[@LIghtJUNction](https://github.com/LIghtJUNction)** — plugin shell install and in-TUI plugin OAuth sign-in ([#6950](https://github.com/codewhale-hq/Codewhale/pull/6950)). -- **[@SparkofSpike](https://github.com/SparkofSpike)** — live network-policy updates and goal milestone hand-back ([#6928](https://github.com/codewhale-hq/Codewhale/pull/6928), [#6930](https://github.com/codewhale-hq/Codewhale/pull/6930)), and a relocated Codewhale home ([#6947](https://github.com/codewhale-hq/Codewhale/issues/6947)). +- **[@SparkofSpike](https://github.com/SparkofSpike)** — live network-policy updates and goal milestone hand-back ([#6928](https://github.com/codewhale-hq/Codewhale/pull/6928), [#6930](https://github.com/codewhale-hq/Codewhale/pull/6930)), Windows ampersand classification ([#6929](https://github.com/codewhale-hq/Codewhale/pull/6929)), and a relocated Codewhale home including sub-agent state ([#6947](https://github.com/codewhale-hq/Codewhale/issues/6947), [#6949](https://github.com/codewhale-hq/Codewhale/pull/6949)). - **[@dajiaohuang](https://github.com/dajiaohuang)** — cache-write token accounting ([#6913](https://github.com/codewhale-hq/Codewhale/pull/6913)). - **[@gaord](https://github.com/gaord)** — embedder telemetry surface support ([#6916](https://github.com/codewhale-hq/Codewhale/pull/6916)). -- **[@Lstarsky0](https://github.com/Lstarsky0)** — localized profile replies ([#6919](https://github.com/codewhale-hq/Codewhale/pull/6919)) and workflow gates that block their own role ([#6952](https://github.com/codewhale-hq/Codewhale/pull/6952)). +- **[@Lstarsky0](https://github.com/Lstarsky0)** — localized profile replies ([#6919](https://github.com/codewhale-hq/Codewhale/pull/6919)), workflow gates that block their own role ([#6952](https://github.com/codewhale-hq/Codewhale/pull/6952)), and proxy-independent MCP loopback checks ([#6971](https://github.com/codewhale-hq/Codewhale/pull/6971)). - **[@jayanthvee](https://github.com/jayanthvee)** — Windows npm launcher guidance ([#6906](https://github.com/codewhale-hq/Codewhale/pull/6906)). - **[@asto18089](https://github.com/asto18089)** — callback-window reference fix for longer browser sign-in waits ([#6865](https://github.com/codewhale-hq/Codewhale/issues/6865)). - **[@BX166](https://github.com/BX166)** — reported three provider failure modes measured on AICraft's own traffic: a cold model that reads as a dead connection, a listed model that fails on every call, and a response cut at the output ceiling that reports `stop` ([#6889](https://github.com/codewhale-hq/Codewhale/issues/6889)). diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 3040ba49e2..bdb2ea2c44 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -407,6 +407,10 @@ launcher guidance. (Refs #6955, thanks @7jrxt42BxFZo4iAnN4CX). - A Codewhale home reached through a symlink or Windows junction keeps saving artifacts and compacting (#6947). +- Sub-agent state and transcripts also save through a relocated Codewhale + home when its junction target already exists. Containment checks use the + same resolved state root throughout the operation (Refs #6949, thanks + @SparkofSpike). - A workflow gate that blocks its own role is rejected when the plan is admitted instead of deadlocking the run (#6945). - Auto-Review gives its guardian an output budget a reasoning model can think @@ -570,10 +574,10 @@ launcher guidance. ### Contributors - **[@LIghtJUNction](https://github.com/LIghtJUNction)** — plugin shell install and in-TUI plugin OAuth sign-in ([#6950](https://github.com/codewhale-hq/Codewhale/pull/6950)). -- **[@SparkofSpike](https://github.com/SparkofSpike)** — live network-policy updates and goal milestone hand-back ([#6928](https://github.com/codewhale-hq/Codewhale/pull/6928), [#6930](https://github.com/codewhale-hq/Codewhale/pull/6930)), and a relocated Codewhale home ([#6947](https://github.com/codewhale-hq/Codewhale/issues/6947)). +- **[@SparkofSpike](https://github.com/SparkofSpike)** — live network-policy updates and goal milestone hand-back ([#6928](https://github.com/codewhale-hq/Codewhale/pull/6928), [#6930](https://github.com/codewhale-hq/Codewhale/pull/6930)), Windows ampersand classification ([#6929](https://github.com/codewhale-hq/Codewhale/pull/6929)), and a relocated Codewhale home including sub-agent state ([#6947](https://github.com/codewhale-hq/Codewhale/issues/6947), [#6949](https://github.com/codewhale-hq/Codewhale/pull/6949)). - **[@dajiaohuang](https://github.com/dajiaohuang)** — cache-write token accounting ([#6913](https://github.com/codewhale-hq/Codewhale/pull/6913)). - **[@gaord](https://github.com/gaord)** — embedder telemetry surface support ([#6916](https://github.com/codewhale-hq/Codewhale/pull/6916)). -- **[@Lstarsky0](https://github.com/Lstarsky0)** — localized profile replies ([#6919](https://github.com/codewhale-hq/Codewhale/pull/6919)) and workflow gates that block their own role ([#6952](https://github.com/codewhale-hq/Codewhale/pull/6952)). +- **[@Lstarsky0](https://github.com/Lstarsky0)** — localized profile replies ([#6919](https://github.com/codewhale-hq/Codewhale/pull/6919)), workflow gates that block their own role ([#6952](https://github.com/codewhale-hq/Codewhale/pull/6952)), and proxy-independent MCP loopback checks ([#6971](https://github.com/codewhale-hq/Codewhale/pull/6971)). - **[@jayanthvee](https://github.com/jayanthvee)** — Windows npm launcher guidance ([#6906](https://github.com/codewhale-hq/Codewhale/pull/6906)). - **[@asto18089](https://github.com/asto18089)** — callback-window reference fix for longer browser sign-in waits ([#6865](https://github.com/codewhale-hq/Codewhale/issues/6865)). - **[@BX166](https://github.com/BX166)** — reported three provider failure modes measured on AICraft's own traffic: a cold model that reads as a dead connection, a listed model that fails on every call, and a response cut at the output ceiling that reports `stop` ([#6889](https://github.com/codewhale-hq/Codewhale/issues/6889)). From 6e72400bbb5182473e6971abcd18bd8953c2f4cc Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Tue, 13 Oct 2026 08:04:40 -0700 Subject: [PATCH 21/26] fix(config): keep saved DeepSeek credentials visible when inactive The legacy TUI writer saved the durable DeepSeek secret but only marked root auth_mode. Inactive-provider inventory correctly requires provider-table metadata before reading that secret slot. Reuse the existing atomic TOML setter to write the matching table marker while preserving identity checks, secret-store rollback, plaintext cleanup and the inactive read guard. Previously saved unmarked keys require one re-save. Strengthen the existing isolated DeepSeek save assertion to distinguish its provider-table marker from root metadata; no new test or code comment. Credit @gaord for the precise writer/read-gate diagnosis in release notes. Verification on macOS, frozen7bf+four owned paths: Summary [ 1.026s] 28 tests run: 28 passed, 15299 skipped (outside filter); 0 failed. Legacy save, missing-marker, endpoint isolation, rollback and logout cases passed. npm: 1312 passed; 0 failed; 7 skipped. check:web, fmt and diff checks passed. Fresh production CLI build finished in48.11s with the existing unsuppressed macOS ld unwind-size warning. No Windows, live-provider, final-package or full-CI result claimed here; exact new-head hosted checks remain the gate. Refs #6972 --- CHANGELOG.md | 6 +++++- crates/tui/CHANGELOG.md | 6 +++++- crates/tui/src/config.rs | 5 +++++ crates/tui/src/config/tests.rs | 6 +++++- 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30f380ae70..9457b6c646 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -411,6 +411,10 @@ launcher guidance. home when its junction target already exists. Containment checks use the same resolved state root throughout the operation (Refs #6949, thanks @SparkofSpike). +- Saving a DeepSeek key in the TUI writes the provider's durable-key marker, + so its saved credential remains visible after restart while another + provider is active. Older saves missing that marker need one re-save + (Refs #6972, thanks @gaord). - A workflow gate that blocks its own role is rejected when the plan is admitted instead of deadlocking the run (#6945). - Auto-Review gives its guardian an output budget a reasoning model can think @@ -576,7 +580,7 @@ launcher guidance. - **[@LIghtJUNction](https://github.com/LIghtJUNction)** — plugin shell install and in-TUI plugin OAuth sign-in ([#6950](https://github.com/codewhale-hq/Codewhale/pull/6950)). - **[@SparkofSpike](https://github.com/SparkofSpike)** — live network-policy updates and goal milestone hand-back ([#6928](https://github.com/codewhale-hq/Codewhale/pull/6928), [#6930](https://github.com/codewhale-hq/Codewhale/pull/6930)), Windows ampersand classification ([#6929](https://github.com/codewhale-hq/Codewhale/pull/6929)), and a relocated Codewhale home including sub-agent state ([#6947](https://github.com/codewhale-hq/Codewhale/issues/6947), [#6949](https://github.com/codewhale-hq/Codewhale/pull/6949)). - **[@dajiaohuang](https://github.com/dajiaohuang)** — cache-write token accounting ([#6913](https://github.com/codewhale-hq/Codewhale/pull/6913)). -- **[@gaord](https://github.com/gaord)** — embedder telemetry surface support ([#6916](https://github.com/codewhale-hq/Codewhale/pull/6916)). +- **[@gaord](https://github.com/gaord)** — embedder telemetry surface support ([#6916](https://github.com/codewhale-hq/Codewhale/pull/6916)) and the DeepSeek saved-key marker diagnosis ([#6972](https://github.com/codewhale-hq/Codewhale/issues/6972)). - **[@Lstarsky0](https://github.com/Lstarsky0)** — localized profile replies ([#6919](https://github.com/codewhale-hq/Codewhale/pull/6919)), workflow gates that block their own role ([#6952](https://github.com/codewhale-hq/Codewhale/pull/6952)), and proxy-independent MCP loopback checks ([#6971](https://github.com/codewhale-hq/Codewhale/pull/6971)). - **[@jayanthvee](https://github.com/jayanthvee)** — Windows npm launcher guidance ([#6906](https://github.com/codewhale-hq/Codewhale/pull/6906)). - **[@asto18089](https://github.com/asto18089)** — callback-window reference fix for longer browser sign-in waits ([#6865](https://github.com/codewhale-hq/Codewhale/issues/6865)). diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index bdb2ea2c44..4e66151b79 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -411,6 +411,10 @@ launcher guidance. home when its junction target already exists. Containment checks use the same resolved state root throughout the operation (Refs #6949, thanks @SparkofSpike). +- Saving a DeepSeek key in the TUI writes the provider's durable-key marker, + so its saved credential remains visible after restart while another + provider is active. Older saves missing that marker need one re-save + (Refs #6972, thanks @gaord). - A workflow gate that blocks its own role is rejected when the plan is admitted instead of deadlocking the run (#6945). - Auto-Review gives its guardian an output budget a reasoning model can think @@ -576,7 +580,7 @@ launcher guidance. - **[@LIghtJUNction](https://github.com/LIghtJUNction)** — plugin shell install and in-TUI plugin OAuth sign-in ([#6950](https://github.com/codewhale-hq/Codewhale/pull/6950)). - **[@SparkofSpike](https://github.com/SparkofSpike)** — live network-policy updates and goal milestone hand-back ([#6928](https://github.com/codewhale-hq/Codewhale/pull/6928), [#6930](https://github.com/codewhale-hq/Codewhale/pull/6930)), Windows ampersand classification ([#6929](https://github.com/codewhale-hq/Codewhale/pull/6929)), and a relocated Codewhale home including sub-agent state ([#6947](https://github.com/codewhale-hq/Codewhale/issues/6947), [#6949](https://github.com/codewhale-hq/Codewhale/pull/6949)). - **[@dajiaohuang](https://github.com/dajiaohuang)** — cache-write token accounting ([#6913](https://github.com/codewhale-hq/Codewhale/pull/6913)). -- **[@gaord](https://github.com/gaord)** — embedder telemetry surface support ([#6916](https://github.com/codewhale-hq/Codewhale/pull/6916)). +- **[@gaord](https://github.com/gaord)** — embedder telemetry surface support ([#6916](https://github.com/codewhale-hq/Codewhale/pull/6916)) and the DeepSeek saved-key marker diagnosis ([#6972](https://github.com/codewhale-hq/Codewhale/issues/6972)). - **[@Lstarsky0](https://github.com/Lstarsky0)** — localized profile replies ([#6919](https://github.com/codewhale-hq/Codewhale/pull/6919)), workflow gates that block their own role ([#6952](https://github.com/codewhale-hq/Codewhale/pull/6952)), and proxy-independent MCP loopback checks ([#6971](https://github.com/codewhale-hq/Codewhale/pull/6971)). - **[@jayanthvee](https://github.com/jayanthvee)** — Windows npm launcher guidance ([#6906](https://github.com/codewhale-hq/Codewhale/pull/6906)). - **[@asto18089](https://github.com/asto18089)** — callback-window reference fix for longer browser sign-in waits ([#6865](https://github.com/codewhale-hq/Codewhale/issues/6865)). diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 89749cacd9..0bf5448932 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -11547,6 +11547,11 @@ fn save_root_api_key_metadata_without_plaintext(config_path: &Path, table: &str) ensure_parent_dir(config_path)?; crate::config_persistence::mutate_config_document(config_path, |doc| { crate::config_persistence::set_document_value(doc, &["auth_mode"], "api_key")?; + crate::config_persistence::set_document_value( + doc, + &["providers", table, "auth_mode"], + "api_key", + )?; // Saving a key never pins a model (see // `codewhale_config::credentials::prepare_provider_api_key_metadata`). if !doc.contains_key("reasoning_effort") { diff --git a/crates/tui/src/config/tests.rs b/crates/tui/src/config/tests.rs index ab4d04af4d..b2107db6cd 100644 --- a/crates/tui/src/config/tests.rs +++ b/crates/tui/src/config/tests.rs @@ -4470,7 +4470,11 @@ fn save_deepseek_key_uses_isolated_file_store_without_plaintext_config() -> Resu .lines() .any(|line| line.trim_start().starts_with("api_key =")) ); - assert!(config.contains("auth_mode = \"api_key\"")); + let parsed: toml::Value = toml::from_str(&config)?; + assert_eq!( + parsed["providers"]["deepseek"]["auth_mode"].as_str(), + Some("api_key") + ); // Saving a key leaves the model to the provider default. assert!(!config.contains("default_text_model"), "{config}"); assert_eq!( From 3c11e8a3028e8cdc775f912e994954e99c585fdb Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Tue, 13 Oct 2026 09:49:12 -0700 Subject: [PATCH 22/26] test(runtime): reuse the durable user-input settlement watchdog The existing completed-tool observer required both an empty pending snapshot and its same-turn/input nonterminal cancellation receipt within two seconds. Windows CI38418948246 ran18301 tests:18300 passed(2leaky),1 failed,27skipped; this observer expired. owner_crash_reentry passed separately. The log does not identify which snapshot/receipt/I/O stage was delayed, so this is not a claim of a proven Windows production defect or benign flake. Reuse the existing ten-second monitor-settlement watchdog only for this observer. Durable event-lock acquisition alone allows five seconds; the monitor may still be finishing the preceding user-input required receipt. Keep both timeout/cancel cases, every state/identity/receipt/late-answer assertion, the first registration wait and production budgets unchanged. No new test or code comment. Two independent source reviews support this bounded test-policy correction; fresh Windows CI remains mandatory. Actual frozen-source macOS focused result: Summary [ 1.097s] 9 tests run: 9 passed, 15318 skipped 0 failed; all nine literal PASS rows audited. npm test:1312 passed;0 failed;7 skipped. npm run check:web,cargo fmt --all -- --check,git diff --check:PASS. Existing unsuppressed macOS linker __eh_frame warning retained. No changed-head Windows pass, final-package/native-app/1607/provider or release qualification is inferred. Raw failed Windows log and scoped local gate are preserved in the release takeover artifact packet. Refs #6094 --- crates/tui/src/runtime_threads/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tui/src/runtime_threads/tests.rs b/crates/tui/src/runtime_threads/tests.rs index 6b2cd62b4e..a3665c33e4 100644 --- a/crates/tui/src/runtime_threads/tests.rs +++ b/crates/tui/src/runtime_threads/tests.rs @@ -13047,7 +13047,7 @@ async fn user_input_tool_timeout_and_cancel_clear_snapshot_before_turn_end() -> result: Err(error), }) .await?; - tokio::time::timeout(Duration::from_secs(2), async { + tokio::time::timeout(TURN_SETTLEMENT_DEADLOCK_TIMEOUT, async { loop { let detail = manager.get_thread_detail(&thread.id).await?; let events = manager.events_since(&thread.id, None)?; From d83e920e4c7e6df69ec87e70863b7c51022dbfc5 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Tue, 13 Oct 2026 13:27:18 -0700 Subject: [PATCH 23/26] fix(models): address release candidate route and pricing review findings Refresh rebound request facts from the exact frozen configured-model snapshot and keep xAI effort metadata scoped to its endpoint. Preserve per-request review and child usage (including audio) through the existing accumulator and metadata; malformed or oversized breakdowns remain visibly unpriced. Reject unsupported configured tiers and nested pricing units at load without restricting upstream Models.dev metadata. Resolve CLI availability against exact live/configured and selected-account facts before the legacy registry fallback; unknown, foreign and omitted models still fail closed. Move the existing registry dependency to its shared resolver owner. Refs #6396 Local macOS evidence at the frozen 13-path batch: Summary [ 0.732s] 98 tests run: 98 passed, 15213 skipped Summary [ 0.036s] 9 tests run: 9 passed, 0 skipped The first production build exposed E0451 on Config's private production receipt. Replace the helper struct update with Default plus public Option::replace; retain that failed log. Only the four affected existing TUI checks were repeated: Summary [ 0.082s] 4 tests run: 4 passed, 15307 skipped Fresh production CLI build finished in 12m14s, then: Summary [ 8.919s] 15 tests run: 15 passed, 0 skipped Manual matrix: 24 passed; 0 failed; 24 executed (isolated fake catalogs). All 48 raw output hashes and source/binary continuity independently audited. npm: 1312 passed; 0 failed; 7 skipped. check:web, fmt and diff passed. Workspace/all-target/all-feature Clippy with CI flags finished in 9m18s, with no diagnostics. Locked 29-crate publication order checked; new clean archive VCS provenance remains a final-source gate. Existing tests were strengthened; no test functions or code comments added. Earlier E0422/zero-test failure and unsuppressed macOS __eh_frame linker warning remain recorded. Source/static reviews plus unchanged passing evidence were reused at their original boundaries. Windows: this repair has not yet run in hosted Windows CI or final packages/VM. The earlier 3c11 full CI belongs to the pre-repair source. No live issuer grant or provider inference acceptance; network traffic was not measured. No release, tag, upload or merge is performed by this commit. --- Cargo.lock | 2 +- crates/cli/Cargo.toml | 1 - crates/cli/src/lib.rs | 32 ++-- crates/cli/tests/model_resolve_provenance.rs | 28 +++- crates/config/src/catalog/configured.rs | 56 ++++--- crates/config/tests/configured_models.rs | 41 +++++ crates/tui/Cargo.toml | 1 + crates/tui/src/client.rs | 66 ++++++++- crates/tui/src/cost_status.rs | 148 ++++++++++++++++++- crates/tui/src/reasoning_preference.rs | 1 - crates/tui/src/route_preferences.rs | 38 ++++- crates/tui/src/tools/review.rs | 49 ++---- crates/tui/src/tui/app/tests.rs | 16 +- 13 files changed, 375 insertions(+), 104 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0917ccd7d2..2edee8a2ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -885,7 +885,6 @@ dependencies = [ "chrono", "clap", "clap_complete", - "codewhale-agent", "codewhale-app-server", "codewhale-build-support", "codewhale-config", @@ -1294,6 +1293,7 @@ dependencies = [ "base64 0.23.1", "chrono", "clap", + "codewhale-agent", "codewhale-app-server", "codewhale-build-support", "codewhale-cloud-facts", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 9a00d0b4c6..71b4749f4f 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -28,7 +28,6 @@ anyhow.workspace = true clap.workspace = true clap_complete.workspace = true codewhale-tui = { path = "../tui", version = "0.10.2", default-features = false } -codewhale-agent = { path = "../agent", version = "0.10.2" } codewhale-app-server = { path = "../app-server", version = "0.10.2" } codewhale-config = { path = "../config", version = "0.10.2" } codewhale-lane = { path = "../lane", version = "0.10.2" } diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index cb0ea485bc..5619b82a3e 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -15,7 +15,6 @@ use std::process::Command; use anyhow::{Context, Result, anyhow, bail}; use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum}; use clap_complete::{Shell, generate}; -use codewhale_agent::ModelRegistry; use codewhale_app_server::RuntimeControlFrontend; use codewhale_config::credentials::{ clear_provider_api_key_from_config, provider_slot, set_provider_api_key, @@ -5900,7 +5899,6 @@ fn run_model_command( // the configured runtime provider remains authoritative. Model // text never authorizes switching providers or credential slots. let provider = subcommand_provider.unwrap_or(resolved_runtime.provider); - let registered = ModelRegistry::default().resolve(queried, Some(provider))?; let identity = if provider == ProviderKind::Custom && subcommand_provider.is_none() { store .config @@ -5925,34 +5923,22 @@ fn run_model_command( identity, &base, queried, - ); + )?; println!("requested: {}", queried.unwrap_or_default()); - println!( - "resolved: {}", - candidate - .as_ref() - .map_or(registered.resolved.id.as_str(), |route| { - route.wire_model_id().as_str() - }) - ); + println!("resolved: {}", candidate.wire_model_id().as_str()); println!("provider: {identity}"); println!( "used_fallback: {}", queried.is_none() || queried == Some("auto") ); - match candidate.as_ref() { - Ok(route) => { - if let Some(canonical) = route.canonical_model() { - println!("canonical_model: {}", canonical.as_str()); - } - println!("limits: {}", serde_json::to_string(&route.limits())?); - println!( - "capabilities: {}", - serde_json::to_string(&route.capabilities())? - ); - } - Err(error) => println!("route_error: {error}"), + if let Some(canonical) = candidate.canonical_model() { + println!("canonical_model: {}", canonical.as_str()); } + println!("limits: {}", serde_json::to_string(&candidate.limits())?); + println!( + "capabilities: {}", + serde_json::to_string(&candidate.capabilities())? + ); println!( "provider_source: {}", if subcommand_provider.is_some() { diff --git a/crates/cli/tests/model_resolve_provenance.rs b/crates/cli/tests/model_resolve_provenance.rs index 11035e6230..1b0a90fdf1 100644 --- a/crates/cli/tests/model_resolve_provenance.rs +++ b/crates/cli/tests/model_resolve_provenance.rs @@ -113,6 +113,22 @@ fn resolve_reports_a_provider_scoped_model_as_explicitly_configured() { Some("config [providers.*].model"), "{report:?}" ); + let base = codewhale_config::ProviderKind::Zai + .provider() + .default_base_url(); + let configured = format!( + "provider = 'zai'\n[providers.zai]\napi_key = 'fixture-only'\nmodel = 'GLM-5.2'\n[[custom_models]]\nprovider = 'zai'\nbase_url = '{base}'\nid = 'operator-only-fixture'\nlimit = {{ context = 96000, output = 8000 }}\n" + ); + let report = resolve_with_config(&configured, &["operator-only-fixture"]); + assert_eq!( + report.get("resolved").map(String::as_str), + Some("operator-only-fixture") + ); + assert_eq!(report.get("provider").map(String::as_str), Some("zai")); + assert_eq!( + report.get("used_fallback").map(String::as_str), + Some("false") + ); } #[test] @@ -323,12 +339,14 @@ fn top_level_provider_and_model_flags_report_the_runtime_route() { /// `kimi-k3`). #[test] fn moonshot_k3_products_resolve_without_crossing_providers() { - for model in ["kimi-k3", "k3"] { - let report = resolve_with_global_flags( - "provider = \"moonshot\"\n\n[providers.moonshot]\napi_key = \"k\"\n", - &[], - &[model, "--provider", "moonshot"], + for (model, base_url) in [ + ("kimi-k3", "https://api.moonshot.ai/v1"), + ("k3", "https://api.kimi.com/coding/v1"), + ] { + let config = format!( + "provider = \"moonshot\"\n\n[providers.moonshot]\napi_key = \"k\"\nbase_url = \"{base_url}\"\n" ); + let report = resolve_with_global_flags(&config, &[], &[model, "--provider", "moonshot"]); assert_eq!( report.get("provider").map(String::as_str), diff --git a/crates/config/src/catalog/configured.rs b/crates/config/src/catalog/configured.rs index 44e0de1a85..ea8a1a15bc 100644 --- a/crates/config/src/catalog/configured.rs +++ b/crates/config/src/catalog/configured.rs @@ -116,11 +116,12 @@ pub fn validate_configured_models(models: &[ConfiguredModel]) -> anyhow::Result< { return Err(invalid("limit")); } - if model - .cost - .as_ref() - .is_some_and(|cost| !crate::pricing::catalog_cost_is_valid(cost)) - { + if model.cost.as_ref().is_some_and(|cost| { + !crate::pricing::catalog_cost_is_valid(cost) + || cost.context_tiers().iter().any(|tier| { + tier.tier.kind != "context" || !tier.cost.context_tiers().is_empty() + }) + }) { return Err(invalid("cost")); } if model.extras.keys().any(|key| { @@ -188,20 +189,37 @@ fn deserialize_limit<'de, D: Deserializer<'de>>(d: D) -> Result>(d: D) -> Result, D::Error> { - deserialize_known( - d, - &[ - "input", - "output", - "cache_read", - "cache_write", - "reasoning", - "input_audio", - "output_audio", - "tiers", - "context_over_200k", - ], - ) + fn supported_fields(value: &toml::Value, is_tier: bool) -> bool { + let Some(table) = value.as_table() else { + return false; + }; + table.iter().all(|(key, value)| match key.as_str() { + "input" | "output" | "cache_read" | "cache_write" | "reasoning" | "input_audio" + | "output_audio" => true, + "tiers" => value + .as_array() + .is_some_and(|tiers| tiers.iter().all(|tier| supported_fields(tier, true))), + "context_over_200k" => supported_fields(value, false), + "tier" if is_tier => value.as_table().is_some_and(|tier| { + tier.keys() + .all(|key| matches!(key.as_str(), "type" | "size")) + }), + _ => false, + }) + } + + Option::::deserialize(d)? + .map(|value| { + if !supported_fields(&value, false) { + return Err(serde::de::Error::custom( + "unsupported nested custom model metadata field", + )); + } + value + .try_into() + .map_err(|_| serde::de::Error::custom("invalid custom model metadata")) + }) + .transpose() } fn deserialize_modalities<'de, D: Deserializer<'de>>( d: D, diff --git a/crates/config/tests/configured_models.rs b/crates/config/tests/configured_models.rs index 336b4db675..b07cd57065 100644 --- a/crates/config/tests/configured_models.rs +++ b/crates/config/tests/configured_models.rs @@ -192,6 +192,38 @@ fn invalid_limits_prices_units_and_authority_fail_closed() { ("input = 0.4", "input = inf"), ("input = 0.4", "input = 0.4, currency = 'CNY'"), ("input = 0.4", "input = 0.4, unit = 'per_token'"), + ( + "input = 0.4", + "input = 0.4, tiers = [{ input = 0.8, tier = { type = 'time', size = 1000 } }]", + ), + ( + "input = 0.4", + "input = 0.4, tiers = [{ input = 0.8, tiers = [{ input = 1.2, tier = { type = 'context', size = 2000 } }], tier = { type = 'context', size = 1000 } }]", + ), + ( + "input = 0.4", + "input = 0.4, context_over_200k = { input = 0.8, tiers = [{ input = 1.2, tier = { type = 'context', size = 300000 } }] }", + ), + ( + "input = 0.4", + "input = 0.4, tiers = [{ input = 0.8, currency = 'CNY', tier = { type = 'context', size = 1000 } }]", + ), + ( + "input = 0.4", + "input = 0.4, tiers = [{ input = 0.8, unit = 'per_token', tier = { type = 'context', size = 1000 } }]", + ), + ( + "input = 0.4", + "input = 0.4, tiers = [{ input = 0.8, tier = { type = 'context', size = 1000, unit = 'request' } }]", + ), + ( + "input = 0.4", + "input = 0.4, context_over_200k = { input = 0.8, currency = 'CNY' }", + ), + ( + "input = 0.4", + "input = 0.4, context_over_200k = { input = 0.8, unit = 'per_token' }", + ), ("future_note =", "source ="), ("future_note =", "canonical_model ="), ("future_note =", "api_key ="), @@ -204,6 +236,15 @@ fn invalid_limits_prices_units_and_authority_fail_closed() { "{before} -> {after}" ); } + for schedule in [ + "tiers = [{ input = 0.8, output = 3.2, tier = { type = 'context', size = 1000 } }]", + "tiers = [{ input = 0.8, output = 3.2, tier = { size = 1000 } }]", + "context_over_200k = { input = 0.8, output = 3.2 }", + ] { + let valid = FIXTURE.replace("input = 0.4", &format!("input = 0.4, {schedule}")); + let parsed = toml::from_str::(&valid).expect("supported configured schedule"); + validate_configured_models(parsed.custom_models.as_deref().unwrap()).unwrap(); + } let mut duplicate = models(); let mut other = duplicate[0].clone(); other.base_url = "https://MODELS.example.test:443/v1/".into(); diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index e50534f041..e65b54754e 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -89,6 +89,7 @@ codewhale-sanitize = { path = "../sanitize", version = "0.10.2" } codewhale-memory = { path = "../memory", version = "0.10.2" } ahash = "0.8" anyhow.workspace = true +codewhale-agent = { path = "../agent", version = "0.10.2" } codewhale-app-server = { path = "../app-server", version = "0.10.2" } codewhale-config = { path = "../config", version = "0.10.2" } codewhale-cloud-facts = { path = "../cloud-facts", version = "0.10.2" } diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 74632ae462..62f682af58 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -2226,6 +2226,24 @@ impl CodewhaleClient { }) } + fn catalog_facts_for_model( + &self, + model: &str, + ) -> Option> { + let mut config = Config::default(); + let _ = config + .custom_models + .replace(self.configured_models.as_ref().clone()); + crate::provider_lake::configured_catalog_offering_for_route( + &config, + self.api_provider, + self.admitted_identity.key.as_str(), + &self.base_url, + model, + ) + .map(Arc::new) + } + /// Resolve `model` through the central route resolver and rebuild this /// client whenever its exact wire identity, limits, or protocol differs /// from the route bound at construction (#5042). `Ok(None)` means the @@ -2259,6 +2277,7 @@ impl CodewhaleClient { let mut rebound = self.clone(); rebound.default_model = candidate.wire_model_id().as_str().to_string(); rebound.route_limits = candidate_limits; + rebound.route_facts = rebound.catalog_facts_for_model(&rebound.default_model); return Ok(Some(rebound)); } let config = config.ok_or_else(|| { @@ -2272,6 +2291,7 @@ impl CodewhaleClient { })?; let mut rebound = Self::from_candidate(config, &candidate)?; rebound.configured_models = Arc::clone(&self.configured_models); + rebound.route_facts = rebound.catalog_facts_for_model(&rebound.default_model); Ok(Some(rebound)) } @@ -2927,14 +2947,9 @@ impl CodewhaleClient { let facts = if request.model == self.default_model { self.route_facts.clone() } else { - crate::provider_lake::catalog_offering_for_route( - self.api_provider, - self.admitted_identity.key.as_str(), - &self.base_url, - &request.model, - ) - .map(Arc::new) - }; + self.catalog_facts_for_model(&request.model) + } + .filter(|facts| facts.wire_model_id == request.model); if let Some(facts) = facts.as_deref() { if facts.temperature == Some(false) { request.temperature = None; @@ -6360,6 +6375,11 @@ mod configured_model_client_tests { fn alternate_declared_model_freezes_wire_limits_and_price() { let _env = crate::test_support::lock_test_env(); let mut config = config(); + let mut initial = config.custom_models.as_ref().unwrap()[0].clone(); + initial.id = "initial-model".into(); + initial.tool_call = Some(true); + config.custom_models.as_mut().unwrap()[0].tool_call = Some(false); + config.custom_models.as_mut().unwrap().push(initial); let client = CodewhaleClient::from_parts( "https://api.deepseek.com".into(), "initial-model".into(), @@ -6386,6 +6406,7 @@ mod configured_model_client_tests { .as_mut() .unwrap() .output = Some(99.0); + config.custom_models.as_mut().unwrap()[0].tool_call = Some(true); assert_eq!(client.effective_max_output_tokens(model), 32); let prepared = client @@ -6397,12 +6418,41 @@ mod configured_model_client_tests { assert_eq!(prepared.wire_model, model); assert_eq!(prepared.body["model"], model); assert_eq!(prepared.body["max_tokens"], 32); + let mut tool_request = translation_message_request("hello", model.into(), "English", 4096); + tool_request.tools = Some(vec![codewhale_models::Tool { + tool_type: None, + name: "fixture".into(), + description: "read-only fixture".into(), + input_schema: json!({ "type": "object", "properties": {} }), + allowed_callers: None, + defer_loading: Some(false), + input_examples: None, + strict: Some(true), + cache_control: None, + }]); + assert!( + client + .prepare_outbound_request(tool_request.clone(), false) + .is_err() + ); + let mut initial_request = tool_request.clone(); + initial_request.model = "initial-model".into(); + assert!( + client + .prepare_outbound_request(initial_request, false) + .is_ok() + ); let rebound = client .rebound_for_model_protocol(Some(&config), model) .unwrap() .unwrap(); assert_eq!(rebound.default_model, model); assert_eq!(rebound.route_limits.unwrap().output_tokens, Some(32)); + assert!( + rebound + .prepare_outbound_request(tool_request, false) + .is_err() + ); let envelope = rebound.effective_route_envelope(model, chrono::Utc::now()); assert_eq!(envelope.model, model); let quote = serde_json::to_value(envelope.provider_live_pricing.unwrap()).unwrap(); diff --git a/crates/tui/src/cost_status.rs b/crates/tui/src/cost_status.rs index 0d3bbf3701..8b86644095 100644 --- a/crates/tui/src/cost_status.rs +++ b/crates/tui/src/cost_status.rs @@ -588,6 +588,25 @@ pub fn child_usage_metadata_fields( "child_server_tool_use".into(), serde_json::json!(usage.server_tool_use), ); + fields.insert( + "child_input_audio_tokens".into(), + serde_json::json!(usage.input_audio_tokens), + ); + fields.insert( + "child_output_audio_tokens".into(), + serde_json::json!(usage.output_audio_tokens), + ); + let requests = if usage.request_usages.len() > 1024 + || usage + .request_usages + .iter() + .any(|request| !request.request_usages.is_empty()) + { + vec![Usage::default(); 1025] + } else { + usage.request_usages.clone() + }; + fields.insert("child_request_usages".into(), serde_json::json!(requests)); fields } @@ -885,6 +904,9 @@ pub fn child_usage_from_metadata(metadata: &serde_json::Value) -> Option "child_prompt_cache_write_tokens", "child_reasoning_tokens", "child_reasoning_replay_tokens", + "child_input_audio_tokens", + "child_output_audio_tokens", + "child_request_usages", ]; if !TOKEN_FIELDS .iter() @@ -900,6 +922,55 @@ pub fn child_usage_from_metadata(metadata: &serde_json::Value) -> Option .map(|value| u32::try_from(value).unwrap_or(u32::MAX)) } + let invalid_requests = || vec![Usage::default(); 1025]; + let token_value = |value: &serde_json::Value| { + value.is_null() + || value + .as_u64() + .is_some_and(|value| u32::try_from(value).is_ok()) + }; + let flat_request = |request: &serde_json::Value| { + request.as_object().is_some_and(|fields| { + ["input_tokens", "output_tokens"].iter().all(|field| { + fields + .get(*field) + .is_some_and(|value| value.as_u64().is_some()) + }) && fields.iter().all(|(key, value)| match key.as_str() { + "input_tokens" + | "output_tokens" + | "prompt_cache_hit_tokens" + | "prompt_cache_miss_tokens" + | "prompt_cache_write_tokens" + | "reasoning_tokens" + | "reasoning_replay_tokens" + | "input_audio_tokens" + | "output_audio_tokens" => token_value(value), + "request_usages" => value.as_array().is_some_and(|nested| nested.is_empty()), + "server_tool_use" => { + value.is_null() + || value.as_object().is_some_and(|tools| { + tools.iter().all(|(key, value)| { + matches!( + key.as_str(), + "code_execution_requests" | "tool_search_requests" + ) && token_value(value) + }) + }) + } + _ => false, + }) + }) + }; + let request_usages = match metadata.get("child_request_usages") { + None => Vec::new(), + Some(serde_json::Value::Array(requests)) + if requests.len() <= 1024 && requests.iter().all(flat_request) => + { + serde_json::from_value(serde_json::Value::Array(requests.clone())) + .unwrap_or_else(|_| invalid_requests()) + } + Some(_) => invalid_requests(), + }; Some(Usage { input_tokens: u32_field(metadata, "child_input_tokens").unwrap_or(0), output_tokens: u32_field(metadata, "child_output_tokens").unwrap_or(0), @@ -913,10 +984,9 @@ pub fn child_usage_from_metadata(metadata: &serde_json::Value) -> Option .cloned() .and_then(|value| serde_json::from_value(value).ok()), - input_audio_tokens: None, - output_audio_tokens: None, - - request_usages: Vec::new(), + input_audio_tokens: u32_field(metadata, "child_input_audio_tokens"), + output_audio_tokens: u32_field(metadata, "child_output_audio_tokens"), + request_usages, }) } @@ -4335,18 +4405,84 @@ mod tests { #[test] fn child_metadata_round_trip_preserves_zero_and_reasoning_usage() { let route = deepseek_envelope(); - let usage = Usage { + let mut usage = Usage { input_tokens: 0, output_tokens: 9, reasoning_tokens: Some(7), reasoning_replay_tokens: Some(3), + input_audio_tokens: Some(0), + output_audio_tokens: Some(1), ..Usage::default() }; + usage.request_usages.push(usage.clone()); let mut metadata = serde_json::json!({"tool": "rlm_eval"}); attach_child_usage_metadata(&mut metadata, &route, &usage); assert_eq!(child_route_envelope_from_metadata(&metadata), Some(route)); - assert_eq!(child_usage_from_metadata(&metadata), Some(usage)); + assert_eq!(child_usage_from_metadata(&metadata), Some(usage.clone())); + metadata + .as_object_mut() + .unwrap() + .remove("child_request_usages"); + let mut legacy = usage.clone(); + legacy.request_usages.clear(); + assert_eq!(child_usage_from_metadata(&metadata), Some(legacy)); + for malformed in [ + serde_json::json!(null), + serde_json::json!({}), + serde_json::json!([{}]), + serde_json::json!([{ "input_tokens": -1, "output_tokens": 9 }]), + serde_json::json!([{ "input_tokens": 0, "output_tokens": 9, "request_usages": [{}] }]), + serde_json::json!(vec![Usage::default(); 1025]), + ] { + metadata["child_request_usages"] = malformed; + let decoded = child_usage_from_metadata(&metadata).unwrap(); + assert!(matches!( + codewhale_config::pricing::OfferingPricing::default() + .quote_cost(&crate::pricing::token_usage_for_pricing(&decoded)), + Err(codewhale_config::pricing::PricingQuoteError::InvalidUsage) + )); + } + let pricing = codewhale_config::pricing::OfferingPricing { + input_per_million: Some(1.0), + output_per_million: Some(0.0), + context_tiers: vec![ + serde_json::from_value(serde_json::json!({ + "tier": { "type": "context", "size": 200_000 }, + "input": 2.0, + "output": 0.0, + })) + .unwrap(), + ], + ..Default::default() + }; + let mut aggregate = Usage::default(); + for _ in 0..3 { + crate::core::turn::add_usage_to( + &mut aggregate, + &Usage { + input_tokens: 100_000, + ..Default::default() + }, + ); + } + let fields = child_usage_metadata_fields(&deepseek_envelope(), &aggregate); + let mut decoded = child_usage_from_metadata(&serde_json::Value::Object(fields)).unwrap(); + assert_eq!( + pricing + .quote_cost(&crate::pricing::token_usage_for_pricing(&decoded)) + .unwrap() + .amount, + "0.3" + ); + decoded.request_usages.clear(); + assert_eq!( + pricing + .quote_cost(&crate::pricing::token_usage_for_pricing(&decoded)) + .unwrap() + .amount, + "0.6" + ); let mut zero_metadata = serde_json::json!({}); let zero = Usage::default(); diff --git a/crates/tui/src/reasoning_preference.rs b/crates/tui/src/reasoning_preference.rs index 16581eefd3..8fc0e9f9b9 100644 --- a/crates/tui/src/reasoning_preference.rs +++ b/crates/tui/src/reasoning_preference.rs @@ -315,7 +315,6 @@ impl ReasoningEffort { base_url, wire_model, ) - .or_else(|| crate::provider_lake::catalog_offering_for_model(provider, wire_model)) } pub fn catalog_default( diff --git a/crates/tui/src/route_preferences.rs b/crates/tui/src/route_preferences.rs index fe022ef7cf..ee72d19266 100644 --- a/crates/tui/src/route_preferences.rs +++ b/crates/tui/src/route_preferences.rs @@ -336,8 +336,42 @@ pub fn resolve_model( config.set_provider_base_url_override(&identity, Some(base_url.into()))?; crate::provider_catalog_live::maybe_load_persisted_cache_for_config(&config); let route = crate::route_runtime::resolve_runtime_route_for_identity(&config, &identity, model) - .map_err(anyhow::Error::msg)?; - Ok(route.candidate) + .map_err(anyhow::Error::msg); + if !route.as_ref().is_ok_and(|route| { + (route.identity.provider == ProviderKind::OpenaiCodex + && crate::codex_model_cache::model_roster_for(&route.config) + .metadata_for(route.candidate.wire_model_id().as_str()) + .is_some()) + || crate::provider_lake::configured_catalog_offering_for_route( + &route.config, + route.identity.provider, + route.identity.key.as_str(), + &route.candidate.endpoint().base_url, + route.candidate.wire_model_id().as_str(), + ) + .is_some() + }) { + if crate::provider_catalog_live::cached_entry_for_route( + identity.provider, + identity.key.as_str(), + base_url, + )? + .is_some_and(|entry| entry.fetched_at > 0) + { + return Err( + codewhale_agent::ModelResolutionError::ModelNotAvailableForProvider { + provider: identity.provider, + requested: model.unwrap_or("auto").to_string(), + } + .into(), + ); + } + codewhale_agent::ModelRegistry::default().resolve( + model.filter(|model| *model != "auto"), + Some(identity.provider), + )?; + } + Ok(route?.candidate) } /// Save one explicit route preference after atomically adopting legacy choices. diff --git a/crates/tui/src/tools/review.rs b/crates/tui/src/tools/review.rs index dcba5a4ba2..951425bd9d 100644 --- a/crates/tui/src/tools/review.rs +++ b/crates/tui/src/tools/review.rs @@ -1633,12 +1633,6 @@ fn review_usage_metadata( metadata } -fn add_optional_usage(total: &mut Option, next: Option) { - if let Some(next) = next { - *total = Some(total.unwrap_or(0).saturating_add(next)); - } -} - /// Describe a failed review request with its whole error chain. /// /// The client wraps the retry loop's `LlmError` in one outer context (the @@ -1657,36 +1651,7 @@ pub(crate) fn request_failure_message( } pub(crate) fn add_review_usage(total: &mut Usage, next: &Usage) { - total.input_tokens = total.input_tokens.saturating_add(next.input_tokens); - total.output_tokens = total.output_tokens.saturating_add(next.output_tokens); - add_optional_usage( - &mut total.prompt_cache_hit_tokens, - next.prompt_cache_hit_tokens, - ); - add_optional_usage( - &mut total.prompt_cache_miss_tokens, - next.prompt_cache_miss_tokens, - ); - add_optional_usage( - &mut total.prompt_cache_write_tokens, - next.prompt_cache_write_tokens, - ); - add_optional_usage(&mut total.reasoning_tokens, next.reasoning_tokens); - add_optional_usage( - &mut total.reasoning_replay_tokens, - next.reasoning_replay_tokens, - ); - if let Some(next_tools) = &next.server_tool_use { - let tools = total.server_tool_use.get_or_insert_with(Default::default); - add_optional_usage( - &mut tools.code_execution_requests, - next_tools.code_execution_requests, - ); - add_optional_usage( - &mut tools.tool_search_requests, - next_tools.tool_search_requests, - ); - } + crate::core::turn::add_usage_to(total, next); } enum ReviewSource { @@ -2681,6 +2646,8 @@ mod tests { output_tokens: 3, prompt_cache_hit_tokens: Some(2), reasoning_tokens: Some(4), + input_audio_tokens: Some(3), + output_audio_tokens: Some(1), ..Default::default() }; first.server_tool_use = Some(codewhale_models::ServerToolUsage { @@ -2692,6 +2659,8 @@ mod tests { output_tokens: 5, prompt_cache_hit_tokens: Some(7), reasoning_tokens: Some(6), + input_audio_tokens: Some(4), + output_audio_tokens: Some(1), server_tool_use: Some(codewhale_models::ServerToolUsage { code_execution_requests: Some(2), tool_search_requests: Some(3), @@ -2704,6 +2673,9 @@ mod tests { assert_eq!(total.output_tokens, 8); assert_eq!(total.prompt_cache_hit_tokens, Some(9)); assert_eq!(total.reasoning_tokens, Some(10)); + assert_eq!(total.input_audio_tokens, Some(7)); + assert_eq!(total.output_audio_tokens, Some(2)); + assert_eq!(total.request_usages, vec![first, second]); assert_eq!( total.server_tool_use.unwrap().code_execution_requests, Some(3) @@ -2771,6 +2743,11 @@ mod tests { let metadata = result.metadata.unwrap(); assert_eq!(metadata["input_tokens"], 24); assert_eq!(metadata["output_tokens"], 4); + assert_eq!( + crate::cost_status::child_usage_from_metadata(&metadata), + Some(usage.clone()) + ); + assert_eq!(usage.request_usages.len(), 2); assert!(metadata.get("review_coverage").is_none()); let mut drift = PrReviewAccumulator::new(&plan); diff --git a/crates/tui/src/tui/app/tests.rs b/crates/tui/src/tui/app/tests.rs index 70790ee17e..9d89670bc9 100644 --- a/crates/tui/src/tui/app/tests.rs +++ b/crates/tui/src/tui/app/tests.rs @@ -1504,8 +1504,20 @@ fn reasoning_effort_preserves_grok_46_ladder_only_on_exact_xai_route() { "https://gateway.example/v1", model, ), - ReasoningEffort::Medium, - "catalog effort lists are model metadata; the Chat wire still omits them on a custom endpoint" + ReasoningEffort::High, + "a custom endpoint must not inherit the official catalog effort ladder" + ); + let custom = "https://gateway.example/v1"; + assert!(ReasoningEffort::offering_for_reasoning(ProviderKind::Xai, custom, model).is_none()); + assert!(ReasoningEffort::catalog_effort_values(ProviderKind::Xai, custom, model).is_none()); + assert_eq!( + crate::tui::model_picker::picker_efforts_for_route(ProviderKind::Xai, custom, model, false), + vec![ + ReasoningEffort::Auto, + ReasoningEffort::Off, + ReasoningEffort::High, + ReasoningEffort::Max, + ] ); } From 98d600388054b044b9e8309d0b1bb9c9df8f68ad Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Tue, 13 Oct 2026 14:33:32 -0700 Subject: [PATCH 24/26] test(tui): scope Ctrl-T catalog fixture as Models.dev The existing Ctrl-T fixture put Models.dev metadata into the ModelsDev snapshot but tagged it as endpoint-owned Live with a fabricated fingerprint. The exact route guard rejects that row. Removing the provider-wide reasoning fallback exposed the malformed fixture in d83 Ubuntu CI: 19,031 passed; 1 failed; 34 skipped. The complete failed log and ordinal audit remain retained. Use ModelsDevLive provenance, matching the existing catalog importer. Both original expected ladders and every two-lap/effective-tier assertion stay unchanged. No production code, test function or code comment was added or weakened. Verified on macOS at d83 plus this frozen existing-test-only edit: Summary [ 0.204s] 5 tests run: 5 passed, 15306 skipped Finished test profile in 2m 02s; existing __eh_frame linker warning retained. fmt and diff checks passed. Unchanged JS and generated-doc inputs retain the earlier npm 1,312 passed; 0 failed; 7 skipped and web checks, with their original source boundary. Production inputs and d83 hosted Clippy9m25s proof are unchanged; current-head hosted CI/Lint still required. This new commit has no Windows, final-package or live-provider acceptance claim. Local proof: artifacts/release-0102-takeover-20261013/release-gates/d83-ctrl-t-fixture-repair/LOCAL_ACCEPTANCE.json in the shared workspace. Refs #6396, #6650 --- crates/tui/src/tui/app/tests.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/tui/src/tui/app/tests.rs b/crates/tui/src/tui/app/tests.rs index 9d89670bc9..efe1cf2b62 100644 --- a/crates/tui/src/tui/app/tests.rs +++ b/crates/tui/src/tui/app/tests.rs @@ -7893,10 +7893,7 @@ fn ctrl_t_skips_catalog_rungs_that_resolve_to_an_offered_tier() { wire_model_id: model.to_string(), endpoint_key: "chat".to_string(), reasoning_options: vec![serde_json::json!({ "type": "effort", "values": values })], - source: codewhale_config::catalog::CatalogSource::Live { - base_url_fingerprint: "models-dev-capabilities".to_string(), - fetched_at, - }, + source: codewhale_config::catalog::CatalogSource::ModelsDevLive { fetched_at }, ..Default::default() } }; From 4cfb453e78ebb7215d32b2799ace04091f4269ff Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Tue, 13 Oct 2026 16:13:04 -0700 Subject: [PATCH 25/26] test(runtime): observe durable steer delivery before interrupt The steering API may truthfully return a queued count0 after its two-second observation limit. Two existing tests assumed an in-band delivered count1. Observe the matching turn.steered and completed-item receipts before asserting exact durable count1. Preserve the mock commitment/text, turn identity, interruption, terminal status and every existing event assertion. Production waits and settlement are unchanged; no new test functions or code comments. Local frozen macOS default-feature steering checks: Summary [ 1.904s] 5 tests run: 5 passed, 15306 skipped 5 passed; 0 failed; 15306 outside the filter. fmt and diff checks pass. The existing __eh_frame linker warning remains unsuppressed. Unchanged JS evidence reused at its recorded source boundary: npm1312 passed;0failed;7skipped and check:web passed. Prior98 hosted Clippy7m15 is historical; new-head CI remains required. Original98 Windows CI failed:18300passed(1leaky),1failed,27skipped; all18301unique result ordinals retained. Owner-crash recovery passed there. This repair has not yet run on Windows. The old log does not establish which settlement stage was delayed or a production defect. Refs #6968 --- crates/tui/src/runtime_api/tests.rs | 34 ++++++++++++++++++++++++- crates/tui/src/runtime_threads/tests.rs | 23 ++++++++++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/runtime_api/tests.rs b/crates/tui/src/runtime_api/tests.rs index e32db42f60..c9a8cfd34a 100644 --- a/crates/tui/src/runtime_api/tests.rs +++ b/crates/tui/src/runtime_api/tests.rs @@ -5522,7 +5522,39 @@ async fn steer_and_interrupt_endpoints_work_on_active_turn() -> Result<()> { .json() .await?; assert_eq!(steer_resp["id"], turn_id); - assert_eq!(steer_resp["steer_count"], 1); + assert!(matches!(steer_resp["steer_count"].as_u64(), Some(0 | 1))); + + tokio::time::timeout(ci_scaled(Duration::from_secs(10)), async { + loop { + let events = runtime_threads.events_since(&thread_id, None)?; + let delivered = events.iter().any(|event| { + event.turn_id.as_deref() == Some(turn_id.as_str()) && event.event == "turn.steered" + }); + let completed = events.iter().any(|event| { + event.turn_id.as_deref() == Some(turn_id.as_str()) + && event.event == "item.completed" + && event.payload["item"]["detail"] == "please steer" + }); + if delivered && completed { + return Ok::<_, anyhow::Error>(()); + } + sleep(Duration::from_millis(25)).await; + } + }) + .await + .context("steer delivery did not settle before interrupt")??; + let detail: serde_json::Value = client + .get(format!("http://{addr}/v1/threads/{thread_id}")) + .send() + .await? + .error_for_status()? + .json() + .await?; + let settled_turn = detail["turns"] + .as_array() + .and_then(|turns| turns.iter().find(|turn| turn["id"] == turn_id)) + .context("steered turn missing from thread detail")?; + assert_eq!(settled_turn["steer_count"], 1); let interrupt_resp: serde_json::Value = client .post(format!( diff --git a/crates/tui/src/runtime_threads/tests.rs b/crates/tui/src/runtime_threads/tests.rs index a3665c33e4..db13e3e95c 100644 --- a/crates/tui/src/runtime_threads/tests.rs +++ b/crates/tui/src/runtime_threads/tests.rs @@ -17306,12 +17306,33 @@ async fn steer_turn_on_active_turn_records_item_and_event() -> Result<()> { }, ) .await?; - assert_eq!(steered_turn.steer_count, 1); + assert!(matches!(steered_turn.steer_count, 0 | 1)); let observed_steer = steer_seen_rx .await .context("driver did not receive steer")?; assert_eq!(observed_steer, steer_text); + tokio::time::timeout(TURN_SETTLEMENT_DEADLOCK_TIMEOUT, async { + loop { + let events = manager.events_since(&thread.id, None)?; + let delivered = events.iter().any(|event| { + event.turn_id.as_deref() == Some(turn.id.as_str()) && event.event == "turn.steered" + }); + let completed = events.iter().any(|event| { + event.turn_id.as_deref() == Some(turn.id.as_str()) + && event.event == "item.completed" + && event.payload["item"]["detail"] == steer_text + }); + if delivered && completed { + return Ok::<_, anyhow::Error>(()); + } + sleep(Duration::from_millis(25)).await; + } + }) + .await + .context("steer delivery did not settle")??; + assert_eq!(manager.store.load_turn(&turn.id)?.steer_count, 1); + let final_turn = wait_for_terminal_turn(&manager, &turn.id).await?; assert_eq!(final_turn.status, RuntimeTurnStatus::Completed); assert_eq!(final_turn.steer_count, 1); From fe7e1fc7aee76d66317b9ebfd8853336821b1e24 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Thu, 15 Oct 2026 08:35:11 -0700 Subject: [PATCH 26/26] docs(release): record permitted child code mode for 0.10.2 The release-wide feature-note gate finds no #6841 receipt for the already carried 6ad6b26196df / CW-7033 implementation. Add the missing 0.10.2 note and sync the TUI changelog. Preserve the October 13 date, existing entries and credit, and the known nested-denial Fleet accounting limitation. Validation on exact base 6eeafcd3 and captured proposal inputs: - Existing release-note check v0.10.1..6eeafcd3: before exit 1 for missing #6841; after PASS, 9 linked issue references checked. - Existing sync-changelog.sh --check: PASS, TUI slice up to date. - git apply --check --whitespace=error-all: PASS. - Exactly 2 files, 8 additions, 0 deletions; removing the 4-line note from each proposal restores its original blob byte for byte. No full version check, npm gate, native build/test, resulting-head CI, package or Windows VM acceptance was run in this artifact-only preparation. Refs #6841, CW-7033 Signed-off-by: CodeWhale Bot --- CHANGELOG.md | 4 ++++ crates/tui/CHANGELOG.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eeeab3d7d..018a2a665f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -177,6 +177,10 @@ launcher guidance. ### Added +- Sub-agents retain permitted code-mode tool composition. Nested calls keep + the child's approvals, write scope, tool timeout and work deadline; approval + waits do not consume that tool timeout. Refused nested calls still do not + count toward Fleet's denial-loop guard. Refs #6841 (CW-7033). - `/connect []` signs in to a model provider that a plugin adds, inside the TUI, and `/disconnect ` removes that sign-in. `/login` and `/logout` are for your Codewhale account only. Plugins that diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 35908a90e7..1f473ce99b 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -177,6 +177,10 @@ launcher guidance. ### Added +- Sub-agents retain permitted code-mode tool composition. Nested calls keep + the child's approvals, write scope, tool timeout and work deadline; approval + waits do not consume that tool timeout. Refused nested calls still do not + count toward Fleet's denial-loop guard. Refs #6841 (CW-7033). - `/connect []` signs in to a model provider that a plugin adds, inside the TUI, and `/disconnect ` removes that sign-in. `/login` and `/logout` are for your Codewhale account only. Plugins that