From dae07dd0d0c85dd08359d6691bc37e846d8e66ac Mon Sep 17 00:00:00 2001 From: AdityaVG13 Date: Tue, 25 Aug 2026 22:37:21 -0400 Subject: [PATCH 01/14] perf(tui): cap tokio workers at 2 for doctor dispatch strace census on the doctor path showed 88.7% of syscall time in futex with 45 clone3 spawns: build_runtime() built a full multi-thread runtime (one worker per CPU, 16MiB stacks each) for a read-only diagnostic that uses none of it. Cap workers at 2 for Commands::Doctor only; interactive sessions and servers keep default sizing. Verified on spark-1672 (aarch64): clone3 45->27, futex calls 265->99, doctor wall 45.4->42.8ms, --help output byte-identical, --version floor unchanged. --- crates/tui/src/lib.rs | 58 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 52 insertions(+), 6 deletions(-) diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 169dd217b0..0f2b081084 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -1731,7 +1731,7 @@ fn run_async_main( plugin_discovery: Arc, plugin_registry: Arc, ) -> Result<()> { - build_runtime()?.block_on(run_async_main_inner( + build_runtime(command.as_ref())?.block_on(run_async_main_inner( cli, command, plugin_discovery, @@ -1755,14 +1755,60 @@ fn run_async_main( /// raises SIGABRT, so `spawn_supervised`'s `catch_unwind` cannot see it and the /// process dies with 134 mid-dispatch. /// +/// Build the runtime that owns every async task in this binary. +/// +/// `command` selects the worker-count policy: read-only diagnostic commands +/// run on a small fixed pool instead of tokio's one-worker-per-CPU default +/// (see [`diagnostic_worker_count`]). Interactive sessions and servers keep +/// the default sizing unchanged. +/// +/// `#[tokio::main]` used to expand here, which left every worker thread on +/// tokio's 2 MiB default while only the `codewhale-main` owner thread above +/// received `CODEWHALE_MAIN_STACK_BYTES`. The engine does not run on that owner +/// thread — `core::engine::spawn_engine` hands `Engine::run` to +/// `utils::spawn_supervised`, a bare `tokio::spawn` — so the explicit stack +/// never applied where the depth actually is. +/// +/// A debug-build `agent` dispatch (turn_loop -> FuturesUnordered -> +/// execute_full_with_context -> AgentTool::execute -> spawn_subagent_from_input) +/// measured a stack high-water mark between 2.25 and 2.5 MiB and aborted the +/// whole process on the guard page. A Rust stack overflow is not a panic: it +/// raises SIGABRT, so `spawn_supervised`'s `catch_unwind` cannot see it and the +/// process dies with 134 mid-dispatch. +/// /// This is behavior-identical to the old `#[tokio::main]` expansion apart from /// the stack size, and it makes the knob greppable. -pub(crate) fn build_runtime() -> Result { - tokio::runtime::Builder::new_multi_thread() +pub(crate) fn build_runtime(command: Option<&Commands>) -> Result { + let mut builder = tokio_runtime_builder(); + if let Some(workers) = diagnostic_worker_count(command) { + builder.worker_threads(workers); + } + builder.build().context("Failed to build the Codewhale Tokio runtime") +} + +/// Number of async workers to request from tokio. +/// +/// Unset means tokio's default: one worker per CPU. That default is right for +/// interactive sessions and long-running servers, but a short-lived diagnostic +/// command gains nothing from a 20-worker pool — it pays thread spawn, stack +/// reservation, and teardown futex traffic for capacity it never uses (perf +/// attribution: pthread_create under `Builder::build` dominates init samples). +const DIAGNOSTIC_WORKER_CAP: usize = 2; + +fn diagnostic_worker_count(command: Option<&Commands>) -> Option { + if matches!(command, Some(Commands::Doctor(_))) { + Some(DIAGNOSTIC_WORKER_CAP) + } else { + None + } +} + +fn tokio_runtime_builder() -> tokio::runtime::Builder { + let mut builder = tokio::runtime::Builder::new_multi_thread(); + builder .enable_all() - .thread_stack_size(CODEWHALE_MAIN_STACK_BYTES) - .build() - .context("Failed to build the Codewhale Tokio runtime") + .thread_stack_size(CODEWHALE_MAIN_STACK_BYTES); + builder } /// Which product surface this process is serving. From cab71f543fa8bc911446c250479dd36438be85d4 Mon Sep 17 00:00:00 2001 From: AdityaVG13 Date: Tue, 25 Aug 2026 22:48:01 -0400 Subject: [PATCH 02/14] perf(config): parse the bundled models.dev catalog once per process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bundled_models_dev_catalog() re-parsed the ~50KB snapshot at every call site: the client route path, provider picker, provider lake, and fleet identity each paid an independent serde parse. Return a &'static catalog backed by OnceLock — the asset is include_str! constant, so sharing the parsed form is immutability-safe. Doctor wall is unchanged (the init path only parses once either way); this removes redundant parses on multi-call paths. --- crates/config/src/catalog.rs | 21 ++++++++++++++++++--- crates/config/src/catalog/tests.rs | 2 +- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/crates/config/src/catalog.rs b/crates/config/src/catalog.rs index 9d2246203e..62011c8721 100644 --- a/crates/config/src/catalog.rs +++ b/crates/config/src/catalog.rs @@ -32,6 +32,7 @@ //! [`ProviderCatalogCache`] tests). use std::collections::BTreeMap; +use std::sync::OnceLock; use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; @@ -186,16 +187,30 @@ impl CatalogOffering { /// honesty rule on omitted pricing (`UnknownOrStale`, never a fabricated zero). pub const BUNDLED_MODELS_DEV_JSON: &str = include_str!("../assets/models_dev.bundled.json"); +/// Parse-once cache for the committed bundled Models.dev snapshot. +/// +/// The bundled asset is compile-time constant (`include_str!`), so its parsed +/// form is immutable and safe to share process-wide. Before this cache, every +/// call site parsed the full snapshot independently — the client route path, +/// pickers, provider lake, and fleet identity each paid a full serde parse of +/// ~50KB on their own first use (perf-attributed during the 0.9.x perf +/// gauntlet: `ModelsDevCost` serde frames in startup profiles). +static BUNDLED_MODELS_DEV_CATALOG: OnceLock = OnceLock::new(); + /// Parse the committed bundled Models.dev snapshot. /// +/// The first call parses; later calls return the shared parsed catalog. +/// /// # Panics /// Panics only if the committed asset is not valid Models.dev JSON. The /// `tests::bundled_asset_parses` guard makes that a build-time failure, so this /// never panics in shipped builds. #[must_use] -pub fn bundled_models_dev_catalog() -> ModelsDevCatalog { - ModelsDevCatalog::parse_json(BUNDLED_MODELS_DEV_JSON) - .expect("committed bundled Models.dev asset must be valid JSON") +pub fn bundled_models_dev_catalog() -> &'static ModelsDevCatalog { + BUNDLED_MODELS_DEV_CATALOG.get_or_init(|| { + ModelsDevCatalog::parse_json(BUNDLED_MODELS_DEV_JSON) + .expect("committed bundled Models.dev asset must be valid JSON") + }) } /// Bundled-layer [`CatalogOffering`] rows from the offline snapshot (#4188). diff --git a/crates/config/src/catalog/tests.rs b/crates/config/src/catalog/tests.rs index 7574504da6..f34f930f4e 100644 --- a/crates/config/src/catalog/tests.rs +++ b/crates/config/src/catalog/tests.rs @@ -648,7 +648,7 @@ fn bundled_asset_parses() { "bundled asset must carry provider rows" ); // The helper returns the same parsed catalog. - assert_eq!(bundled_models_dev_catalog(), catalog); + assert_eq!(*bundled_models_dev_catalog(), catalog); } #[test] From ffbb0b5199708845c1b1adc659000e62fc4d6146 Mon Sep 17 00:00:00 2001 From: AdityaVG13 Date: Thu, 27 Aug 2026 16:37:15 -0400 Subject: [PATCH 03/14] perf(tui): capture the rustc banner during resolution, halving doctor's rustc spawns RustC::resolve() proved presence by executing 'rustc --version' and discarding stdout; doctor's rustc_version() then launched a second rustc process to read the same banner. Each launch loads libLLVM. probe_executable_capturing() now records the banner during the probe (OnceLock), and the diagnostics path consumes it after an available() check. Verified on spark-1672 (aarch64): execve(rustc) 2->1 per run, doctor wall 42.8->37.6ms (-12%, n=60), 'rust:' line byte-identical to the toolchain's own 'rustc --version', help output unchanged. --- crates/tui/src/dependencies.rs | 50 +++++++++++++++++++++++++++++++++- crates/tui/src/lib.rs | 14 ++++------ 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/crates/tui/src/dependencies.rs b/crates/tui/src/dependencies.rs index 0b597f08cb..9b6cd9c524 100644 --- a/crates/tui/src/dependencies.rs +++ b/crates/tui/src/dependencies.rs @@ -84,6 +84,32 @@ pub fn probe_executable_with_flag(spec: &str, version_flag: &str) -> bool { matches!(cmd.status(), Ok(status) if status.success()) } +/// Probe a single executable and capture its version banner in one spawn. +/// +/// Same contract as [`probe_executable`] (success = exit 0), but returns the +/// trimmed stdout so callers that want the banner don't need a second process +/// launch. Returns `None` when the probe fails or stdout is not valid UTF-8. +pub fn probe_executable_capturing(spec: &str, version_flag: &str) -> Option { + let mut parts = spec.split_whitespace(); + let program = parts.next()?; + let mut cmd = Command::new(program); + crate::utils::suppress_console_window(&mut cmd); + for arg in parts { + cmd.arg(arg); + } + cmd.arg(version_flag); + cmd.stderr(std::process::Stdio::null()); + + let output = cmd.output().ok()?; + if !output.status.success() { + return None; + } + String::from_utf8(output.stdout) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + fn executable_path_candidates(program: &str) -> Vec { let program_path = Path::new(program); if program_path.components().count() > 1 { @@ -463,9 +489,14 @@ impl ExternalTool for RustC { static CACHE: OnceLock> = OnceLock::new(); CACHE .get_or_init(|| { + // Probe with capture so the `--version` banner observed during + // resolution is reused by [`rustc_version_banner`] instead of + // paying a second rustc process launch (each launch loads + // libLLVM, which dominated diagnostic-command init profiles). for candidate in Self::candidates() { - if probe_executable(candidate) { + if let Some(banner) = probe_executable_capturing(candidate, "--version") { tracing::info!(target: "tool_dependencies", "Resolved rustc binary"); + let _ = RUSTC_VERSION_BANNER.set(Some(banner)); return Some((*candidate).to_string()); } } @@ -475,6 +506,23 @@ impl ExternalTool for RustC { } } +/// Captured `--version` banner from the [`RustC`] resolution probe. +/// +/// `None` until `RustC::resolve()`/`available()`/`command()` first runs, or +/// when rustc is absent/failing. Reading this after an `available()` check +/// yields the same string the tool would print, without a second process. +static RUSTC_VERSION_BANNER: OnceLock> = OnceLock::new(); + +/// The rustc `--version` banner, if rustc resolved successfully. +/// +/// Populated as a side effect of resolving [`RustC`]; this reads no fresh +/// process state. Callers wanting the value should touch `RustC::available()` +/// first (as the diagnostics path does). +#[must_use] +pub fn rustc_version_banner() -> Option { + RUSTC_VERSION_BANNER.get().cloned().flatten() +} + /// Rust build tool — used by the `run_tests` tool. pub struct Cargo; diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 0f2b081084..f869ee8ee9 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -7680,15 +7680,13 @@ async fn test_api_connectivity(config: &Config) -> Result<()> { } fn rustc_version() -> String { - let Some(mut cmd) = crate::dependencies::RustC::command() else { + // `RustC::available()` resolves the tool once, capturing the `--version` + // banner as a side effect of the probe; reuse it instead of launching a + // second rustc process (each launch loads libLLVM). + if !crate::dependencies::RustC::available() { return "unknown".to_string(); - }; - let Ok(output) = cmd.arg("--version").output() else { - return "unknown".to_string(); - }; - String::from_utf8(output.stdout) - .map(|s| s.trim().to_string()) - .unwrap_or_else(|_| "unknown".to_string()) + } + crate::dependencies::rustc_version_banner().unwrap_or_else(|| "unknown".to_string()) } /// List saved sessions From 6f6e5bbb72da853b6d3b94aa40603284fb40f11d Mon Sep 17 00:00:00 2001 From: AdityaVG13 Date: Wed, 26 Aug 2026 00:12:05 -0400 Subject: [PATCH 04/14] perf(tui): extend the diagnostic worker cap to the offline eval harness codewhale eval is a sequential offline tool-loop; it needs no async concurrency, so build its runtime with the same 2-worker cap doctor uses instead of one worker per CPU. Interleaved A/B on spark-1672 under load 9-11: eval wall -8/-10/-6 percent across three OLD/NEW batch pairs; thread spawns for the command drop from ~45 to 5. Interactive surfaces unchanged. style(tui): rustfmt the runtime builder chain --- crates/tui/src/lib.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index f869ee8ee9..6287a259eb 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -1783,20 +1783,27 @@ pub(crate) fn build_runtime(command: Option<&Commands>) -> Result) -> Option { - if matches!(command, Some(Commands::Doctor(_))) { + if matches!( + command, + Some(Commands::Doctor(_)) + // Offline sequential tool-loop harness: no concurrent async work. + | Some(Commands::Eval(_)) + ) { Some(DIAGNOSTIC_WORKER_CAP) } else { None From 9fa638e6a3b09cd3410464995ca785cd951fb505 Mon Sep 17 00:00:00 2001 From: AdityaVG13 Date: Wed, 26 Aug 2026 00:30:06 -0400 Subject: [PATCH 05/14] perf(tui): cap workers for the full read-only diagnostic family setup --status, sessions listing, and session diagnostics share the doctor dispatch shape: read-only, short-lived, no async concurrency need. Extend diagnostic_worker_count to cover them via a structured match mirroring telemetry_command_is_read_only. Interleaved A/B on spark-1672 for 'setup --status' (-17/-21/-16 percent across three batch pairs, NEW wins all three); interactive and mutating surfaces unchanged. --- crates/tui/src/lib.rs | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 6287a259eb..259e4fdeae 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -1798,16 +1798,19 @@ pub(crate) fn build_runtime(command: Option<&Commands>) -> Result) -> Option { - if matches!( - command, - Some(Commands::Doctor(_)) - // Offline sequential tool-loop harness: no concurrent async work. - | Some(Commands::Eval(_)) - ) { - Some(DIAGNOSTIC_WORKER_CAP) - } else { - None - } + let capped = match command { + // Read-only diagnostic surfaces (doctor family). + Some( + Commands::Doctor(_) + | Commands::Eval(_) + | Commands::SessionDiagnostics(_) + | Commands::Sessions { .. }, + ) => true, + // Only the read-only status report; mutating setup keeps defaults. + Some(Commands::Setup(args)) => args.status, + _ => false, + }; + capped.then_some(DIAGNOSTIC_WORKER_CAP) } fn tokio_runtime_builder() -> tokio::runtime::Builder { From 0d8be200104a98fb55fb683d382f9ac7be64a6e2 Mon Sep 17 00:00:00 2001 From: AdityaVG13 Date: Wed, 26 Aug 2026 02:13:47 -0400 Subject: [PATCH 06/14] perf(tui): single-parse Models.dev disk cache; interactive boot -70 percent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persisted models.dev cache stores a ~5MB catalog body JSON-escaped inside a JSON envelope. maybe_load_persisted_cache() runs synchronously on the interactive boot path and parsed the body twice (envelope, then re-parse of the escaped body) plus a full-body string copy — a gdb mid-boot sample caught serde_json::visit_map inside models_dev_live during the largest silent window of startup, and strace showed a 17.7ms zero-syscall compute burst after config load. v2 format: one-line JSON metadata header followed by the verbatim catalog body. Loading does one small header parse, one body parse, zero body copies; v1 envelopes still load via fallback and every refresh now writes v2. Measured on spark-1672 (aarch64), identical binary, interleaved batches over scratch CODEWHALE_HOMEs differing only in cache format: time-to-first-frame median 44.0/52.7/48.6/51.7ms (v1) vs 13.9/14.1/13.9/14.4ms (v2). --- crates/tui/src/models_dev_live.rs | 97 +++++++++++++++++++++++-------- 1 file changed, 74 insertions(+), 23 deletions(-) diff --git a/crates/tui/src/models_dev_live.rs b/crates/tui/src/models_dev_live.rs index 174d8d0e09..0531f2269f 100644 --- a/crates/tui/src/models_dev_live.rs +++ b/crates/tui/src/models_dev_live.rs @@ -93,6 +93,23 @@ struct PersistedModelsDevCache { body: String, } +/// Metadata header for the v2 cache format. +/// +/// v1 serialized the whole cache as one JSON envelope with the catalog body +/// escaped inside it, so loading parsed ~5MB twice (envelope, then body) plus +/// a full-body copy on every interactive boot. v2 stores the metadata as a +/// single JSON header line followed by the raw catalog body bytes, so boot +/// performs exactly one catalog parse and zero body copies. +const CACHE_SCHEMA_VERSION_V2: u32 = 2; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PersistedModelsDevCacheV2 { + schema_version: u32, + fetched_at: u64, + source_fingerprint: String, + source_label: String, +} + /// Why a Models.dev refresh did not publish new rows. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ModelsDevRefreshError { @@ -192,26 +209,27 @@ pub fn maybe_load_persisted_cache() { let Some(path) = cache_path() else { return; }; - if let Some(cache) = load_cache_file(&path) { - let age = now_unix().saturating_sub(cache.fetched_at); - let freshness = if age > DEFAULT_MODELS_DEV_TTL_SECS { - ModelsDevFreshness::Stale - } else { - ModelsDevFreshness::Live - }; - if let Err(err) = publish_from_body( - &cache.body, - &cache.source_fingerprint, - cache.fetched_at, - &cache.source_label, - freshness, - ) { - tracing::debug!( - target: "models_dev_live", - error = %err, - "persisted Models.dev cache failed to publish; keeping bundled" - ); - } + let Some(cache) = load_cache_file(&path) else { + return; + }; + let age = now_unix().saturating_sub(cache.fetched_at); + let freshness = if age > DEFAULT_MODELS_DEV_TTL_SECS { + ModelsDevFreshness::Stale + } else { + ModelsDevFreshness::Live + }; + if let Err(err) = publish_from_body( + &cache.body, + &cache.source_fingerprint, + cache.fetched_at, + cache.source_label.as_str(), + freshness, + ) { + tracing::debug!( + target: "models_dev_live", + error = %err, + "persisted Models.dev cache failed to publish; keeping bundled" + ); } } @@ -401,8 +419,31 @@ fn mark_failed(err: ModelsDevRefreshError) { set_status(next); } +/// Load the on-disk Models.dev cache. +/// +/// Reads v2 (single-parse: one header line + raw body) and v1 (JSON envelope +/// with an escaped body) formats. Returns metadata and the *unescaped* body +/// without copying it in the v2 path. fn load_cache_file(path: &Path) -> Option { let bytes = std::fs::read(path).ok()?; + // v2: single-line JSON header terminated by a newline, then the verbatim + // catalog body. One small parse, zero body copies. + if bytes.first() == Some(&b'{') && bytes.contains(&b'\n') { + let split = bytes.iter().position(|b| *b == b'\n')?; + if let Ok(header) = serde_json::from_slice::(&bytes[..split]) { + if header.schema_version == CACHE_SCHEMA_VERSION_V2 && !bytes[split + 1..].is_empty() { + let body = String::from_utf8(bytes[split + 1..].to_vec()).ok()?; + return Some(PersistedModelsDevCache { + schema_version: CACHE_SCHEMA_VERSION, + fetched_at: header.fetched_at, + source_fingerprint: header.source_fingerprint, + source_label: header.source_label, + body, + }); + } + } + } + // v1 fallback: whole-file JSON envelope with the body escaped inside. let cache: PersistedModelsDevCache = serde_json::from_slice(&bytes).ok()?; if cache.schema_version != CACHE_SCHEMA_VERSION { return None; @@ -417,9 +458,19 @@ fn save_cache_file( path: &Path, cache: &PersistedModelsDevCache, ) -> Result<(), ModelsDevRefreshError> { - let bytes = - serde_json::to_vec(cache).map_err(|err| ModelsDevRefreshError::Io(err.to_string()))?; - atomic_write(path, &bytes).map_err(|err| ModelsDevRefreshError::Io(err.to_string())) + // Write the single-parse format so the next boot parses the catalog once. + let header = PersistedModelsDevCacheV2 { + schema_version: CACHE_SCHEMA_VERSION_V2, + fetched_at: cache.fetched_at, + source_fingerprint: cache.source_fingerprint.clone(), + source_label: cache.source_label.clone(), + }; + let mut header_line = + serde_json::to_vec(&header).map_err(|err| ModelsDevRefreshError::Io(err.to_string()))?; + header_line.push(b'\n'); + let mut payload = header_line; + payload.extend_from_slice(cache.body.as_bytes()); + atomic_write(path, &payload).map_err(|err| ModelsDevRefreshError::Io(err.to_string())) } /// Compile helper exposed for unit tests: body → live offerings with normalized From 3364a8017d59c4e38231b776710ee4800e6ef148 Mon Sep 17 00:00:00 2001 From: AdityaVG13 Date: Wed, 26 Aug 2026 03:30:47 -0400 Subject: [PATCH 07/14] perf(tui): memoize provider resolution in catalog cutline pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apply_provider_model_cutlines called ApiProvider::parse per offering; parse scans every provider and its alias list with case-insensitive compares, and the live models.dev snapshot carries thousands of rows. Freeze-and-inspect at t+180ms of boot caught the main thread inside this loop. Resolve each distinct provider string once through a HashMap. Interleaved A/B on spark-1672: first-paint median drops ~2-6ms per run (195.7/196.9/192.9/197.2 -> 185.0/190.9/190.3/193.8, NEW wins 4/4). perf(tui): adaptive poll cadence for foreground shell completion Foreground bash runs go through execute_foreground_via_background, whose wait loop polled child status on a fixed 100ms tick. A command that finished in 2ms was only noticed at the next tick, so every fast foreground call (ls, grep, wc, echo — the bulk of agent traffic) carried a ~100ms floor: a simulated 8-tool turn spent ~400ms of its ~526ms wall in poll quantization (measured over serve --mcp with the real registry). Replace the fixed tick in all three wait loops (foreground completion, wait-many, delta waiter) with an adaptive cadence: first sleep 10ms, then double to a 100ms cap. Instant commands are now detected within ~10ms; long-running commands reach the old cap after one doubling step, so their overhead is unchanged. Revert "perf(tui): adaptive poll cadence for foreground shell completion" This reverts commit 1a2f42c9f0b53d9b83ed50db4d4d7bd66df7977e. --- crates/tui/src/provider_lake.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/provider_lake.rs b/crates/tui/src/provider_lake.rs index d585e78cb6..3944958c29 100644 --- a/crates/tui/src/provider_lake.rs +++ b/crates/tui/src/provider_lake.rs @@ -128,11 +128,21 @@ fn bundled_snapshot() -> &'static CatalogSnapshot { /// deliberately downstream of every publisher so stale cached rows cannot /// bypass the client-side live-fetch filter. fn apply_provider_model_cutlines(mut snapshot: CatalogSnapshot) -> CatalogSnapshot { + // `ApiProvider::parse` scans every provider and alias list per call; the + // distinct provider strings in a catalog are few, so resolve each distinct + // string once instead of once per offering (boot-path profiles showed + // this loop as the largest post-parse compute block). + let mut resolved: std::collections::HashMap> = + std::collections::HashMap::new(); snapshot.offerings = snapshot .offerings .into_iter() .filter_map(|mut offering| { - if ApiProvider::parse(&offering.provider) == Some(ApiProvider::OpencodeGo) { + let parsed = resolved + .entry(offering.provider.clone()) + .or_insert_with(|| ApiProvider::parse(&offering.provider)) + .clone(); + if parsed == Some(ApiProvider::OpencodeGo) { let canonical = opencode_go_chat_model_id(&offering.wire_model_id)?; offering.provider = ApiProvider::OpencodeGo.as_str().to_string(); offering.wire_model_id = canonical.to_string(); From 4fc1d594fab68172d7a60673566f8bf07deddeb8 Mon Sep 17 00:00:00 2001 From: AdityaVG13 Date: Wed, 26 Aug 2026 19:41:56 -0400 Subject: [PATCH 08/14] perf(tui): adaptive poll cadence for foreground shell completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foreground bash runs go through execute_foreground_via_background, whose wait loop polled child status on a fixed 100ms tick. A command that finished in 2ms was only noticed at the next tick, so every fast foreground call (ls, grep, wc, echo — the bulk of agent traffic) carried a ~100ms floor: a simulated 8-tool turn spent ~400ms of its ~526ms wall in poll quantization (measured over serve --mcp with the real registry). Replace the fixed tick in all three wait loops (foreground completion, wait-many, delta waiter) with an adaptive cadence: first sleep 10ms, then double to a 100ms cap. Instant commands are now detected within ~10ms; long-running commands reach the old cap after one doubling step, so their overhead is unchanged. Measured over real serve --mcp (n=150, binaries aside): bash `true`: p50 13.6 both | p95 16.2→13.9 | p99 21.5→18.6 | p100 35.9→21.6 bash grep: p99-p100 flat ~13.8-14.1 both file_read: p100 0.2 both (min/p50/p99 all 0.1) tools/list: p100 0.2-0.7 both Tail-wall win: p95 -14%, p99 -13%, p100 -40% with p50 unchanged. An earlier A/B that compared only medians missed the win and was reverted; the tail distribution above is what justified re-landing it. Interleaved A/B on aarch64 Linux, same host, same window. --- crates/tui/src/tools/shell.rs | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index 0f82c6f21d..f658dd6912 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -4137,6 +4137,11 @@ async fn execute_foreground_via_background( } let deadline = timeout_ms.map(|timeout| Instant::now() + Duration::from_millis(timeout)); + // Adaptive poll cadence: fast commands (the common case — grep, wc, echo) + // finish in single-digit milliseconds, and a fixed 100ms tick made every + // foreground call pay that full quantum before completion was noticed. + // Start fine-grained and back off to the 100ms cap for long-running work. + let mut poll_tick_ms: u64 = FOREGROUND_POLL_INITIAL_MS; loop { if context .cancel_token @@ -4192,12 +4197,22 @@ async fn execute_foreground_via_background( return Ok(result); } - tokio::time::sleep(Duration::from_millis(100)).await; + tokio::time::sleep(Duration::from_millis(poll_tick_ms)).await; + poll_tick_ms = (poll_tick_ms * 2).min(FOREGROUND_POLL_MAX_MS); } } const BASH_MAX_TIMEOUT_MS: u64 = i32::MAX as u64; +/// Initial cadence for foreground-via-background completion polling. Fast +/// commands dominate real agent traffic; detection latency on `true`-class +/// commands drops from ~100ms to ~10ms, while long commands reach the +/// 100ms cap within one doubling step. +const FOREGROUND_POLL_INITIAL_MS: u64 = 10; +/// Poll-cadence ceiling; matches the previous fixed tick so long-running +/// command overhead is unchanged. +const FOREGROUND_POLL_MAX_MS: u64 = 100; + /// Default foreground lifetime for a contract-`bash` `action=run` that names /// no `timeout_ms`. Matches the value the tool's own input schema advertises; /// before this existed the omitted case fell through to @@ -5488,6 +5503,7 @@ impl BashTool { .collect() }; + let mut poll_tick_ms: u64 = FOREGROUND_POLL_INITIAL_MS; let statuses = loop { let current = { let mut manager = context @@ -5521,7 +5537,8 @@ impl BashTool { timed_out = true; break current; } - tokio::time::sleep(Duration::from_millis(100)).await; + tokio::time::sleep(Duration::from_millis(poll_tick_ms)).await; + poll_tick_ms = (poll_tick_ms * 2).min(FOREGROUND_POLL_MAX_MS); }; let running_after = statuses @@ -5934,6 +5951,7 @@ async fn wait_for_shell_delta_cancellable( let mut stdout_accum = String::new(); let mut stderr_accum = String::new(); + let mut poll_tick_ms: u64 = FOREGROUND_POLL_INITIAL_MS; let (command, result, stdout_total_len, stderr_total_len) = loop { if context .cancel_token @@ -5981,7 +5999,8 @@ async fn wait_for_shell_delta_cancellable( break (command, delta.result, stdout_total_len, stderr_total_len); } - tokio::time::sleep(Duration::from_millis(100)).await; + tokio::time::sleep(Duration::from_millis(poll_tick_ms)).await; + poll_tick_ms = (poll_tick_ms * 2).min(FOREGROUND_POLL_MAX_MS); }; Ok(( From 692cf1efb1479f8b11f5f98b87996042c2862443 Mon Sep 17 00:00:00 2001 From: AdityaVG13 Date: Thu, 27 Aug 2026 16:38:48 -0400 Subject: [PATCH 09/14] perf(tui): single-pass token accounting on per-turn pressure paths The per-turn metadata build and the compaction decision each re-walked the full transcript more than once per step. Make those walks single-pass with byte-identical behavior, and fix one cache-invalidation bug found along the way. compaction_decision_with_billed: the same pure estimator ran twice per step once pressure existed, once in the pressure gate and once in the prune projection. Compute it at most once and thread it to both consumers. Guard order (gate, prune projection, too-few-messages, retained-floor) is untouched and decisions are identical for all inputs. When provider-billed input alone proves pressure, the estimate walk is skipped entirely without bypassing the guards that keep a compaction loop from re-firing on every step. active_input_tokens_with_current_text: the per-turn metadata builder deep-cloned the entire message history just to append the composer text before estimating. Estimate the history in place and add the one-message delta arithmetically instead. The estimator inflates the summed byte count by a factor of 1.5 rounded up as a whole, so the delta is floor(own * 3 / 2) plus one exactly when the running byte sum is even and the composer text contributes an odd count. Proven against a clone-and-estimate reference over 80,000 parity combinations and pinned per case by a test that also covers replayed reasoning, the arm where a naive helper undercounts. Op::SyncSession: session restore assigned the restored message list directly to the session field, bypassing the messages_revision bump the token-estimate cache keys on, so a restored conversation could be priced from a stale estimate. Bump the revision and pin the behavior with a test. Verification: cargo fmt clean; compaction:: and engine preview, pressure, and restore tests 19 passed via remote rch lane; cargo check --all-targets clean with no warnings. --- crates/tui/src/compaction.rs | 51 +++-- crates/tui/src/core/engine.rs | 48 ++++- crates/tui/src/core/engine/preview/tests.rs | 208 ++++++++++++++++++++ 3 files changed, 283 insertions(+), 24 deletions(-) diff --git a/crates/tui/src/compaction.rs b/crates/tui/src/compaction.rs index 9a68310e4a..4d456e13f9 100644 --- a/crates/tui/src/compaction.rs +++ b/crates/tui/src/compaction.rs @@ -283,7 +283,7 @@ pub(crate) fn is_compaction_checkpoint_message(message: &Message) -> bool { user_text_of(message).is_some_and(|text| is_compaction_summary_text(&text)) } -fn estimate_tokens_for_message(message: &Message, include_thinking: bool) -> usize { +pub(crate) fn estimate_tokens_for_message(message: &Message, include_thinking: bool) -> usize { message .content .iter() @@ -340,14 +340,14 @@ pub fn estimate_tokens(messages: &[Message]) -> usize { .sum() } -fn message_has_tool_use(message: &Message) -> bool { +pub(crate) fn message_has_tool_use(message: &Message) -> bool { message .content .iter() .any(|block| matches!(block, ContentBlock::ToolUse { .. })) } -pub fn estimate_text_tokens_conservative(text: &str) -> usize { +pub(crate) fn estimate_text_tokens_conservative(text: &str) -> usize { text.chars().count().div_ceil(3) } @@ -455,10 +455,18 @@ pub fn compaction_pressure_reached_with_billed( if !config.enabled { return false; } - let estimated = estimate_input_tokens_for_pressure(messages, system_prompt); let billed = billed_input_tokens .and_then(|tokens| usize::try_from(tokens).ok()) .unwrap_or(0); + // Billing alone proving pressure short-circuits the walk (#perf-r5): + // `estimated.max(billed) >= threshold` is unconditionally true when + // `billed >= threshold`, so estimating cannot change the answer and the + // O(transcript) pass is skipped. Over-pressure sessions pay this check + // multiple times per step (pressure gate + decision re-check). + if billed >= config.token_threshold { + return true; + } + let estimated = estimate_input_tokens_for_pressure(messages, system_prompt); estimated.max(billed) >= config.token_threshold } @@ -525,14 +533,26 @@ pub fn compaction_decision_with_billed( if !config.enabled { return CompactionDecision::NotNeeded; } - if !compaction_pressure_reached_with_billed( - messages, - system_prompt, - config, - billed_input_tokens, - ) { - return CompactionDecision::NotNeeded; - } + // Pressure gate + prune projection share one estimate (#perf-r5): both + // consume `estimate_input_tokens_for_pressure` over the same + // `(messages, system_prompt)`, a pure function, so it is computed at + // most once. `billed >= threshold` proves pressure without estimating + // (max is unconditionally >= threshold then); the estimate is deferred + // until something actually needs it — the prune projection below — so + // the billed-corner still reaches the TooFew and RetainedFloor guards + // unchanged, and skips the walk entirely when no prune candidates exist. + let billed = billed_input_tokens + .and_then(|tokens| usize::try_from(tokens).ok()) + .unwrap_or(0); + let estimated: Option = if billed < config.token_threshold { + let estimate = estimate_input_tokens_for_pressure(messages, system_prompt); + if estimate.max(billed) < config.token_threshold { + return CompactionDecision::NotNeeded; + } + Some(estimate) + } else { + None + }; // The execution path mechanically prunes old verbose tool results before // asking the model for a summary. Local pruning alone may be enough to @@ -542,9 +562,12 @@ pub fn compaction_decision_with_billed( // without cloning a multi-megabyte transcript on every step. let prune_plan = plan_tool_result_prunes(messages, KEEP_RECENT_MESSAGES); if !prune_plan.is_empty() { + let estimate = match estimated { + Some(value) => value, + None => estimate_input_tokens_for_pressure(messages, system_prompt), + }; let reclaimed_tokens: usize = prune_plan.iter().map(PlannedPrune::tokens_reclaimed).sum(); - let projected = estimate_input_tokens_for_pressure(messages, system_prompt) - .saturating_sub(reclaimed_tokens); + let projected = estimate.saturating_sub(reclaimed_tokens); if projected < config.token_threshold { return CompactionDecision::Compact; } diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 2852bf055f..82d2cc3232 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -2841,6 +2841,14 @@ impl Engine { .push(crate::compaction::compaction_checkpoint_message(checkpoint)); } self.session.messages = restored_messages.into(); + // Direct field assignment bypasses `add_message` / + // `replace_messages`, which own the messages-revision + // bump the token-estimate cache keys on (#perf-r5). + // Without this bump the first estimate after a + // session restore is computed against whatever + // history revision was current before the sync — a + // stale number can flow into capacity checkpoints. + self.session.bump_messages_revision(); self.session.compaction_summary_prompt = compaction_checkpoint; self.session.system_prompt = crate::compaction::strip_compaction_summaries(system_prompt.as_ref()); @@ -3134,17 +3142,37 @@ impl Engine { current_text: &str, system_prompt: Option<&SystemPrompt>, ) -> usize { - let mut messages: Vec = self.session.messages.clone().into(); - if !current_text.trim().is_empty() { - messages.push(Message { - role: Role::User, - content: vec![ContentBlock::Text { - text: current_text.to_string(), - cache_control: None, - }], - }); + // Estimate the installed history IN PLACE — no full-transcript clone + // per `` build (#perf-r5). `&AppendLog` deref-coerces to + // `&[Message]` exactly like the cache call site. + let base = estimate_input_tokens_conservative(&self.session.messages, system_prompt); + if current_text.trim().is_empty() { + return base; + } + // Arithmetic equivalent of pushing one more user message: `own` + // un-inflated tokens (Text block rule, `len()/4` — same as the + // estimator's per-message byte sum S) plus one framing increment. + // The estimator inflates S by ceil(3/2) as a WHOLE, so + // ceil((S+own)*3/2) − ceil(S*3/2) = floor(own*3/2) + 1 exactly when + // S is even and own is odd; pinned exhaustively (80k pairs) and per + // case by `context_pressure_delta_matches_clone_and_push_reference`. + let sum: usize = self + .session + .messages + .iter() + .map(|m| { + crate::compaction::estimate_tokens_for_message( + m, + crate::compaction::message_has_tool_use(m), + ) + }) + .sum(); + let own = current_text.len() / 4; + let mut inflated_delta = own * 3 / 2; + if sum % 2 == 0 && own % 2 == 1 { + inflated_delta += 1; } - estimate_input_tokens_conservative(&messages, system_prompt) + base.saturating_add(inflated_delta).saturating_add(12) } fn append_resource_metadata_lines( diff --git a/crates/tui/src/core/engine/preview/tests.rs b/crates/tui/src/core/engine/preview/tests.rs index 2533a5dd38..3d61944b9d 100644 --- a/crates/tui/src/core/engine/preview/tests.rs +++ b/crates/tui/src/core/engine/preview/tests.rs @@ -333,6 +333,214 @@ fn turn_metadata_uses_planned_cross_route_limits_not_installed_limits() { assert!(!metadata.contains("4096 tokens"), "{metadata}"); } +/// #perf-r5: the pressure-line helper must estimate the history IN PLACE and +/// add the composer text arithmetically. Guards two things at once: +/// +/// 1. Equivalence — the arithmetic form must equal the naive +/// "clone + push + estimate" reference for non-trivial inputs (Unicode +/// multi-byte content included, since Text blocks count *chars* for the +/// conservative estimator but the delta path counts... the same rule as +/// `estimate_tokens_for_message`: bytes/4). +/// 2. The contract that empty/no-op composer text costs nothing extra. +#[test] +fn context_pressure_delta_matches_clone_and_push_reference() { + let config = deepseek_config(); + let (mut engine, _handle, _tmp) = preview_engine(&config); + engine.api_provider = ApiProvider::Deepseek; + let installed_limits = codewhale_config::route::RouteLimits { + context_tokens: Some(64_000), + input_tokens: None, + output_tokens: Some(512), + }; + engine.active_route_limits = Some(installed_limits); + // Multi-byte content on purpose: chars().count() != len() here, so an + // arity mistake between the byte rule (estimator) would surface. + engine.session.messages.push(Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "héllo wörld — ünïcode ✓ ".repeat(500), + cache_control: None, + }], + }); + engine.session.messages.push(Message { + role: Role::Assistant, + content: vec![ContentBlock::Thinking { + thinking: "step".repeat(100), + signature: None, + state: None, + }], + }); + // Replayed-reasoning case (#perf-r5 fresh-eyes fix): an assistant message + // carrying BOTH thinking and a tool call keeps its reasoning content in + // every subsequent request — the estimator counts those bytes, and this + // was the exact arm the delta helper originally missed. Both parity + // variants of the thinking byte-count are exercised below. + engine.session.messages.push(Message { + role: Role::Assistant, + content: vec![ + ContentBlock::Thinking { + thinking: "replayed".repeat(300), // 8 bytes per unit -> even count + signature: None, + state: None, + }, + ContentBlock::ToolUse { + id: "call_1".to_string(), + name: "bash".to_string(), + input: json!({"command": "echo hello"}), + caller: None, + thought_signature: None, + }, + ], + }); + engine.session.messages.push(Message { + role: Role::Assistant, + content: vec![ + ContentBlock::Thinking { + thinking: "odd replay".to_string(), // 11 bytes / 4 = 2 (even)... use odd total + signature: None, + state: None, + }, + ContentBlock::ToolUse { + id: "call_2".to_string(), + name: "read".to_string(), + input: json!({"path": "x"}), // 13-byte JSON -> 3 + caller: None, + thought_signature: None, + }, + ], + }); + let _prompt_context = NextTurnPromptContext::for_planned_turn( + ApiProvider::Deepseek, + "deepseek-v4-flash".to_string(), + Some(installed_limits), + AppMode::Agent, + None, + GoalStatus::Active, + None, + false, + None, + ); + let _ = &_prompt_context; + + // Naive reference implementation: clone the transcript, push a + // hypothetical user message, run the full conservative estimator. + let reference = |engine: &Engine, text: &str| -> usize { + let mut messages: Vec = + crate::prompt_zones::AppendLog::clone(&engine.session.messages).into(); + if !text.trim().is_empty() { + messages.push(Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: text.to_string(), + cache_control: None, + }], + }); + } + crate::compaction::estimate_input_tokens_conservative(&messages, None) + }; + + for text in [ + "", + " ", + "short", + "a much longer composer draft with punctuation…", + ] { + let via_pressure_line_input = engine.active_input_tokens_with_current_text(text, None); + assert_eq!( + via_pressure_line_input, + reference(&engine, text), + "delta arithmetic diverged from clone+push+estimate for {text:?}" + ); + } +} + +/// #perf-r5 guard: billed input above the threshold must report pressure with +/// a provably-empty history — proving the short-circuit answers from billing +/// alone without consulting message contents. +#[test] +fn billed_pressure_above_threshold_answers_from_billing_alone() { + let config = CompactionConfig { + enabled: true, + token_threshold: 1_000, + ..Default::default() + }; + let pressure = crate::compaction::compaction_pressure_reached_with_billed( + &[], // empty history: only billing can prove pressure + None, + &config, + Some(2_000), + ); + assert!(pressure, "billed 2000 >= threshold 1000 must be pressure"); +} + +/// #perf-r5 guard: under-threshold billing keeps the old max() semantics — +/// an estimate above the trigger still fires even when billing is quiet. +#[test] +fn billed_below_threshold_still_fires_on_estimate() { + let config = CompactionConfig { + enabled: true, + token_threshold: 100, + ..Default::default() + }; + let big = Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "x".repeat(4 * 200), + cache_control: None, + }], + }; + let pressure = crate::compaction::compaction_pressure_reached_with_billed( + std::slice::from_ref(&big), + None, + &config, + Some(10), // below threshold; must not short-circuit to false either + ); + assert!(pressure, "estimate 200 (+1.0 framing) >= 100 must fire"); +} + +/// #perf-r5 guard: a direct `session.messages` overwrite (the SyncSession +/// restore path) must advance `messages_revision` so the token-estimate +/// cache invalidates instead of serving the pre-sync value. +#[test] +fn sync_restore_bumps_messages_revision_for_estimate_cache() { + use crate::core::engine::token_estimate_cache::TokenEstimateCache; + + let config = deepseek_config(); + let (mut engine, _handle, _tmp) = preview_engine(&config); + engine.session.add_message(Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "before restore".to_string(), + cache_control: None, + }], + }); + let revision_before = engine.session.messages_revision; + let mut cache = TokenEstimateCache::new(); + let stale = cache.lookup_or_compute( + revision_before, + engine.session.system_prompt.as_ref(), + &engine.session.messages, + ); + + // Simulate the restore's direct field assignment. + engine.session.messages = Vec::new().into(); + engine.session.bump_messages_revision(); + + assert_ne!( + engine.session.messages_revision, revision_before, + "restore must bump the revision the estimate cache keys on" + ); + let fresh = cache.lookup_or_compute( + engine.session.messages_revision, + engine.session.system_prompt.as_ref(), + &engine.session.messages, + ); + assert_ne!( + fresh, stale, + "cache must recompute after a restore-driven revision bump" + ); +} + #[tokio::test] async fn compaction_preview_uses_the_planned_routes_system_prompt() { let config = deepseek_config(); From 5edb8c3f94d5703e6e1337ef09a1b4a29a34533b Mon Sep 17 00:00:00 2001 From: AdityaVG13 Date: Thu, 27 Aug 2026 16:39:10 -0400 Subject: [PATCH 10/14] perf(tui): zero-copy LaTeX fast path for the streaming render The streaming render ran the math transform over the full accumulated content on every chunk, before the incremental markdown cache that itself only renders deltas, so streamed turns paid an O(n) copy and scan per chunk with O(n^2) cumulative cost. render_latex_in_text now returns Cow. One byte scan for the three opening delimiters ($, \(, \[) decides between borrowing the input untouched, the overwhelming case for streamed prose, and running the transform. Output is byte-identical either way: the full transform still re-verifies delimiters precisely, so the fast scan cannot create false negatives. Tests: no_math_is_borrowed_without_copy pins the borrowed path and that the \( form is not missed by the fast scan; test_inline_dollar pins the owned path on math input. Verification: cargo fmt clean; latex_render:: 18 passed via remote rch lane; cargo check --all-targets clean with no warnings. --- crates/tui/src/tui/history/latex_render.rs | 37 ++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/tui/history/latex_render.rs b/crates/tui/src/tui/history/latex_render.rs index 242b179743..7394b9f36d 100644 --- a/crates/tui/src/tui/history/latex_render.rs +++ b/crates/tui/src/tui/history/latex_render.rs @@ -128,7 +128,29 @@ fn render_math_segment(text: &str) -> String { } /// Replace math delimiters with plain Unicode while preserving Markdown code. -pub fn render_latex_in_text(text: &str) -> String { +/// +/// Fast path (#perf-r5): the overwhelming majority of streamed content +/// contains no math delimiters at all. A single byte scan for the three +/// opening delimiters (`$`, `\(`, `\[`) decides between borrowing the input +/// untouched and running the full transform, so the per-chunk streaming +/// render avoids allocating a full-content copy on every update when no +/// math is present. +pub fn render_latex_in_text(text: &str) -> std::borrow::Cow<'_, str> { + // Math can only start at '$' (incl. '$$') or the two-byte '\(' and '\['. + // Scanning bytes directly avoids a regex; any hit falls back to the + // full transform below, which re-verifies delimiters precisely. + let has_delim = text + .as_bytes() + .iter() + .enumerate() + .any(|(idx, &byte)| match byte { + b'$' => true, + b'\\' => matches!(text.as_bytes().get(idx + 1), Some(b'(') | Some(b'[')), + _ => false, + }); + if !has_delim { + return std::borrow::Cow::Borrowed(text); + } let mut result = String::with_capacity(text.len()); let mut cursor = 0; @@ -156,7 +178,7 @@ pub fn render_latex_in_text(text: &str) -> String { } } - result + std::borrow::Cow::Owned(result) } // --- Environment rendering --- @@ -1653,6 +1675,7 @@ mod tests { fn test_inline_dollar() { let r = render_latex_in_text(r"text $x^2$ more"); assert_eq!(r, "text x\u{00b2} more"); + assert!(matches!(r, std::borrow::Cow::Owned(_))); } #[test] fn test_display_bracket() { @@ -1660,6 +1683,16 @@ mod tests { assert_eq!(r, "text x\u{00b2} more"); } #[test] + fn no_math_is_borrowed_without_copy() { + let r = render_latex_in_text("plain prose with `code` but no math at all"); + assert!(matches!(r, std::borrow::Cow::Borrowed(_))); + assert_eq!(&*r, "plain prose with `code` but no math at all"); + // The '$' fast path must not miss \( + let p = render_latex_in_text("parens \\(x^2\\) inline"); + assert!(matches!(p, std::borrow::Cow::Owned(_))); + assert_eq!(&*p, "parens x\u{00b2} inline"); + } + #[test] fn preserves_currency() { assert_eq!(render_latex_in_text("cost $5 and $10"), "cost $5 and $10"); } From ab4c8a8e157233f50c731505edc0482b5255c144 Mon Sep 17 00:00:00 2001 From: Isabel Wu <231155141+wuisabel-gif@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:47:40 -0700 Subject: [PATCH 11/14] chore(tui): gate audited test-only helpers Convert the first audited test-only dead-code slice to cfg(test), covering rendering/text fixtures and helper surfaces while preserving runtime behavior. Add direct tests for scroll and exploration wrappers for #5587. --- CHANGELOG.md | 3 +++ crates/tui/CHANGELOG.md | 3 +++ crates/tui/src/tui/ambient_life.rs | 2 +- crates/tui/src/tui/file_tree.rs | 15 ++++++++++++++- crates/tui/src/tui/focus_texture.rs | 2 +- crates/tui/src/tui/history.rs | 4 ++-- crates/tui/src/tui/history/tests.rs | 2 +- crates/tui/src/tui/history/thinking.rs | 2 +- crates/tui/src/tui/whales.rs | 22 +++++++++++++++------- 9 files changed, 41 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34072a5618..82e923e8c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- The first #5587 dead-code sweep converts audited test-only helpers to + `#[cfg(test)]`, keeping production builds free of test-only APIs without + changing runtime behavior. - `/plugin reload` is now discoverable when on-disk plugin bundles change: the next send and `/plugin list` nudge once with `Run /plugin reload to apply` instead of silently keeping the stale catalog (#5579). Trust is unchanged; diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index f7ec4934c2..e0531d94b3 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -72,6 +72,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- The first #5587 dead-code sweep converts audited test-only helpers to + `#[cfg(test)]`, keeping production builds free of test-only APIs without + changing runtime behavior. - `/plugin reload` is now discoverable when on-disk plugin bundles change: the next send and `/plugin list` nudge once with `Run /plugin reload to apply` instead of silently keeping the stale catalog (#5579). Trust is unchanged; diff --git a/crates/tui/src/tui/ambient_life.rs b/crates/tui/src/tui/ambient_life.rs index d4662fe1b1..a22e754838 100644 --- a/crates/tui/src/tui/ambient_life.rs +++ b/crates/tui/src/tui/ambient_life.rs @@ -191,7 +191,7 @@ pub struct AmbientFrameStats { /// [`AmbientFrameStats::marks_built`], not a runtime clamp: the population is /// bounded by construction, and this constant is what fails the build if a /// future change makes it unbounded. -#[allow(dead_code)] +#[cfg(test)] pub const MAX_FRAME_MARKS: u32 = 24; /// Optional pointer reaction for fish dart / bubble rise. diff --git a/crates/tui/src/tui/file_tree.rs b/crates/tui/src/tui/file_tree.rs index 9fd699ee7d..e92d2e00c9 100644 --- a/crates/tui/src/tui/file_tree.rs +++ b/crates/tui/src/tui/file_tree.rs @@ -348,7 +348,7 @@ impl FileTreeState { } /// Adjust scroll for a given visible height. - #[allow(dead_code)] + #[cfg(test)] pub fn adjust_scroll(&mut self, visible: usize) { if self.cursor < self.scroll_offset { self.scroll_offset = self.cursor; @@ -663,6 +663,19 @@ mod tests { ); } + #[test] + fn adjust_scroll_keeps_the_cursor_inside_the_visible_window() { + let ws = fixture_workspace(); + let mut state = FileTreeState::new(ws.path()); + state.cursor = state.entries.len().saturating_sub(1); + state.adjust_scroll(3); + assert!(state.cursor < state.scroll_offset + 3); + + state.cursor = 0; + state.adjust_scroll(3); + assert_eq!(state.scroll_offset, 0); + } + #[test] fn stale_expand_results_are_discarded() { let ws = fixture_workspace(); diff --git a/crates/tui/src/tui/focus_texture.rs b/crates/tui/src/tui/focus_texture.rs index fc26f2a9bc..ebaa4cbb98 100644 --- a/crates/tui/src/tui/focus_texture.rs +++ b/crates/tui/src/tui/focus_texture.rs @@ -114,7 +114,7 @@ pub struct FocusTextureStats { impl FocusTextureStats { /// The accounting identity asserted by the unit tests. This type's only /// consumer is the test gate below, hence the `dead_code` allowance. - #[allow(dead_code)] + #[cfg(test)] #[must_use] pub fn accounted(&self) -> bool { self.cells_examined diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index 4077c2149b..09a5e6dbd0 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -1167,7 +1167,7 @@ pub struct ExploringCell { impl ExploringCell { /// Render the exploring cell into lines. - #[allow(dead_code)] + #[cfg(test)] pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec> { self.lines_with_motion_and_locale(width, low_motion, Locale::En) } @@ -1691,7 +1691,7 @@ impl GenericToolCell { /// `mode` controls multi-line output handling: `Live` caps at /// `TOOL_OUTPUT_LINE_LIMIT` rows with a "+N more" affordance; /// `Transcript` emits the full output. - #[allow(dead_code)] + #[cfg(test)] pub fn lines_with_mode( &self, width: u16, diff --git a/crates/tui/src/tui/history/tests.rs b/crates/tui/src/tui/history/tests.rs index a85ca4d3f3..0e93dc4800 100644 --- a/crates/tui/src/tui/history/tests.rs +++ b/crates/tui/src/tui/history/tests.rs @@ -1175,7 +1175,7 @@ fn a_card_verb_agrees_with_its_own_label_in_every_locale() { }], }; - let header_en = line_text(&cell.lines_with_motion_and_locale(80, true, Locale::En)[0]); + let header_en = line_text(&cell.lines_with_motion(80, true)[0]); assert!( header_en.contains(expected_en), "{label:?} should read {expected_en:?}: {header_en:?}" diff --git a/crates/tui/src/tui/history/thinking.rs b/crates/tui/src/tui/history/thinking.rs index ffffaba844..ccd782d17c 100644 --- a/crates/tui/src/tui/history/thinking.rs +++ b/crates/tui/src/tui/history/thinking.rs @@ -30,7 +30,7 @@ enum ThinkingVisualState { Idle, } -#[allow(dead_code)] // Kept for compatibility/tests; live view uses explicit summaries only. +#[cfg(test)] #[must_use] pub fn extract_reasoning_summary(text: &str) -> Option { extract_explicit_reasoning_summary(text).or_else(|| { diff --git a/crates/tui/src/tui/whales.rs b/crates/tui/src/tui/whales.rs index 7d75aba94b..3d700d8a9e 100644 --- a/crates/tui/src/tui/whales.rs +++ b/crates/tui/src/tui/whales.rs @@ -73,7 +73,7 @@ pub enum WhaleSpecies { impl WhaleSpecies { /// Every species, for exhaustive checks and the test gallery. - #[allow(dead_code)] + #[cfg(test)] pub const ALL: [WhaleSpecies; 7] = [ Self::Scout, Self::Patch, @@ -190,7 +190,7 @@ pub enum WhaleState { impl WhaleState { /// Every state, for exhaustive checks and the test gallery. - #[allow(dead_code)] + #[cfg(test)] pub const ALL: [WhaleState; 6] = [ Self::Resting, Self::Thinking, @@ -202,7 +202,7 @@ impl WhaleState { /// CWC state priority; higher wins when several facts apply. Public /// contract for surfaces that fold several children into one whale. - #[allow(dead_code)] + #[cfg(test)] #[must_use] pub const fn priority(self) -> u8 { match self { @@ -633,7 +633,7 @@ pub fn portrait( /// The portrait narrowed through the glyph charter's ASCII fallback — what an /// `CODEWHALE_ASCII_SAFE=1` terminal draws. Pure text, for tests and /// text-only surfaces. -#[allow(dead_code)] // test/text-surface API; the draw path narrows per cell +#[cfg(test)] #[must_use] pub fn portrait_ascii( species: WhaleSpecies, @@ -656,7 +656,7 @@ pub fn portrait_ascii( } /// The portrait as plain Unicode rows (no color), for tests and snapshots. -#[allow(dead_code)] // test/snapshot API +#[cfg(test)] #[must_use] pub fn portrait_text( species: WhaleSpecies, @@ -695,7 +695,7 @@ pub fn badge(species: WhaleSpecies, theme: &UiTheme) -> Vec> { /// Badge followed by the state word (glyph + word: never color alone). The /// word takes the state's tone; when `state` is `None` only the badge renders. -#[allow(dead_code)] // frame-less convenience for static surfaces +#[cfg(test)] #[must_use] pub fn badge_with_state( species: WhaleSpecies, @@ -753,7 +753,7 @@ fn state_cue( } /// The badge as ASCII text (`<#`, `#]`, ...), for tests and text surfaces. -#[allow(dead_code)] // test/text-surface API +#[cfg(test)] #[must_use] pub fn badge_ascii(species: WhaleSpecies) -> String { let (feature, body, feature_first) = species.badge_glyphs(); @@ -859,6 +859,14 @@ mod tests { } } + #[test] + fn state_priority_orders_attention_before_work() { + assert!( + WhaleState::Waiting.priority() > WhaleState::Working.priority() + && WhaleState::Working.priority() > WhaleState::Resting.priority() + ); + } + #[test] fn authored_art_rows_and_ink_maps_agree() { for species in WhaleSpecies::ALL { From b051ee7a3737649374620f3a3fde771ce8414b58 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Thu, 27 Aug 2026 14:58:30 -0700 Subject: [PATCH 12/14] feat(tui): quieter chrome, compatible hosts, and delete staged runtime_contract Hosted Chat Completions backends (Baseten, Groq, Cerebras) land as Compatible setup templates, not ProviderKind variants. MCP manager copy names the server, the failure, and one recovery command. Idle metrics omit zeros. Working chrome says "in the current". Nine uncompiled runtime_contract files are gone. Dead-code budget 448 -> 372. --- .github/PULL_REQUEST_TEMPLATE.md | 1 + .github/workflows/ci.yml | 6 +- .gitignore | 7 + AGENTS.md | 4 + CHANGELOG.md | 12 + crates/config/src/lib.rs | 10 +- crates/config/src/provider_templates.rs | 106 +++++++- crates/tui/CHANGELOG.md | 12 + crates/tui/locales/en.json | 4 +- crates/tui/src/core/engine.rs | 2 +- crates/tui/src/core/mod.rs | 5 +- .../tui/src/core/runtime_contract/budget.rs | 115 -------- .../tui/src/core/runtime_contract/context.rs | 168 ------------ .../tui/src/core/runtime_contract/ledger.rs | 200 -------------- .../tui/src/core/runtime_contract/manifest.rs | 246 ------------------ crates/tui/src/core/runtime_contract/mod.rs | 21 -- .../tui/src/core/runtime_contract/profile.rs | 174 ------------- .../tui/src/core/runtime_contract/progress.rs | 232 ----------------- crates/tui/src/core/runtime_contract/retry.rs | 109 -------- .../tui/src/core/runtime_contract/terminal.rs | 156 ----------- crates/tui/src/core/runtime_contract/work.rs | 85 ------ crates/tui/src/mcp.rs | 21 ++ crates/tui/src/mcp/tests.rs | 8 + crates/tui/src/tui/footer_ui.rs | 3 +- crates/tui/src/tui/mcp_routing.rs | 4 + crates/tui/src/tui/notifications.rs | 22 +- crates/tui/src/tui/session_metrics.rs | 98 ++++--- crates/tui/src/tui/underwater.rs | 4 +- crates/tui/src/work_graph/mod.rs | 6 +- docs/design/TUI_DECONSTRUCTION.md | 91 +++++++ scripts/dead-code-budget.json | 5 +- 31 files changed, 360 insertions(+), 1577 deletions(-) delete mode 100644 crates/tui/src/core/runtime_contract/budget.rs delete mode 100644 crates/tui/src/core/runtime_contract/context.rs delete mode 100644 crates/tui/src/core/runtime_contract/ledger.rs delete mode 100644 crates/tui/src/core/runtime_contract/manifest.rs delete mode 100644 crates/tui/src/core/runtime_contract/mod.rs delete mode 100644 crates/tui/src/core/runtime_contract/profile.rs delete mode 100644 crates/tui/src/core/runtime_contract/progress.rs delete mode 100644 crates/tui/src/core/runtime_contract/retry.rs delete mode 100644 crates/tui/src/core/runtime_contract/terminal.rs delete mode 100644 crates/tui/src/core/runtime_contract/work.rs create mode 100644 docs/design/TUI_DECONSTRUCTION.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 75b67390a1..cb23939976 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -11,6 +11,7 @@ ## Checklist +- [ ] This PR adds a new layer/module/abstraction — it names or deletes the layer it replaces - [ ] Updated docs or comments as needed - [ ] Added or updated tests where relevant - [ ] Verified TUI behavior manually if UI changes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5af11297e9..a49d0ddb1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,7 +131,7 @@ jobs: ;; esac case "${path}" in - crates/workflow/*|workflows/rlm_cache_change.star|.github/workflows/ci.yml) + crates/workflow/*|.github/workflows/ci.yml) workflow=true ;; esac @@ -484,8 +484,8 @@ jobs: with: cache-bin: false save-if: ${{ github.ref == 'refs/heads/main' }} - - name: Run RLM cache workflow mock/replay tests - run: cargo test -p codewhale-workflow --locked rlm_cache_change + - name: Run workflow crate tests + run: cargo test -p codewhale-workflow --locked test: name: Test diff --git a/.gitignore b/.gitignore index 7fa418c260..89872540c9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,12 @@ # Build artifacts /target +/target-* +.entire/ +.cursor/ +.plans/ +.codewhale-worktrees/ +docs/superpowers/ +codewhale-inference/ /extensions/vscode/out/ *.pdb *.exe diff --git a/AGENTS.md b/AGENTS.md index fa9674e83a..059299ffcf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,10 @@ instructions or memory. The nearest scoped `AGENTS.md` adds path-specific rules. - Inspect status and existing consumers before editing. Preserve unrelated, dirty, and untracked work. +- Before adding a module named `model_*`, `*_config`, `provider_*`, or + anything that "bridges", "mirrors", or "stages" an existing thing, grep + for the existing thing and edit it. A new layer must name the predecessor + it replaces in the module doc; otherwise edit the original. - Prefer the simplest implementation that preserves observable contracts. A rewrite is acceptable when justified by product intent and observed behavior, not as a shortcut around understanding existing code. diff --git a/CHANGELOG.md b/CHANGELOG.md index 82e923e8c0..cfec6ea42c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Baseten, Groq, and Cerebras are bundled OpenAI-compatible setup templates + (`[providers.] kind = "openai-compatible"`), not new `ProviderKind` + variants. `/provider` fills URL, model, and env from one catalog row. +- MCP manager copy now names the server, the failure, and one recovery + command (`The X MCP server requires OAuth reauthentication. Run /mcp login X`). + - Added `/import-claude` (#5557): reads `~/.claude.json` and `~/.claude/settings.json` read-only and renders an explicit, reviewable migration plan plus a written report. MCP servers route through the @@ -72,6 +78,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Idle session metrics omit zero facts (`0 turns`, `LLM 0s`) until the + runtime has evidence. Working chrome says `in the current` instead of a + generic `working`. +- Deleted nine uncompiled `runtime_contract/` staging files. Live contracts + remain `model.rs` and `termination.rs`. + - The first #5587 dead-code sweep converts audited test-only helpers to `#[cfg(test)]`, keeping production builds free of test-only APIs without changing runtime behavior. diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 4ffd897539..c6384df9ea 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -30,9 +30,13 @@ pub use model_reference::{Modality, ModelReferenceCard, ModelReferenceDatabase}; pub(crate) use provider_defaults::*; pub use provider_kind::ProviderKind; pub use provider_templates::{ - AGNES_TEMPLATE_ID, ProviderSetupApply, ProviderSetupTemplate, SENSENOVA_API_KEY_ENV, - SENSENOVA_BASE_URL, SENSENOVA_DEFAULT_MODEL, SENSENOVA_MODELS, SENSENOVA_TEMPLATE_ID, - compatible_provider_setup_templates, provider_setup_template, provider_setup_templates, + AGNES_TEMPLATE_ID, BASETEN_API_KEY_ENV, BASETEN_BASE_URL, BASETEN_DEFAULT_MODEL, + BASETEN_MODELS, BASETEN_TEMPLATE_ID, CEREBRAS_API_KEY_ENV, CEREBRAS_BASE_URL, + CEREBRAS_DEFAULT_MODEL, CEREBRAS_MODELS, CEREBRAS_TEMPLATE_ID, GROQ_API_KEY_ENV, GROQ_BASE_URL, + GROQ_DEFAULT_MODEL, GROQ_MODELS, GROQ_TEMPLATE_ID, ProviderSetupApply, ProviderSetupTemplate, + SENSENOVA_API_KEY_ENV, SENSENOVA_BASE_URL, SENSENOVA_DEFAULT_MODEL, SENSENOVA_MODELS, + SENSENOVA_TEMPLATE_ID, compatible_provider_setup_templates, provider_setup_template, + provider_setup_templates, }; pub use setup_state::{ ConstitutionAuthoring, ConstitutionChoice, ConstitutionSource, ConstitutionValidity, diff --git a/crates/config/src/provider_templates.rs b/crates/config/src/provider_templates.rs index 0fd97e47e5..1a59ffc10c 100644 --- a/crates/config/src/provider_templates.rs +++ b/crates/config/src/provider_templates.rs @@ -6,7 +6,10 @@ //! - first-class gateways users still treat as "paste a Base URL" //! (OpenCode Zen / Go), and //! - named OpenAI-compatible custom routes that are not `ProviderKind` -//! variants (SenseNova). +//! variants (SenseNova, Baseten, Groq, Cerebras). Hosted Chat Completions +//! backends are data rows here — not new enum variants. Distinct *wires* +//! (Anthropic Messages, Codex Responses, Google thought signatures) stay +//! on `ProviderKind`. //! //! Values here are limited to hosts, models, and env names already //! documented in this repository. Agnes is catalogued as unpublished so @@ -31,6 +34,33 @@ pub const SENSENOVA_MODELS: &[&str] = &[SENSENOVA_DEFAULT_MODEL]; /// host in this repository. pub const AGNES_TEMPLATE_ID: &str = "agnes"; +/// Baseten Model APIs — OpenAI Chat Completions, discovered at `/v1/models`. +pub const BASETEN_TEMPLATE_ID: &str = "baseten"; +pub const BASETEN_BASE_URL: &str = "https://inference.baseten.co/v1"; +pub const BASETEN_DEFAULT_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro"; +pub const BASETEN_API_KEY_ENV: &str = "BASETEN_API_KEY"; +pub const BASETEN_MODELS: &[&str] = &[ + BASETEN_DEFAULT_MODEL, + "deepseek-ai/DeepSeek-V4-Flash-0731", + "deepseek-ai/DeepSeek-V4-Pro-0813", + "zai-org/GLM-5.2", + "moonshotai/Kimi-K2.7-Code", +]; + +/// Groq — OpenAI Chat Completions hosted inference. +pub const GROQ_TEMPLATE_ID: &str = "groq"; +pub const GROQ_BASE_URL: &str = "https://api.groq.com/openai/v1"; +pub const GROQ_DEFAULT_MODEL: &str = "llama-3.3-70b-versatile"; +pub const GROQ_API_KEY_ENV: &str = "GROQ_API_KEY"; +pub const GROQ_MODELS: &[&str] = &[GROQ_DEFAULT_MODEL, "openai/gpt-oss-120b"]; + +/// Cerebras — OpenAI Chat Completions hosted inference. +pub const CEREBRAS_TEMPLATE_ID: &str = "cerebras"; +pub const CEREBRAS_BASE_URL: &str = "https://api.cerebras.ai/v1"; +pub const CEREBRAS_DEFAULT_MODEL: &str = "llama-3.3-70b"; +pub const CEREBRAS_API_KEY_ENV: &str = "CEREBRAS_API_KEY"; +pub const CEREBRAS_MODELS: &[&str] = &[CEREBRAS_DEFAULT_MODEL]; + /// How a beginner template is applied. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProviderSetupApply { @@ -223,6 +253,42 @@ const TEMPLATES: &[ProviderSetupTemplate] = &[ credential_url: None, guidance: "OpenAI-compatible SenseTime SenseNova host. Store an env var name, not a raw key.", }, + ProviderSetupTemplate { + id: BASETEN_TEMPLATE_ID, + display_name: "Baseten", + apply: ProviderSetupApply::Compatible, + base_url: Some(BASETEN_BASE_URL), + default_model: Some(BASETEN_DEFAULT_MODEL), + models: BASETEN_MODELS, + api_key_env: Some(BASETEN_API_KEY_ENV), + docs_url: Some("https://docs.baseten.co/inference/model-apis/overview"), + credential_url: Some("https://app.baseten.co/settings/api_keys"), + guidance: "Baseten Model APIs. OpenAI Chat Completions at inference.baseten.co. Store BASETEN_API_KEY, not a raw key.", + }, + ProviderSetupTemplate { + id: GROQ_TEMPLATE_ID, + display_name: "Groq", + apply: ProviderSetupApply::Compatible, + base_url: Some(GROQ_BASE_URL), + default_model: Some(GROQ_DEFAULT_MODEL), + models: GROQ_MODELS, + api_key_env: Some(GROQ_API_KEY_ENV), + docs_url: Some("https://console.groq.com/docs/quickstart"), + credential_url: Some("https://console.groq.com/keys"), + guidance: "Groq hosted inference. OpenAI Chat Completions. Store GROQ_API_KEY, not a raw key.", + }, + ProviderSetupTemplate { + id: CEREBRAS_TEMPLATE_ID, + display_name: "Cerebras", + apply: ProviderSetupApply::Compatible, + base_url: Some(CEREBRAS_BASE_URL), + default_model: Some(CEREBRAS_DEFAULT_MODEL), + models: CEREBRAS_MODELS, + api_key_env: Some(CEREBRAS_API_KEY_ENV), + docs_url: Some("https://inference-docs.cerebras.ai/quickstart"), + credential_url: Some("https://cloud.cerebras.ai"), + guidance: "Cerebras hosted inference. OpenAI Chat Completions. Store CEREBRAS_API_KEY, not a raw key.", + }, ProviderSetupTemplate { id: AGNES_TEMPLATE_ID, display_name: "Agnes", @@ -266,6 +332,7 @@ pub fn provider_setup_template(id: &str) -> Option<&'static ProviderSetupTemplat "sense-nova" | "meituan-sensenova" | "meituan-sensenova-cn" => { template.id == SENSENOVA_TEMPLATE_ID } + "base-ten" | "base_ten" => template.id == BASETEN_TEMPLATE_ID, _ => false, }) }) @@ -387,7 +454,7 @@ mod tests { fn settings_value_names_fillable_then_unpublished() { assert_eq!( ProviderSetupTemplate::settings_value(), - "opencode-zen, opencode-go, sensenova; agnes unpublished" + "opencode-zen, opencode-go, sensenova, baseten, groq, cerebras; agnes unpublished" ); } @@ -402,4 +469,39 @@ mod tests { Some("opencode-zen") ); } + + #[test] + fn hosted_openai_compat_hosts_are_templates_not_enum_variants() { + for (alias, id, url, env) in [ + ( + "baseten", + BASETEN_TEMPLATE_ID, + BASETEN_BASE_URL, + BASETEN_API_KEY_ENV, + ), + ( + "base-ten", + BASETEN_TEMPLATE_ID, + BASETEN_BASE_URL, + BASETEN_API_KEY_ENV, + ), + ("groq", GROQ_TEMPLATE_ID, GROQ_BASE_URL, GROQ_API_KEY_ENV), + ( + "cerebras", + CEREBRAS_TEMPLATE_ID, + CEREBRAS_BASE_URL, + CEREBRAS_API_KEY_ENV, + ), + ] { + let template = provider_setup_template(alias).unwrap_or_else(|| panic!("{alias}")); + assert_eq!(template.id, id); + assert!(template.is_compatible(), "{alias}"); + assert_eq!(template.base_url(), Some(url)); + assert_eq!(template.api_key_env(), Some(env)); + assert!( + ProviderKind::parse(id).is_none(), + "{id} must not be a ProviderKind" + ); + } + } } diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index e0531d94b3..aa5dbd220c 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Baseten, Groq, and Cerebras are bundled OpenAI-compatible setup templates + (`[providers.] kind = "openai-compatible"`), not new `ProviderKind` + variants. `/provider` fills URL, model, and env from one catalog row. +- MCP manager copy now names the server, the failure, and one recovery + command (`The X MCP server requires OAuth reauthentication. Run /mcp login X`). + - Added `/import-claude` (#5557): reads `~/.claude.json` and `~/.claude/settings.json` read-only and renders an explicit, reviewable migration plan plus a written report. MCP servers route through the @@ -72,6 +78,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Idle session metrics omit zero facts (`0 turns`, `LLM 0s`) until the + runtime has evidence. Working chrome says `in the current` instead of a + generic `working`. +- Deleted nine uncompiled `runtime_contract/` staging files. Live contracts + remain `model.rs` and `termination.rs`. + - The first #5587 dead-code sweep converts audited test-only helpers to `#[cfg(test)]`, keeping production builds free of test-only APIs without changing runtime behavior. diff --git a/crates/tui/locales/en.json b/crates/tui/locales/en.json index 19f1f8a751..c7c151305e 100644 --- a/crates/tui/locales/en.json +++ b/crates/tui/locales/en.json @@ -1205,11 +1205,11 @@ "LaunchNoSavedSessions": "No saved sessions for this workspace.", "PhaseIdle": "idle", "PhaseDraft": "draft", - "PhaseWorking": "working", + "PhaseWorking": "in the current", "PhaseReasoning": "reasoning", "PhaseReading": "reading", "PhaseUsingTool": "using tool", - "PhaseSubagents": "working on subagents", + "PhaseSubagents": "pod underway", "PhaseVerifying": "verifying", "PhaseWaitingOnYou": "waiting on you", "PhaseDone": "done", diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 82d2cc3232..a2987d8786 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -3169,7 +3169,7 @@ impl Engine { .sum(); let own = current_text.len() / 4; let mut inflated_delta = own * 3 / 2; - if sum % 2 == 0 && own % 2 == 1 { + if sum.is_multiple_of(2) && own % 2 == 1 { inflated_delta += 1; } base.saturating_add(inflated_delta).saturating_add(12) diff --git a/crates/tui/src/core/mod.rs b/crates/tui/src/core/mod.rs index 1b839ecaf4..01ed9027a3 100644 --- a/crates/tui/src/core/mod.rs +++ b/crates/tui/src/core/mod.rs @@ -28,12 +28,9 @@ pub mod events; #[path = "runtime_contract/model.rs"] pub mod model_client; pub mod ops; +pub mod session; #[path = "runtime_contract/termination.rs"] pub mod termination; -// The rest of `runtime_contract/` stays on disk as staged Core-runtime -// scaffolding and remains deliberately uncompiled until it has production -// consumers (TUI-DOG-017). -pub mod session; // Moved to `codewhale_core::tool_parser` (zero crate-internal dependencies); // re-exported so `crate::core::tool_parser` keeps working. pub use codewhale_core::tool_parser; diff --git a/crates/tui/src/core/runtime_contract/budget.rs b/crates/tui/src/core/runtime_contract/budget.rs deleted file mode 100644 index d109f92b99..0000000000 --- a/crates/tui/src/core/runtime_contract/budget.rs +++ /dev/null @@ -1,115 +0,0 @@ -use std::time::Duration; - -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct ExecutionBudget { - pub max_steps: u32, - pub max_tool_calls: u32, - pub max_retries: u32, - pub max_wall_time_ms: u64, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct BudgetUsage { - pub steps: u32, - pub tool_calls: u32, - pub retries: u32, - pub elapsed_ms: u64, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum BudgetExhaustion { - Steps, - ToolCalls, - Retries, - WallTime, -} - -impl ExecutionBudget { - pub fn validate(self) -> Result<(), String> { - if self.max_steps == 0 { - return Err("execution budget max_steps must be greater than zero".to_string()); - } - if self.max_tool_calls == 0 { - return Err("execution budget max_tool_calls must be greater than zero".to_string()); - } - if self.max_wall_time_ms == 0 { - return Err("execution budget max_wall_time_ms must be greater than zero".to_string()); - } - Ok(()) - } - - #[must_use] - pub const fn wall_time(self) -> Duration { - Duration::from_millis(self.max_wall_time_ms) - } - - #[must_use] - pub fn exhausted_by(self, usage: BudgetUsage) -> Option { - if usage.steps >= self.max_steps { - Some(BudgetExhaustion::Steps) - } else if usage.tool_calls >= self.max_tool_calls { - Some(BudgetExhaustion::ToolCalls) - } else if usage.retries > self.max_retries { - Some(BudgetExhaustion::Retries) - } else if usage.elapsed_ms >= self.max_wall_time_ms { - Some(BudgetExhaustion::WallTime) - } else { - None - } - } - - /// Child work may narrow a parent budget but can never widen it. - #[must_use] - pub fn child_budget(self, requested: Self) -> Self { - Self { - max_steps: self.max_steps.min(requested.max_steps), - max_tool_calls: self.max_tool_calls.min(requested.max_tool_calls), - max_retries: self.max_retries.min(requested.max_retries), - max_wall_time_ms: self.max_wall_time_ms.min(requested.max_wall_time_ms), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn child_cannot_widen_parent_budget() { - let parent = ExecutionBudget { - max_steps: 32, - max_tool_calls: 64, - max_retries: 3, - max_wall_time_ms: 60_000, - }; - let child = parent.child_budget(ExecutionBudget { - max_steps: u32::MAX, - max_tool_calls: u32::MAX, - max_retries: u32::MAX, - max_wall_time_ms: u64::MAX, - }); - assert_eq!(child, parent); - } - - #[test] - fn wall_time_has_a_distinct_exhaustion_reason() { - let budget = ExecutionBudget { - max_steps: 100, - max_tool_calls: 100, - max_retries: 3, - max_wall_time_ms: 10, - }; - assert_eq!( - budget.exhausted_by(BudgetUsage { - steps: 1, - tool_calls: 1, - retries: 0, - elapsed_ms: 10, - }), - Some(BudgetExhaustion::WallTime) - ); - } -} diff --git a/crates/tui/src/core/runtime_contract/context.rs b/crates/tui/src/core/runtime_contract/context.rs deleted file mode 100644 index 4b096f2483..0000000000 --- a/crates/tui/src/core/runtime_contract/context.rs +++ /dev/null @@ -1,168 +0,0 @@ -use std::collections::BTreeSet; -use std::path::PathBuf; - -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ContextSourceKind { - Constitution, - RepositoryLaw, - ScopedRepositoryLaw, - Instruction, - Skill, - Hook, - Mcp, - Memory, - ModelProfile, - CapabilityProfile, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ContextPriority { - Required, - High, - Normal, - Optional, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ContextSourceReceipt { - pub id: String, - pub kind: ContextSourceKind, - pub priority: ContextPriority, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub path: Option, - pub bytes: u64, - pub estimated_tokens: u64, - pub content_hash: String, - pub included: bool, - pub reason: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ResourcesLoadedEvent { - pub event_id: String, - pub sources: Vec, - pub assembly_ms: u64, - pub prompt_tokens: u64, - pub schema_tokens: u64, -} - -impl ResourcesLoadedEvent { - #[must_use] - pub fn included_tokens(&self) -> u64 { - self.sources - .iter() - .filter(|source| source.included) - .map(|source| source.estimated_tokens) - .sum() - } - - pub fn validate_required_sources(&self) -> Result<(), Vec> { - let missing = self - .sources - .iter() - .filter(|source| source.priority == ContextPriority::Required && !source.included) - .map(|source| source.id.clone()) - .collect::>(); - if missing.is_empty() { - Ok(()) - } else { - Err(missing) - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ContinuitySet { - #[serde(default)] - pub constraints: BTreeSet, - #[serde(default)] - pub approvals: BTreeSet, - #[serde(default)] - pub failed_checks: BTreeSet, - #[serde(default)] - pub edited_paths: BTreeSet, - #[serde(default)] - pub pending_work: BTreeSet, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct CondensationEvent { - pub event_id: String, - pub source_start_event_id: String, - pub source_end_event_id: String, - pub summary_hash: String, - pub provider: String, - pub model: String, - pub reason: String, - pub continuity: ContinuitySet, - pub input_tokens: u64, - pub output_tokens: u64, -} - -impl CondensationEvent { - pub fn validate(&self) -> Result<(), String> { - for (label, value) in [ - ("event_id", self.event_id.as_str()), - ("source_start_event_id", self.source_start_event_id.as_str()), - ("source_end_event_id", self.source_end_event_id.as_str()), - ("summary_hash", self.summary_hash.as_str()), - ("provider", self.provider.as_str()), - ("model", self.model.as_str()), - ("reason", self.reason.as_str()), - ] { - if value.trim().is_empty() { - return Err(format!("condensation {label} cannot be empty")); - } - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn required_law_cannot_be_silently_dropped() { - let event = ResourcesLoadedEvent { - event_id: "resources-1".to_string(), - sources: vec![ContextSourceReceipt { - id: "AGENTS.md".to_string(), - kind: ContextSourceKind::RepositoryLaw, - priority: ContextPriority::Required, - path: Some(PathBuf::from("AGENTS.md")), - bytes: 100, - estimated_tokens: 25, - content_hash: "hash".to_string(), - included: false, - reason: "budget".to_string(), - }], - assembly_ms: 1, - prompt_tokens: 0, - schema_tokens: 0, - }; - assert_eq!( - event.validate_required_sources().unwrap_err(), - vec!["AGENTS.md"] - ); - } - - #[test] - fn condensation_preserves_distinct_continuity_domains() { - let continuity = ContinuitySet { - constraints: BTreeSet::from(["do not deploy".to_string()]), - approvals: BTreeSet::from(["edit src only".to_string()]), - failed_checks: BTreeSet::from(["cargo test".to_string()]), - edited_paths: BTreeSet::from([PathBuf::from("src/lib.rs")]), - pending_work: BTreeSet::from(["rerun test".to_string()]), - }; - let json = serde_json::to_value(&continuity).unwrap(); - assert_eq!(json["constraints"][0], "do not deploy"); - assert_eq!(json["failed_checks"][0], "cargo test"); - assert_eq!(json["pending_work"][0], "rerun test"); - } -} diff --git a/crates/tui/src/core/runtime_contract/ledger.rs b/crates/tui/src/core/runtime_contract/ledger.rs deleted file mode 100644 index 70e5782cfc..0000000000 --- a/crates/tui/src/core/runtime_contract/ledger.rs +++ /dev/null @@ -1,200 +0,0 @@ -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use sha2::{Digest, Sha256}; - -use super::RUNTIME_CONTRACT_SCHEMA_VERSION; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RuntimeEventKind { - UserMessage, - AssistantMessage, - ToolStarted, - ToolCompleted, - ApprovalRequested, - ApprovalResolved, - SteeringQueued, - SteeringDelivered, - ResourcesLoaded, - Condensation, - Work, - Child, - Usage, - Retry, - Termination, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RuntimeEventEnvelope { - pub schema_version: u32, - pub sequence: u64, - pub event_id: String, - pub kind: RuntimeEventKind, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub parent_event_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub causal_event_id: Option, - pub recorded_at_ms: u64, - pub payload: Value, - pub checksum: String, -} - -impl RuntimeEventEnvelope { - #[must_use] - pub fn new( - sequence: u64, - event_id: impl Into, - kind: RuntimeEventKind, - recorded_at_ms: u64, - payload: Value, - ) -> Self { - let mut event = Self { - schema_version: RUNTIME_CONTRACT_SCHEMA_VERSION, - sequence, - event_id: event_id.into(), - kind, - parent_event_id: None, - causal_event_id: None, - recorded_at_ms, - payload, - checksum: String::new(), - }; - event.checksum = event.expected_checksum(); - event - } - - #[must_use] - pub fn expected_checksum(&self) -> String { - let canonical = serde_json::json!({ - "schema_version": self.schema_version, - "sequence": self.sequence, - "event_id": self.event_id, - "kind": self.kind, - "parent_event_id": self.parent_event_id, - "causal_event_id": self.causal_event_id, - "recorded_at_ms": self.recorded_at_ms, - "payload": self.payload, - }); - let bytes = serde_json::to_vec(&canonical).expect("runtime event JSON is serializable"); - let digest = Sha256::digest(bytes); - digest.iter().map(|byte| format!("{byte:02x}")).collect() - } - - pub fn validate(&self) -> Result<(), String> { - if self.schema_version != RUNTIME_CONTRACT_SCHEMA_VERSION { - return Err(format!( - "unsupported runtime event schema {}", - self.schema_version - )); - } - if self.event_id.trim().is_empty() { - return Err("runtime event ID cannot be empty".to_string()); - } - let expected = self.expected_checksum(); - if self.checksum != expected { - return Err(format!("runtime event {} checksum mismatch", self.event_id)); - } - Ok(()) - } -} - -#[derive(Debug, Default, Clone)] -pub struct AppendOnlyRuntimeLedger { - events: Vec, -} - -impl AppendOnlyRuntimeLedger { - pub fn append(&mut self, event: RuntimeEventEnvelope) -> Result<(), String> { - event.validate()?; - let expected_sequence = self.events.last().map_or(0, |last| last.sequence + 1); - if event.sequence != expected_sequence { - return Err(format!( - "runtime event sequence {} does not follow {}", - event.sequence, expected_sequence - )); - } - if self - .events - .iter() - .any(|item| item.event_id == event.event_id) - { - return Err(format!("duplicate runtime event ID `{}`", event.event_id)); - } - self.events.push(event); - Ok(()) - } - - #[must_use] - pub fn events(&self) -> &[RuntimeEventEnvelope] { - &self.events - } - - #[must_use] - pub fn range(&self, start: u64, end_inclusive: u64) -> Vec<&RuntimeEventEnvelope> { - self.events - .iter() - .filter(|event| (start..=end_inclusive).contains(&event.sequence)) - .collect() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn ledger_rejects_corruption_and_sequence_gaps() { - let mut ledger = AppendOnlyRuntimeLedger::default(); - ledger - .append(RuntimeEventEnvelope::new( - 0, - "event-0", - RuntimeEventKind::UserMessage, - 1, - serde_json::json!({"text": "hello"}), - )) - .unwrap(); - - let gap = RuntimeEventEnvelope::new( - 2, - "event-2", - RuntimeEventKind::Termination, - 2, - serde_json::json!({}), - ); - assert!(ledger.append(gap).unwrap_err().contains("does not follow")); - - let mut corrupt = RuntimeEventEnvelope::new( - 1, - "event-1", - RuntimeEventKind::ToolCompleted, - 2, - serde_json::json!({"ok": true}), - ); - corrupt.payload = serde_json::json!({"ok": false}); - assert!( - corrupt - .validate() - .unwrap_err() - .contains("checksum mismatch") - ); - } - - #[test] - fn derived_range_does_not_remove_original_events() { - let mut ledger = AppendOnlyRuntimeLedger::default(); - for sequence in 0..3 { - ledger - .append(RuntimeEventEnvelope::new( - sequence, - format!("event-{sequence}"), - RuntimeEventKind::AssistantMessage, - sequence, - serde_json::json!({"sequence": sequence}), - )) - .unwrap(); - } - assert_eq!(ledger.range(1, 2).len(), 2); - assert_eq!(ledger.events().len(), 3); - } -} diff --git a/crates/tui/src/core/runtime_contract/manifest.rs b/crates/tui/src/core/runtime_contract/manifest.rs deleted file mode 100644 index 13a5e38f16..0000000000 --- a/crates/tui/src/core/runtime_contract/manifest.rs +++ /dev/null @@ -1,246 +0,0 @@ -use std::collections::BTreeMap; -use std::path::PathBuf; - -use serde::{Deserialize, Serialize}; - -use super::{RUNTIME_CONTRACT_SCHEMA_VERSION, profile::ToolProfileManifest}; - -/// Reproducible runtime contract captured before an unattended or measured -/// run starts. Secret values never belong in this structure. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RunContractManifest { - pub schema_version: u32, - pub binary_version: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub source_sha: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub dirty_patch_hash: Option, - pub workspace: PathBuf, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub base_revision: Option, - pub provider: String, - pub model: String, - pub route: String, - pub permission_posture: String, - pub sandbox_identity: String, - pub network_policy: String, - pub prompt_hash: String, - pub profile: ToolProfileManifest, - /// Stable hash by tool name. A resume must not silently continue with a - /// different model-visible schema. - pub tool_schema_hashes: BTreeMap, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ManifestMismatch { - pub field: String, - pub saved: String, - pub current: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ResumeCompatibility { - Compatible, - ExplicitMigrationRequired(Vec), -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RunReadiness { - pub ready: bool, - #[serde(default)] - pub blockers: Vec, - #[serde(default)] - pub warnings: Vec, -} - -impl RunContractManifest { - pub fn validate(&self) -> Result<(), String> { - if self.schema_version != RUNTIME_CONTRACT_SCHEMA_VERSION { - return Err(format!( - "unsupported run manifest schema {}; expected {}", - self.schema_version, RUNTIME_CONTRACT_SCHEMA_VERSION - )); - } - self.profile.validate()?; - for (label, value) in [ - ("binary_version", self.binary_version.as_str()), - ("provider", self.provider.as_str()), - ("model", self.model.as_str()), - ("route", self.route.as_str()), - ("permission_posture", self.permission_posture.as_str()), - ("sandbox_identity", self.sandbox_identity.as_str()), - ("prompt_hash", self.prompt_hash.as_str()), - ] { - if value.trim().is_empty() { - return Err(format!("run manifest {label} cannot be empty")); - } - } - Ok(()) - } - - #[must_use] - pub fn readiness(&self) -> RunReadiness { - let mut blockers = Vec::new(); - let mut warnings = Vec::new(); - if let Err(error) = self.validate() { - blockers.push(error); - } - if self.source_sha.is_none() { - warnings.push("source SHA is not available".to_string()); - } - if self.dirty_patch_hash.is_none() { - warnings.push("dirty patch hash is not recorded".to_string()); - } - RunReadiness { - ready: blockers.is_empty(), - blockers, - warnings, - } - } - - #[must_use] - pub fn compare_for_resume(&self, current: &Self) -> ResumeCompatibility { - let mut mismatches = Vec::new(); - compare_field( - &mut mismatches, - "schema_version", - self.schema_version, - current.schema_version, - ); - compare_field( - &mut mismatches, - "provider", - &self.provider, - ¤t.provider, - ); - compare_field(&mut mismatches, "model", &self.model, ¤t.model); - compare_field(&mut mismatches, "route", &self.route, ¤t.route); - compare_field( - &mut mismatches, - "permission_posture", - &self.permission_posture, - ¤t.permission_posture, - ); - compare_field( - &mut mismatches, - "sandbox_identity", - &self.sandbox_identity, - ¤t.sandbox_identity, - ); - compare_field( - &mut mismatches, - "prompt_hash", - &self.prompt_hash, - ¤t.prompt_hash, - ); - compare_field( - &mut mismatches, - "profile", - format!("{:?}", self.profile), - format!("{:?}", current.profile), - ); - compare_field( - &mut mismatches, - "tool_schema_hashes", - format!("{:?}", self.tool_schema_hashes), - format!("{:?}", current.tool_schema_hashes), - ); - if mismatches.is_empty() { - ResumeCompatibility::Compatible - } else { - ResumeCompatibility::ExplicitMigrationRequired(mismatches) - } - } -} - -fn compare_field( - mismatches: &mut Vec, - field: &str, - saved: impl ToString, - current: impl ToString, -) { - let saved = saved.to_string(); - let current = current.to_string(); - if saved != current { - mismatches.push(ManifestMismatch { - field: field.to_string(), - saved, - current, - }); - } -} - -#[cfg(test)] -mod tests { - use std::collections::BTreeSet; - - use super::*; - use crate::core::runtime_contract::{ - profile::{AgentProfileCandidate, SemanticCapability, ToolActivationPolicy}, - terminal::TerminalProcessPolicy, - }; - - fn manifest() -> RunContractManifest { - let mut capabilities = BTreeSet::new(); - capabilities.insert(SemanticCapability::TypedTermination); - RunContractManifest { - schema_version: RUNTIME_CONTRACT_SCHEMA_VERSION, - binary_version: "0.8.68".to_string(), - source_sha: Some("abc".to_string()), - dirty_patch_hash: Some("patch".to_string()), - workspace: PathBuf::from("/workspace"), - base_revision: Some("base".to_string()), - provider: "example".to_string(), - model: "model".to_string(), - route: "api".to_string(), - permission_posture: "ask".to_string(), - sandbox_identity: "workspace_write".to_string(), - network_policy: "ask".to_string(), - prompt_hash: "prompt".to_string(), - profile: ToolProfileManifest { - schema_version: RUNTIME_CONTRACT_SCHEMA_VERSION, - candidate: AgentProfileCandidate::AdaptiveCore, - activation_policy: ToolActivationPolicy::DeferredSearch, - terminal_policy: TerminalProcessPolicy::Hybrid, - capabilities, - active_tools: BTreeSet::from(["tool_search".to_string()]), - deferred_tools: BTreeSet::from(["run_verifiers".to_string()]), - max_steps: 64, - max_wall_time_seconds: Some(900), - }, - tool_schema_hashes: BTreeMap::from([( - "tool_search".to_string(), - "schema-a".to_string(), - )]), - } - } - - #[test] - fn resume_fails_closed_on_tool_schema_drift() { - let saved = manifest(); - let mut current = saved.clone(); - current - .tool_schema_hashes - .insert("tool_search".to_string(), "schema-b".to_string()); - let ResumeCompatibility::ExplicitMigrationRequired(mismatches) = - saved.compare_for_resume(¤t) - else { - panic!("schema drift must require migration"); - }; - assert!( - mismatches - .iter() - .any(|mismatch| mismatch.field == "tool_schema_hashes") - ); - } - - #[test] - fn readiness_warns_without_source_identity_but_does_not_block() { - let mut value = manifest(); - value.source_sha = None; - let readiness = value.readiness(); - assert!(readiness.ready); - assert_eq!(readiness.warnings, vec!["source SHA is not available"]); - } -} diff --git a/crates/tui/src/core/runtime_contract/mod.rs b/crates/tui/src/core/runtime_contract/mod.rs deleted file mode 100644 index e4cf9e6d66..0000000000 --- a/crates/tui/src/core/runtime_contract/mod.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! Versioned contracts shared by interactive, headless, Fleet, and evaluation -//! adapters. -//! -//! This module is intentionally independent of rendering and provider clients. -//! It gives the Core-profile experiments a typed place to converge before any -//! candidate becomes the product default. - -pub mod budget; -pub mod context; -pub mod ledger; -pub mod manifest; -pub mod model; -pub mod profile; -pub mod progress; -pub mod retry; -pub mod terminal; -pub mod termination; -pub mod work; - -/// Schema shared by the initial Core runtime contracts. -pub const RUNTIME_CONTRACT_SCHEMA_VERSION: u32 = 1; diff --git a/crates/tui/src/core/runtime_contract/profile.rs b/crates/tui/src/core/runtime_contract/profile.rs deleted file mode 100644 index 7420d84708..0000000000 --- a/crates/tui/src/core/runtime_contract/profile.rs +++ /dev/null @@ -1,174 +0,0 @@ -use std::collections::BTreeSet; - -use serde::{Deserialize, Serialize}; - -use super::{RUNTIME_CONTRACT_SCHEMA_VERSION, terminal::TerminalProcessPolicy}; - -/// Candidate profiles are implementation experiments, not public Codewhale -/// modes. Plan/Act/Operate and permission posture remain independent axes. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum AgentProfileCandidate { - CurrentFull, - ConsolidatedCore, - SpecializedCore, - AdaptiveCore, -} - -/// Semantic abilities a profile promises regardless of model-facing tool -/// names. This lets paired trials compare combined and specialized schemas -/// without changing the task contract. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SemanticCapability { - CommandExecution, - FileRead, - FileSearch, - FileEdit, - ActiveChecklist, - TypedTermination, - Verification, - Delegation, - Network, - Mcp, - Knowledge, - Media, - Release, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ToolActivationPolicy { - Static, - DeferredSearch, - ExplicitCapability, - Adaptive, -} - -/// Exact tool/profile manifest supplied to a model for one run. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ToolProfileManifest { - pub schema_version: u32, - pub candidate: AgentProfileCandidate, - pub activation_policy: ToolActivationPolicy, - pub terminal_policy: TerminalProcessPolicy, - pub capabilities: BTreeSet, - pub active_tools: BTreeSet, - pub deferred_tools: BTreeSet, - pub max_steps: u32, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_wall_time_seconds: Option, -} - -impl ToolProfileManifest { - #[must_use] - pub fn new( - candidate: AgentProfileCandidate, - activation_policy: ToolActivationPolicy, - terminal_policy: TerminalProcessPolicy, - max_steps: u32, - ) -> Self { - Self { - schema_version: RUNTIME_CONTRACT_SCHEMA_VERSION, - candidate, - activation_policy, - terminal_policy, - capabilities: BTreeSet::new(), - active_tools: BTreeSet::new(), - deferred_tools: BTreeSet::new(), - max_steps, - max_wall_time_seconds: None, - } - } - - #[must_use] - pub fn with_capability(mut self, capability: SemanticCapability) -> Self { - self.capabilities.insert(capability); - self - } - - #[must_use] - pub fn with_active_tool(mut self, tool: impl Into) -> Self { - self.active_tools.insert(tool.into()); - self - } - - #[must_use] - pub fn with_deferred_tool(mut self, tool: impl Into) -> Self { - self.deferred_tools.insert(tool.into()); - self - } - - pub fn validate(&self) -> Result<(), String> { - if self.schema_version != RUNTIME_CONTRACT_SCHEMA_VERSION { - return Err(format!( - "unsupported runtime profile schema {}; expected {}", - self.schema_version, RUNTIME_CONTRACT_SCHEMA_VERSION - )); - } - if self.max_steps == 0 { - return Err("runtime profile max_steps must be greater than zero".to_string()); - } - if let Some(overlap) = self.active_tools.intersection(&self.deferred_tools).next() { - return Err(format!( - "tool `{overlap}` cannot be both active and deferred" - )); - } - if !self - .capabilities - .contains(&SemanticCapability::TypedTermination) - { - return Err("runtime profile must promise typed termination".to_string()); - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn candidate_profile_keeps_public_modes_out_of_the_contract() { - let json = serde_json::to_string(&AgentProfileCandidate::AdaptiveCore).unwrap(); - assert_eq!(json, "\"adaptive_core\""); - assert!(!json.contains("plan")); - assert!(!json.contains("operate")); - } - - #[test] - fn manifest_rejects_active_deferred_overlap() { - let manifest = ToolProfileManifest::new( - AgentProfileCandidate::AdaptiveCore, - ToolActivationPolicy::DeferredSearch, - TerminalProcessPolicy::Hybrid, - 64, - ) - .with_capability(SemanticCapability::TypedTermination) - .with_active_tool("tool_search") - .with_deferred_tool("tool_search"); - - assert!( - manifest - .validate() - .unwrap_err() - .contains("both active and deferred") - ); - } - - #[test] - fn manifest_requires_typed_termination() { - let manifest = ToolProfileManifest::new( - AgentProfileCandidate::SpecializedCore, - ToolActivationPolicy::Static, - TerminalProcessPolicy::Isolated, - 32, - ); - assert!( - manifest - .validate() - .unwrap_err() - .contains("typed termination") - ); - } -} diff --git a/crates/tui/src/core/runtime_contract/progress.rs b/crates/tui/src/core/runtime_contract/progress.rs deleted file mode 100644 index 82df57925e..0000000000 --- a/crates/tui/src/core/runtime_contract/progress.rs +++ /dev/null @@ -1,232 +0,0 @@ -use std::collections::VecDeque; - -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ActionFingerprint { - pub operation: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub target: Option, - pub input_digest: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub outcome_digest: Option, - pub succeeded: bool, - pub changed_state: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct ProgressGuardConfig { - pub identical_action_limit: usize, - pub alternating_cycle_limit: usize, - pub no_progress_limit: usize, - pub history_limit: usize, -} - -impl Default for ProgressGuardConfig { - fn default() -> Self { - Self { - identical_action_limit: 3, - alternating_cycle_limit: 3, - no_progress_limit: 6, - history_limit: 16, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum GuardDecision { - Continue, - Warn { reason: String }, - Stop { reason: String }, -} - -/// Structural no-progress detector. It consumes normalized fingerprints, not -/// provider prose, so the same policy can run in TUI, headless, and tests. -#[derive(Debug, Clone)] -pub struct ProgressGuard { - config: ProgressGuardConfig, - history: VecDeque, - warning_issued: bool, -} - -impl ProgressGuard { - #[must_use] - pub fn new(config: ProgressGuardConfig) -> Self { - Self { - config, - history: VecDeque::with_capacity(config.history_limit), - warning_issued: false, - } - } - - pub fn observe(&mut self, action: ActionFingerprint) -> GuardDecision { - if action.changed_state { - self.warning_issued = false; - } - self.history.push_back(action); - while self.history.len() > self.config.history_limit { - self.history.pop_front(); - } - - let reason = self - .identical_loop_reason() - .or_else(|| self.alternating_loop_reason()) - .or_else(|| self.no_progress_reason()); - let Some(reason) = reason else { - return GuardDecision::Continue; - }; - if self.warning_issued { - GuardDecision::Stop { reason } - } else { - self.warning_issued = true; - GuardDecision::Warn { reason } - } - } - - fn identical_loop_reason(&self) -> Option { - let limit = self.config.identical_action_limit; - if limit < 2 || self.history.len() < limit { - return None; - } - let recent = self.history.iter().rev().take(limit).collect::>(); - let first = recent.first()?; - recent - .iter() - .all(|action| same_action(first, action)) - .then(|| { - format!( - "repeated identical `{}` action without progress", - first.operation - ) - }) - } - - fn alternating_loop_reason(&self) -> Option { - let cycles = self.config.alternating_cycle_limit; - let needed = cycles.saturating_mul(2); - if cycles < 2 || self.history.len() < needed { - return None; - } - let recent = self.history.iter().rev().take(needed).collect::>(); - let a = recent.first()?; - let b = recent.get(1)?; - if same_action(a, b) { - return None; - } - recent - .iter() - .enumerate() - .all(|(index, action)| same_action(if index % 2 == 0 { a } else { b }, action)) - .then(|| { - format!( - "alternating `{}`/`{}` actions are cycling without progress", - a.operation, b.operation - ) - }) - } - - fn no_progress_reason(&self) -> Option { - let limit = self.config.no_progress_limit; - if limit == 0 || self.history.len() < limit { - return None; - } - self.history - .iter() - .rev() - .take(limit) - .all(|action| !action.changed_state) - .then(|| format!("{limit} consecutive actions produced no observable state change")) - } -} - -fn same_action(left: &ActionFingerprint, right: &ActionFingerprint) -> bool { - left.operation == right.operation - && left.target == right.target - && left.input_digest == right.input_digest - && left.outcome_digest == right.outcome_digest - && left.succeeded == right.succeeded - && left.changed_state == right.changed_state -} - -#[cfg(test)] -mod tests { - use super::*; - - fn action(operation: &str, changed_state: bool) -> ActionFingerprint { - ActionFingerprint { - operation: operation.to_string(), - target: Some("src/lib.rs".to_string()), - input_digest: operation.to_string(), - outcome_digest: Some("same".to_string()), - succeeded: false, - changed_state, - } - } - - #[test] - fn repeated_action_warns_then_stops() { - let mut guard = ProgressGuard::new(ProgressGuardConfig { - identical_action_limit: 3, - alternating_cycle_limit: 99, - no_progress_limit: 99, - history_limit: 16, - }); - assert_eq!( - guard.observe(action("search", false)), - GuardDecision::Continue - ); - assert_eq!( - guard.observe(action("search", false)), - GuardDecision::Continue - ); - assert!(matches!( - guard.observe(action("search", false)), - GuardDecision::Warn { .. } - )); - assert!(matches!( - guard.observe(action("search", false)), - GuardDecision::Stop { .. } - )); - } - - #[test] - fn real_progress_clears_warning_latch() { - let mut guard = ProgressGuard::new(ProgressGuardConfig { - identical_action_limit: 2, - alternating_cycle_limit: 99, - no_progress_limit: 99, - history_limit: 16, - }); - guard.observe(action("search", false)); - assert!(matches!( - guard.observe(action("search", false)), - GuardDecision::Warn { .. } - )); - assert_eq!(guard.observe(action("edit", true)), GuardDecision::Continue); - assert_eq!( - guard.observe(action("search", false)), - GuardDecision::Continue - ); - } - - #[test] - fn alternating_cycle_is_detected() { - let mut guard = ProgressGuard::new(ProgressGuardConfig { - identical_action_limit: 99, - alternating_cycle_limit: 3, - no_progress_limit: 99, - history_limit: 16, - }); - for operation in ["search", "read", "search", "read", "search"] { - assert_eq!( - guard.observe(action(operation, false)), - GuardDecision::Continue - ); - } - assert!(matches!( - guard.observe(action("read", false)), - GuardDecision::Warn { .. } - )); - } -} diff --git a/crates/tui/src/core/runtime_contract/retry.rs b/crates/tui/src/core/runtime_contract/retry.rs deleted file mode 100644 index 430ebd1880..0000000000 --- a/crates/tui/src/core/runtime_contract/retry.rs +++ /dev/null @@ -1,109 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum OperationClass { - ModelRequest, - ModelStream, - ToolTransport, - ToolExecution, - ContextCompaction, - Verification, - ChildRun, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum Idempotency { - ReadOnly, - IdempotentWrite, - NonIdempotentWrite, - Unknown, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RetryAttempt { - pub operation: OperationClass, - pub idempotency: Idempotency, - pub attempt: u32, - pub max_attempts: u32, - pub content_observed: bool, - pub side_effect_observed: bool, - pub canceled: bool, - pub reason: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RetryDecision { - Retry, - Stop { reason: String }, -} - -#[must_use] -pub fn decide_retry(attempt: &RetryAttempt) -> RetryDecision { - if attempt.canceled { - return RetryDecision::Stop { - reason: "operation was canceled".to_string(), - }; - } - if attempt.attempt >= attempt.max_attempts { - return RetryDecision::Stop { - reason: "retry budget exhausted".to_string(), - }; - } - if attempt.side_effect_observed - && matches!( - attempt.idempotency, - Idempotency::NonIdempotentWrite | Idempotency::Unknown - ) - { - return RetryDecision::Stop { - reason: "uncertain write side effect prevents an automatic retry".to_string(), - }; - } - if attempt.content_observed && attempt.operation == OperationClass::ModelStream { - return RetryDecision::Stop { - reason: "model stream already emitted content".to_string(), - }; - } - RetryDecision::Retry -} - -#[cfg(test)] -mod tests { - use super::*; - - fn attempt() -> RetryAttempt { - RetryAttempt { - operation: OperationClass::ModelStream, - idempotency: Idempotency::ReadOnly, - attempt: 0, - max_attempts: 2, - content_observed: false, - side_effect_observed: false, - canceled: false, - reason: "network".to_string(), - } - } - - #[test] - fn model_stream_retries_only_before_content() { - assert_eq!(decide_retry(&attempt()), RetryDecision::Retry); - let mut after_content = attempt(); - after_content.content_observed = true; - assert!(matches!( - decide_retry(&after_content), - RetryDecision::Stop { .. } - )); - } - - #[test] - fn uncertain_write_never_retries_after_side_effect() { - let mut write = attempt(); - write.operation = OperationClass::ToolExecution; - write.idempotency = Idempotency::Unknown; - write.side_effect_observed = true; - assert!(matches!(decide_retry(&write), RetryDecision::Stop { .. })); - } -} diff --git a/crates/tui/src/core/runtime_contract/terminal.rs b/crates/tui/src/core/runtime_contract/terminal.rs deleted file mode 100644 index b9b0d22aaf..0000000000 --- a/crates/tui/src/core/runtime_contract/terminal.rs +++ /dev/null @@ -1,156 +0,0 @@ -use std::collections::BTreeSet; -use std::path::PathBuf; - -use serde::{Deserialize, Serialize}; - -/// Process-continuity policy selected by a runtime profile. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TerminalProcessPolicy { - /// Every command starts from an explicit cwd/environment. - Isolated, - /// Commands share cwd/environment and may keep one live process. - Stateful, - /// Isolated by default; stateful sessions are explicitly requested. - Hybrid, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct TerminalSessionIdentity { - pub session_id: String, - pub host_fingerprint: String, - pub cwd: PathBuf, - /// Environment names only. Values are deliberately excluded from durable - /// metadata so secrets cannot leak into manifests. - pub environment_keys: BTreeSet, - pub shell: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TerminalSessionRecovery { - Reattached, - RestartRequired, - Stale, - Unsupported, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct TerminalBackendCapabilities { - pub interactive: bool, - pub background: bool, - pub tty: bool, - pub stateful: bool, - pub restart_reattach: bool, -} - -impl TerminalBackendCapabilities { - pub const LOCAL: Self = Self { - interactive: true, - background: true, - tty: true, - stateful: true, - restart_reattach: false, - }; -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct TerminalRequest { - pub policy: TerminalProcessPolicy, - pub interactive: bool, - pub background: bool, - pub tty: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub session_id: Option, -} - -/// Fail before spawning when a backend cannot honor the declared terminal -/// contract. This avoids silently degrading interactive/background work. -pub fn validate_terminal_request( - request: &TerminalRequest, - backend: TerminalBackendCapabilities, -) -> Result<(), String> { - if request.interactive && !backend.interactive { - return Err("terminal backend does not support interactive input".to_string()); - } - if request.background && !backend.background { - return Err("terminal backend does not support background processes".to_string()); - } - if request.tty && !backend.tty { - return Err("terminal backend does not support a TTY".to_string()); - } - if matches!(request.policy, TerminalProcessPolicy::Stateful) && !backend.stateful { - return Err("terminal backend does not support stateful sessions".to_string()); - } - if request.session_id.is_some() && matches!(request.policy, TerminalProcessPolicy::Isolated) { - return Err("isolated terminal requests cannot name a shared session".to_string()); - } - Ok(()) -} - -impl TerminalSessionIdentity { - /// A persisted session is safe to reattach only when both the logical ID - /// and host fingerprint match. PIDs alone are intentionally insufficient. - #[must_use] - pub fn recovery_on_host( - &self, - session_id: &str, - host_fingerprint: &str, - backend: TerminalBackendCapabilities, - ) -> TerminalSessionRecovery { - if !backend.stateful { - return TerminalSessionRecovery::Unsupported; - } - if self.session_id != session_id || self.host_fingerprint != host_fingerprint { - return TerminalSessionRecovery::Stale; - } - if backend.restart_reattach { - TerminalSessionRecovery::Reattached - } else { - TerminalSessionRecovery::RestartRequired - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn external_backend_fails_before_unsupported_interactive_spawn() { - let request = TerminalRequest { - policy: TerminalProcessPolicy::Stateful, - interactive: true, - background: false, - tty: false, - session_id: Some("term-1".to_string()), - }; - let backend = TerminalBackendCapabilities { - interactive: false, - background: false, - tty: false, - stateful: false, - restart_reattach: false, - }; - assert!( - validate_terminal_request(&request, backend) - .unwrap_err() - .contains("interactive") - ); - } - - #[test] - fn host_fingerprint_prevents_pid_style_false_reattach() { - let identity = TerminalSessionIdentity { - session_id: "term-1".to_string(), - host_fingerprint: "host-a".to_string(), - cwd: PathBuf::from("/workspace"), - environment_keys: BTreeSet::new(), - shell: "zsh".to_string(), - }; - assert_eq!( - identity.recovery_on_host("term-1", "host-b", TerminalBackendCapabilities::LOCAL), - TerminalSessionRecovery::Stale - ); - } -} diff --git a/crates/tui/src/core/runtime_contract/work.rs b/crates/tui/src/core/runtime_contract/work.rs deleted file mode 100644 index 98c623e29e..0000000000 --- a/crates/tui/src/core/runtime_contract/work.rs +++ /dev/null @@ -1,85 +0,0 @@ -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum WorkDomain { - /// Durable delegated/background unit. - Task, - /// Active checklist inside the current turn or lane. - Todo, - /// Strategy owned through Plan mode. - Plan, - /// Ordered durable execution. - Workflow, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum WorkLifecycle { - Pending, - Active, - Waiting, - Completed, - Failed, - Canceled, -} - -/// Common envelope only. The payload remains owned by its domain so Tasks, -/// To-do, Plan, and Workflow cannot collapse into a generic tracker. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct WorkEventEnvelope { - pub schema_version: u32, - pub event_id: String, - pub domain: WorkDomain, - pub object_id: String, - pub lifecycle: WorkLifecycle, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub parent_id: Option, - pub payload: Value, -} - -impl WorkEventEnvelope { - pub fn validate(&self) -> Result<(), String> { - if self.schema_version == 0 { - return Err("work event schema version cannot be zero".to_string()); - } - if self.event_id.trim().is_empty() || self.object_id.trim().is_empty() { - return Err("work event and object IDs cannot be empty".to_string()); - } - if !self.payload.is_object() { - return Err("work event payload must remain a typed object".to_string()); - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn domains_share_an_envelope_without_losing_meaning() { - let task = WorkEventEnvelope { - schema_version: 1, - event_id: "event-task".to_string(), - domain: WorkDomain::Task, - object_id: "task-1".to_string(), - lifecycle: WorkLifecycle::Active, - parent_id: None, - payload: serde_json::json!({"worker_id": "worker-1"}), - }; - let todo = WorkEventEnvelope { - domain: WorkDomain::Todo, - event_id: "event-todo".to_string(), - object_id: "todo-1".to_string(), - payload: serde_json::json!({"checked": false}), - ..task.clone() - }; - assert_ne!(task.domain, todo.domain); - assert!(task.payload.get("worker_id").is_some()); - assert!(todo.payload.get("checked").is_some()); - assert!(task.validate().is_ok()); - assert!(todo.validate().is_ok()); - } -} diff --git a/crates/tui/src/mcp.rs b/crates/tui/src/mcp.rs index e17f70e16e..9ea94f7de9 100644 --- a/crates/tui/src/mcp.rs +++ b/crates/tui/src/mcp.rs @@ -3665,6 +3665,27 @@ pub fn mcp_recovery_kind( McpRecoveryKind::Reconnect } +/// Session-start / manager line: name the server, name the failure, one command. +/// Matches the Codex shape (`The X MCP server requires OAuth reauthentication. Run …` +/// / `MCP startup incomplete (failed: X)`). +#[must_use] +pub fn mcp_startup_warning(name: &str, kind: McpRecoveryKind, failed: bool) -> String { + let command = kind.slash_command(name); + match kind { + McpRecoveryKind::Reauth => { + format!("The {name} MCP server requires OAuth reauthentication. Run `{command}`.") + } + McpRecoveryKind::Enable => { + format!("The {name} MCP server is disabled. Run `{command}`.") + } + _ if failed => format!("MCP startup incomplete (failed: {name}). Run `{command}`."), + McpRecoveryKind::Connect => { + format!("The {name} MCP server is not connected yet. Run `{command}`.") + } + _ => format!("The {name} MCP server needs attention. Run `{command}`."), + } +} + pub fn load_config(path: &Path) -> Result { validate_mcp_config_path(path)?; let Some(contents) = read_mcp_config_file(path)? else { diff --git a/crates/tui/src/mcp/tests.rs b/crates/tui/src/mcp/tests.rs index 2e7d31f25e..5a7b7fba6d 100644 --- a/crates/tui/src/mcp/tests.rs +++ b/crates/tui/src/mcp/tests.rs @@ -5756,6 +5756,14 @@ fn mcp_recovery_kind_names_real_login_and_reload_commands() { McpRecoveryKind::Reauth.slash_command("github"), "/mcp login github" ); + assert_eq!( + crate::mcp::mcp_startup_warning("cloudflare-api", McpRecoveryKind::Reauth, true), + "The cloudflare-api MCP server requires OAuth reauthentication. Run `/mcp login cloudflare-api`." + ); + assert_eq!( + crate::mcp::mcp_startup_warning("cloudflare-api", McpRecoveryKind::Diagnose, true), + "MCP startup incomplete (failed: cloudflare-api). Run `/mcp validate`." + ); assert_eq!( McpRecoveryKind::Connect.slash_command("github"), "/mcp reload" diff --git a/crates/tui/src/tui/footer_ui.rs b/crates/tui/src/tui/footer_ui.rs index 2f77df1c9e..5f30bcaa18 100644 --- a/crates/tui/src/tui/footer_ui.rs +++ b/crates/tui/src/tui/footer_ui.rs @@ -73,10 +73,11 @@ pub(crate) fn friendly_subagent_progress(app: &App, id: &str, status: &str) -> S if let Some(existing) = app.agent_progress.get(id) && !is_noisy_subagent_progress(existing) && existing != "working" + && existing != "in the current" { return existing.clone(); } - "working".to_string() + "in the current".to_string() } pub(crate) fn one_line_summary(text: &str, max_width: usize) -> String { diff --git a/crates/tui/src/tui/mcp_routing.rs b/crates/tui/src/tui/mcp_routing.rs index 3d4442bf66..52589fef80 100644 --- a/crates/tui/src/tui/mcp_routing.rs +++ b/crates/tui/src/tui/mcp_routing.rs @@ -87,6 +87,10 @@ fn push_server(lines: &mut Vec, server: &McpServerSnapshot, locale: Loca crate::mcp::McpRecoveryKind::Reauth => "Re-auth", crate::mcp::McpRecoveryKind::Diagnose => "Diagnose", }; + lines.push(format!( + " {}", + crate::mcp::mcp_startup_warning(&server.name, recovery, server.error.is_some()) + )); lines.push(format!(" next: {verb} {command}")); lines.push(format!( " discovered: {} tools, {} resources, {} prompts", diff --git a/crates/tui/src/tui/notifications.rs b/crates/tui/src/tui/notifications.rs index ed9b76b443..49155b6722 100644 --- a/crates/tui/src/tui/notifications.rs +++ b/crates/tui/src/tui/notifications.rs @@ -598,7 +598,7 @@ fn title_animation_base() -> &'static Mutex { } fn title_activity_verb() -> &'static Mutex { - TITLE_ACTIVITY_VERB.get_or_init(|| Mutex::new("working…".to_string())) + TITLE_ACTIVITY_VERB.get_or_init(|| Mutex::new("in the current…".to_string())) } /// Configure whether the title whale cycles frames. @@ -610,7 +610,7 @@ pub fn set_title_motion_enabled(enabled: bool) { } /// Update the truthful activity verb shown next to the title whale -/// (`working…`, `reasoning…`, `using tool…`, `verifying…`, `waiting on you…`). +/// (`in the current…`, `reasoning…`, `using tool…`, `verifying…`, `waiting on you…`). pub fn set_title_activity_verb(verb: &str) { let verb = verb.trim(); if verb.is_empty() { @@ -640,7 +640,7 @@ pub fn set_title_activity_verb(verb: &str) { fn title_activity_label(base: &str, elapsed: Duration, focused: bool, motion: bool) -> String { let verb = title_activity_verb() .lock() - .map_or_else(|_| "working…".to_string(), |v| v.clone()); + .map_or_else(|_| "in the current…".to_string(), |v| v.clone()); let body = if verb.is_empty() { base.to_string() } else { @@ -698,7 +698,7 @@ pub fn start_title_animation(original: &str) { if let Ok(mut verb) = title_activity_verb().lock() && verb.is_empty() { - "working…".clone_into(&mut *verb); + "in the current…".clone_into(&mut *verb); } COMPLETION_MARKER_SHOWN.store(false, Ordering::SeqCst); TITLE_ANIMATION_RUNNING.store(true, Ordering::SeqCst); @@ -1234,23 +1234,23 @@ mod tests { fn title_whale_is_static_when_focused_or_motion_disabled() { let _guard = prefix_lock(); if let Ok(mut verb) = title_activity_verb().lock() { - "working…".clone_into(&mut *verb); + "in the current…".clone_into(&mut *verb); } assert_eq!( title_activity_label("Codewhale", Duration::ZERO, true, true), - "🐳 working…" + "🐳 in the current…" ); assert_eq!( title_activity_label("Codewhale", Duration::ZERO, false, false), - "🐳 working…" + "🐳 in the current…" ); assert_eq!( title_activity_label("Codewhale", Duration::ZERO, false, true), - "🐳 working…" + "🐳 in the current…" ); assert_eq!( title_activity_label("Codewhale", Duration::from_millis(800), false, true), - "🐋 working…" + "🐋 in the current…" ); } @@ -1557,8 +1557,8 @@ mod tests { assert_eq!(taskbar_progress_sequence(1, Some(42)), "\x1b]9;4;1;42\x07"); assert_eq!(taskbar_progress_sequence(0, None), "\x1b]9;4;0\x07"); assert_eq!( - terminal_title_sequence("🐳 working…"), - "\x1b]0;🐳 working…\x07" + terminal_title_sequence("🐳 in the current…"), + "\x1b]0;🐳 in the current…\x07" ); } diff --git a/crates/tui/src/tui/session_metrics.rs b/crates/tui/src/tui/session_metrics.rs index f48a45e100..e48940ed08 100644 --- a/crates/tui/src/tui/session_metrics.rs +++ b/crates/tui/src/tui/session_metrics.rs @@ -300,46 +300,68 @@ pub fn format_rate(rate: f64) -> String { /// A cell whose evidence has not arrived is omitted — never a placeholder: /// `TTFT avg` / `tok/s` appear only once a model call reported them, `Cache /// hit` only when a provider reported cache classes, `Input` only after the -/// first usage receipt. Turn, step, and time cells are always present once -/// the session has started (zero is a real count). +/// first usage receipt. Turn cells are present once the session has started +/// (zero turns is a real count). Step cells wait for the first completed +/// model or tool call so `0 steps` cannot look like a stalled scoreboard. #[must_use] pub fn build_groups(snapshot: MetricsSnapshot, locale: Locale) -> Vec { let label = |id: MessageId| tr(locale, id).into_owned(); let mut groups = Vec::new(); for group in GROUP_ORDER { let cells = match group { - MetricGroup::Turns => vec![ - MetricCell { - label: label(if snapshot.turns == 1 { - MessageId::SessionMetricsTurn - } else { - MessageId::SessionMetricsTurns - }), - value: snapshot.turns.to_string(), - value_first: true, - }, - MetricCell { - label: label(if snapshot.steps == 1 { - MessageId::SessionMetricsStep - } else { - MessageId::SessionMetricsSteps - }), - value: snapshot.steps.to_string(), - value_first: true, - }, - ], - MetricGroup::Llm => vec![ - MetricCell { - label: label(MessageId::SessionMetricsLlm), - value: format_duration(snapshot.llm_time), - value_first: false, - }, - MetricCell { - label: label(MessageId::SessionMetricsTools), - value: format_duration(snapshot.tool_time), - value_first: false, - }, - ], + MetricGroup::Turns => { + if snapshot.turns == 0 && snapshot.steps == 0 { + continue; + } + let mut cells = Vec::new(); + if snapshot.turns > 0 { + cells.push(MetricCell { + label: label(if snapshot.turns == 1 { + MessageId::SessionMetricsTurn + } else { + MessageId::SessionMetricsTurns + }), + value: snapshot.turns.to_string(), + value_first: true, + }); + } + if snapshot.steps > 0 { + cells.push(MetricCell { + label: label(if snapshot.steps == 1 { + MessageId::SessionMetricsStep + } else { + MessageId::SessionMetricsSteps + }), + value: snapshot.steps.to_string(), + value_first: true, + }); + } + if cells.is_empty() { + continue; + } + cells + } + MetricGroup::Llm => { + let mut cells = Vec::new(); + if !snapshot.llm_time.is_zero() { + cells.push(MetricCell { + label: label(MessageId::SessionMetricsLlm), + value: format_duration(snapshot.llm_time), + value_first: false, + }); + } + if !snapshot.tool_time.is_zero() { + cells.push(MetricCell { + label: label(MessageId::SessionMetricsTools), + value: format_duration(snapshot.tool_time), + value_first: false, + }); + } + if cells.is_empty() { + continue; + } + cells + } MetricGroup::Latency => { let mut cells = Vec::new(); if let Some(ttft) = snapshot.ttft_avg { @@ -635,6 +657,12 @@ mod tests { ); } + #[test] + fn idle_snapshot_paints_nothing() { + let text = full_text(MetricsSnapshot::default(), Locale::En, false); + assert_eq!(text, ""); + } + #[test] fn absent_evidence_omits_the_cell_instead_of_a_placeholder() { let mut snapshot = sample(); @@ -664,7 +692,7 @@ mod tests { ..MetricsSnapshot::default() }; let text = full_text(snapshot, Locale::En, false); - assert!(text.starts_with("1 turn · 1 step │"), "{text}"); + assert_eq!(text, "1 turn · 1 step", "{text}"); } #[test] diff --git a/crates/tui/src/tui/underwater.rs b/crates/tui/src/tui/underwater.rs index b25fa91e05..af6854c857 100644 --- a/crates/tui/src/tui/underwater.rs +++ b/crates/tui/src/tui/underwater.rs @@ -564,9 +564,9 @@ pub(crate) fn title_activity_verb(app: &App) -> &'static str { LiveActivityKind::Reasoning => "reasoning…", LiveActivityKind::Reading => "reading…", LiveActivityKind::UsingTool => "using tool…", - LiveActivityKind::UsingSubagents => "subagents…", + LiveActivityKind::UsingSubagents => "pod underway…", LiveActivityKind::Verifying => "verifying…", - LiveActivityKind::Working => "working…", + LiveActivityKind::Working => "in the current…", }, } } diff --git a/crates/tui/src/work_graph/mod.rs b/crates/tui/src/work_graph/mod.rs index e6029b0b3d..31f1ff869c 100644 --- a/crates/tui/src/work_graph/mod.rs +++ b/crates/tui/src/work_graph/mod.rs @@ -13,11 +13,9 @@ //! and liveness truth stays with the owning subsystems — the graph records //! observations, it never invents them. //! -//! This slice is the core only: model, changes, pure reducer, validation. //! Session persistence, legacy import, UI projections, and liveness adapters -//! land in later slices; nothing in the app or engine calls this yet. -// Staged cutover: later slices wire persistence, UI, and liveness; until -// then the public surface (including re-exports) has no external callers. +//! are wired: `session_manager` stores a snapshot, `tools/shell` and +//! `tools/tasks` observe operations, and the engine records owner liveness. #![allow(dead_code)] #![allow(unused_imports)] diff --git a/docs/design/TUI_DECONSTRUCTION.md b/docs/design/TUI_DECONSTRUCTION.md new file mode 100644 index 0000000000..324d851269 --- /dev/null +++ b/docs/design/TUI_DECONSTRUCTION.md @@ -0,0 +1,91 @@ +# TUI deconstruction + +Stop paying the monolith tax by **consolidating then extracting**, never the reverse. This is the playbook. It is not permission to open a crate per file. + +## Invariants (mechanical, every PR) + +- Runtime-contract receipt (`python3 scripts/measure-runtime-contract.py` / `check-runtime-contract-budget.py`) is **byte-identical** before and after. That is the KV-cache prefix made checkable. +- `crates/core/tests/single_turn_loop.rs` stays green. There is one turn loop: `Engine::run_turn`. Do not add a second. +- `BASE_PROMPT` in `crates/tui/src/prompts/text.rs` is the sole base prompt. Tool catalog order is a cache-prefix fact; do not shuffle it as a drive-by. +- Dead-code / file-size / persistence budgets may go **down**, never up, unless the PR names why. +- Clippy/fmt + targeted tests via `scripts/dev-test.sh [filter]`. Do not `cargo test --workspace` for a single-area edit. +- Never merge `#5576` or `#5628`. Never add `target-*` at the repo root. + +## Anti-goal + +No micro-crates. No "extract because the file is large." A new crate exists only when it has **more than one production consumer** already, or when the extraction is the last step of a finished consolidation. + +## Target topology + +| Lives in | Owns | +| --- | --- | +| `crates/tui` | UI, slash commands, process entry. Target: **<150K lines**. | +| `crates/mcp`, `crates/tools`, `crates/state` | Grow in place. One MCP client (the rmcp stack). | +| **new** `codewhale-models` | After catalog/config consolidation (C3): client, one catalog, pricing, credentials, routing. | +| **Decision A** | Thread store + HTTP automation either becomes `codewhale-runtime` **or** folds into `crates/app-server`. Pick one; do not ship both. | +| `codewhale-engine` | Extracted **last**. Today the engine still lives in `tui/src/core`. | + +`crates/core` already owns request construction, bounded fragments, and thread/session types. It does not run turns. Do not rename it as a substitute for extracting the engine. + +## Sequencing + +### Phase 0 — preconditions (do these first; they are the audit's C1–C4) + +Never extract a crate before these finish. Extraction-before-consolidation relocates the mess. + +1. **C1 MCP unification** — one client. Move the rmcp stack down, delete the hand-rolled stdio client. +2. **C2 Config mirror deletion** — one schema crate owns `config.toml`. TUI's `Config` becomes a resolved view. One struct pair per PR. +3. **C3 Model-facts unification** — config crate catalog is the single source. Delete the seeded `model_registry` table and its drift-guard test. Prices become data. +4. **C4 Test-giant migration** — move the six `>10K` `tests.rs` files out via the existing `#[path = "tests/..."]` pattern. Test count identical before/after. + +Off-ramp: stop after Phase 0 if that is all 0.9.12 can hold. That is a legitimate ship. + +### Phase 1 — dismember `lib.rs` intra-crate + +Stay inside `crates/tui`. Follow `scripts/command-migration-topology.json`. Order: `cli_args` → `doctor` → subcommands → tests out. + +Gate: `crates/tui/src/lib.rs` **< 2,000 lines**. + +### Phase 2 — leaf extractions, fewest-dependents first + +Always **two PRs per extraction**: + +1. Pure `git mv` + re-export shims. Zero logic edits. Receipt identical. +2. Repoint consumers, delete shims, `cargo machete`. Shims get a removal issue at merge. + +Never mix a move with an edit. + +### Phase 3 — engine last + +Extract `Engine` / `run_turn` only after C4 (suite builds fast) and after the leaves are gone. Freeze behavior with the existing runtime-contract receipt. Introduce `TurnLoopState` as a field grouping, not a second loop. + +Off-ramp after "2e" (leaves extracted, engine still in tui) is allowed. + +## Contributor loop (already exists — do not add a second script) + +```sh +./scripts/dev-test.sh config +./scripts/dev-test.sh tui session_metrics:: +./scripts/dev-test.sh tui-integration +./scripts/dev-test.sh crates/tui/src/elapsed.rs +``` + +- Incremental by default. Isolated build-dir via `scripts/dev-cache.sh`. +- Prints the exact `cargo` / `nextest` command (`+ cargo …`). +- `CODEWHALE_DEV_NEXTEST=0` forces libtest. There is no `CARGO_INCREMENTAL=0` requirement for ordinary targeted work. +- `--lib` does not cover `crates/tui/tests/`. Use `tui-integration` / `tui-cucumber`. +- Full CI remains the release gate. Local `tui` is `--lib` on purpose. + +If a `tui full` alias is needed, add it to **this** script, not a new one. + +## Providers (OMP / OpenCode, not a new enum) + +Hosted OpenAI Chat Completions backends (Baseten, Groq, Cerebras, SenseNova) are **data rows** in `crates/config/src/provider_templates.rs` (`ProviderSetupApply::Compatible`). They persist as `[providers.] kind = "openai-compatible"`. They do **not** get a `ProviderKind` / `ApiProvider` variant. + +Add a new hosted Chat Completions host by appending one template (id, URL, env, default model, docs). Enum variants stay for distinct **wires**: Anthropic Messages, Codex Responses, Google thought signatures, OAuth-only import. + +OMP does the same: one catalog descriptor + one auth file. OpenCode does models.dev + named `provider` config + plugins. Neither adds a 15-arm match. + +## Recipe reminder + +Add a layer only when the PR **names or deletes** the layer it replaces. Before adding `model_*`, `*_config`, `provider_*`, or anything that "bridges" / "mirrors" / "stages", grep the existing thing and edit it. diff --git a/scripts/dead-code-budget.json b/scripts/dead-code-budget.json index 0552d2ca42..b9f1eb5eb1 100644 --- a/scripts/dead-code-budget.json +++ b/scripts/dead-code-budget.json @@ -1,10 +1,9 @@ { "_comment": "Ceiling for `#[allow(dead_code)]` across crates/. This number may go down freely; raising it needs a reviewer to say why in the PR. Regenerate with: python3 scripts/check-dead-code-budget.py --update", "_issue": "https://github.com/Hmbown/CodeWhale/issues/4785", - "total": 448, + "total": 372, "per_crate": { - "config": 1, "tools": 2, - "tui": 445 + "tui": 370 } } From aa60285b7a9940aed5ad9d420cf2ce7dbc9d73c9 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Thu, 27 Aug 2026 15:49:23 -0700 Subject: [PATCH 13/14] feat(config): register GLM-5.3-Flash on Z.ai and OpenRouter Add GLM-5.3-Flash as a first-class picker row (wire id GLM-5.3-Flash, OpenRouter z-ai/glm-5.3-flash) so /model can select it. Flash is the faster/explore sibling of GLM-5.3; the Z.ai default stays GLM-5.3. Ship the published $0.15/$0.50 list, not the 50% promo. --- CHANGELOG.md | 4 ++ config.example.toml | 15 ++++--- crates/agent/src/lib.rs | 24 ++++++++++++ crates/config/assets/models_dev.bundled.json | 26 ++++++++++++- crates/config/src/catalog/tests.rs | 23 ++++++++++- crates/config/src/lib.rs | 8 ++++ crates/config/src/provider_defaults.rs | 5 +++ crates/config/src/provider_templates.rs | 1 + crates/config/src/tests.rs | 18 +++++++++ crates/tui/CHANGELOG.md | 3 ++ crates/tui/assets/model_catalog.bundled.json | 22 +++++++++++ crates/tui/src/client/chat.rs | 5 ++- crates/tui/src/config.rs | 24 ++++++++---- crates/tui/src/config/models.rs | 3 ++ crates/tui/src/config/tests.rs | 13 ++++++- crates/tui/src/model_registry.rs | 3 ++ crates/tui/src/model_routing.rs | 41 ++++++++++++-------- crates/tui/src/models.rs | 14 +++++-- crates/tui/src/pricing.rs | 11 +++++- docs/PROVIDERS.md | 28 +++++++------ 20 files changed, 239 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfec6ea42c..8c0187a0c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Z.ai `GLM-5.3-Flash` and OpenRouter `z-ai/glm-5.3-flash` are first-class + picker rows (`/model GLM-5.3-Flash`). Flash is the faster/explore sibling + of `GLM-5.3`; the Z.ai default stays `GLM-5.3`. List price is $0.15/$0.50 + per 1M (durable; the 50% promo through 2026-09-09 is not the catalog row). - Baseten, Groq, and Cerebras are bundled OpenAI-compatible setup templates (`[providers.] kind = "openai-compatible"`), not new `ProviderKind` variants. `/provider` fills URL, model, and env from one catalog row. diff --git a/config.example.toml b/config.example.toml index 71fcc40a5f..736ea9bbf2 100644 --- a/config.example.toml +++ b/config.example.toml @@ -46,12 +46,14 @@ base_url = "https://api.deepseek.com/beta" # z-ai/glm-5.2 — OpenRouter Z.AI GLM 5.2 # z-ai/glm-5.3 — OpenRouter Z.AI GLM 5.3 (live on Z.ai since 2026-08-13; # metadata inherited from 5.2, unpriced) -# z-ai/glm-5-turbo — OpenRouter Z.AI GLM 5 Turbo (scout fast sibling) +# z-ai/glm-5.3-flash — OpenRouter Z.AI GLM 5.3 Flash (1M multimodal; $0.15/$0.50 list) +# z-ai/glm-5-turbo — OpenRouter Z.AI GLM 5 Turbo (scout fast sibling of 5.2) # GLM-5.3 — default direct Z.AI Coding Plan model (live since 2026-08-13; # metadata inherited from 5.2, unpriced) +# GLM-5.3-Flash — direct Z.AI GLM 5.3 Flash (faster/explore sibling of 5.3) # GLM-5.2 — direct Z.AI GLM 5.2 (previous default; explicit selections keep it) # GLM-5.1 — direct Z.AI smaller model -# GLM-5-Turbo — direct Z.AI fast model (scout fast sibling) +# GLM-5-Turbo — direct Z.AI fast model (scout fast sibling of 5.2) # step-3.7-flash — default direct StepFun / StepFlash model ID # kimi-k3 — direct Moonshot K3 model ID (1M context) # kimi-k2.7-code — default direct Moonshot/Kimi K2.7 model ID @@ -674,12 +676,13 @@ max_subagents = 10 # optional (default 64, clamped to 1-128) # base_url = "https://api.z.ai/api/coding/paas/v4" # # General API endpoint, if you are not using the Coding Plan: # # base_url = "https://api.z.ai/api/paas/v4" -# model = "GLM-5.3" # default; GLM-5.2 is the previous default, GLM-5.1 the smaller model, GLM-5-Turbo the fast sub-agent sibling +# model = "GLM-5.3" # default; GLM-5.3-Flash is the fast sibling, GLM-5.2 the previous default, GLM-5.1 the smaller model, GLM-5-Turbo the 5.2 fast sibling # # GLM-5.3 is live on the Z.ai Coding Plan (2026-08-13). Its catalog metadata # # (limits, reasoning options) is inherited from GLM-5.2 until Z.ai publishes -# # distinct 5.3 numbers, and it carries no price. An explicit model = "GLM-5.2" -# # keeps sending GLM-5.2; only the default moved. Accounts not provisioned for -# # 5.3 can still see a 429 with entitlement code 1311. +# # distinct 5.3 numbers, and it carries no price. GLM-5.3-Flash (2026-08-26) +# # is the 1M multimodal picker row (`model = "GLM-5.3-Flash"`). An explicit +# # model = "GLM-5.2" keeps sending GLM-5.2; only the default moved. Accounts +# # not provisioned for 5.3 can still see a 429 with entitlement code 1311. # StepFun / StepFlash direct OpenAI-compatible endpoint (https://platform.stepfun.ai) [providers.stepfun] diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index 0a48ee360b..19c4bdf272 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -516,6 +516,13 @@ impl Default for ModelRegistry { supports_tools: true, supports_reasoning: true, }, + ModelInfo { + id: "z-ai/glm-5.3-flash".to_string(), + provider: ProviderKind::Openrouter, + aliases: vec!["glm-5.3-flash".to_string(), "zai-glm-5.3-flash".to_string()], + supports_tools: true, + supports_reasoning: true, + }, ModelInfo { id: "z-ai/glm-5-turbo".to_string(), provider: ProviderKind::Openrouter, @@ -535,6 +542,18 @@ impl Default for ModelRegistry { supports_tools: true, supports_reasoning: true, }, + ModelInfo { + id: "GLM-5.3-Flash".to_string(), + provider: ProviderKind::Zai, + aliases: vec![ + "glm-5.3-flash".to_string(), + "glm-5-3-flash".to_string(), + "zai-glm-5.3-flash".to_string(), + "zai-glm-5-3-flash".to_string(), + ], + supports_tools: true, + supports_reasoning: true, + }, // The first Z.ai row is the provider default. Keep this ordering // aligned with `DEFAULT_ZAI_MODEL` in codewhale-config. ModelInfo { @@ -2321,6 +2340,10 @@ mod tests { ("glm-5.3", "GLM-5.3"), ("glm-5-3", "GLM-5.3"), ("zai-glm-5-3", "GLM-5.3"), + ("GLM-5.3-Flash", "GLM-5.3-Flash"), + ("glm-5.3-flash", "GLM-5.3-Flash"), + ("glm-5-3-flash", "GLM-5.3-Flash"), + ("zai-glm-5.3-flash", "GLM-5.3-Flash"), ("GLM-5-Turbo", "GLM-5-Turbo"), ("glm-5-turbo", "GLM-5-Turbo"), ("zai-glm-5-turbo", "GLM-5-Turbo"), @@ -2555,6 +2578,7 @@ mod tests { ("glm-5.1", "z-ai/glm-5.1"), ("glm-5.2", "z-ai/glm-5.2"), ("glm-5.3", "z-ai/glm-5.3"), + ("glm-5.3-flash", "z-ai/glm-5.3-flash"), ("minimax-m3", "minimax/minimax-m3"), ("minimax-2.7", "minimax/minimax-m2.7"), ("openrouter-mimo-v2.5-pro", "xiaomi/mimo-v2.5-pro"), diff --git a/crates/config/assets/models_dev.bundled.json b/crates/config/assets/models_dev.bundled.json index 0dc907dd68..799eb6a2b7 100644 --- a/crates/config/assets/models_dev.bundled.json +++ b/crates/config/assets/models_dev.bundled.json @@ -9,8 +9,9 @@ "curated": "qwen3.8-max (GA) is curated ahead of upstream Models.dev, which as of 2026-08-03 lists only qwen3.8-max-preview; facts verified against the owner's Token Plan console (2026-08-03): ~1M context, 128K output, image understanding, always-on reasoning. deepseek-v4-flash-0731 keeps the console/in-repo wire id for the row upstream serves as deepseek-v4-flash. Coding Plan rows for qwen3.8-max-preview, deepseek-v4-pro, deepseek-v4-flash-0731, and glm-5.2 are curated from the Token Plan upstream entries (upstream alibaba-coding-plan does not list them yet); the in-repo route layer already offers the same model set on both plans. Upstream provider ids alibaba-token-plan(-cn) / alibaba-coding-plan(-cn) were merged onto the CodeWhale provider ids (live refresh normalizes them via ProviderKind aliases; the -cn regional variants stay upstream-id browse rows until Codewhale ships China endpoints).", "qwen_3_8_flash_2026_08_26": "OpenRouter qwen/qwen3.8-flash recorded 2026-08-26 against https://models.dev/api.json: release_date 2026-08-26, 1,000,000 context / 131,072 output, text+image+video input / text output, reasoning true, tool_call true, family qwen. Durable list prices (no promo annotation): input 0.16, output 0.47, cache_read 0.016, cache_write 0.20 per 1M. Alibaba first-party lists only qwen3.8-max (already curated, unpriced on Token Plan); this seed curates the OpenRouter row only and does not invent a first-party flash id. Live refresh supersedes on (provider, wire_model_id) identity. The flash suffix is not a GA family default, so DEFAULT_MODELSTUDIO_TOKEN_PLAN_MODEL stays qwen3.8-max and this row is not default: true.", "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.", + "glm_5_3_flash_2026_08_26": "GLM-5.3-Flash recorded 2026-08-26 against https://docs.z.ai/guides/overview/pricing: natively multimodal (text/image/video), 1,000,000 context / 131,072 output, reasoning + tools. First-party wire id GLM-5.3-Flash; OpenRouter mirror z-ai/glm-5.3-flash. Durable list prices (not the 50% promo ending 2026-09-09 UTC+8): input 0.15, output 0.50, cache_read 0.03 per 1M. Flash is a picker row and the faster/explore sibling of GLM-5.3; DEFAULT_ZAI_MODEL stays GLM-5.3.", "currency_sweep_2026_08_17": "Rows re-verified against official pages on 2026-08-17 (#5470 follow-up): gpt-5.6-terra 2.00/12.00 (cache read 0.20, write 2.50) and gpt-5.6-luna 0.20/1.20 (0.02/0.25) per developers.openai.com model pages; claude-sonnet-5 2.00/10.00 (0.20/2.50) is now Anthropic's standard price (the 2026-09-01 increase was cancelled) and claude-opus-5 5.00/25.00 (0.50/6.25) was added; kimi-k3 3.00/15.00 (0.30) and kimi-k2.7-code-highspeed 1.90/8.00 (0.38) per platform.kimi.ai; MiniMax-M2.7-highspeed 0.60/2.40 (0.06/0.375) per platform.minimax.io; grok-4.5 (500K) and grok-4.3 (1M) carry limits only because xAI doubles their rates past 200K (same rule as grok-4.6); OpenRouter dots-studio/dots-3-note-preview:free carries limits only (its single free endpoint publishes $0, which this seed does not restate as a price).", - "coverage": "20 providers, 90 model rows (offline seed only)." + "coverage": "20 providers, 92 model rows (offline seed only)." }, "models": { "deepseek-v4-pro": { @@ -104,6 +105,18 @@ "modalities": { "input": ["text"], "output": ["text"] }, "limit": { "context": 1000000, "output": 131072 } }, + "GLM-5.3-Flash": { + "id": "GLM-5.3-Flash", + "name": "GLM-5.3-Flash", + "family": "glm", + "attachment": true, + "reasoning": true, + "reasoning_options": [{ "type": "effort", "values": ["high", "max"] }], + "tool_call": true, + "modalities": { "input": ["text", "image", "video"], "output": ["text"] }, + "limit": { "context": 1000000, "output": 131072 }, + "cost": { "input": 0.15, "output": 0.50, "cache_read": 0.03 } + }, "glm-5.1": { "id": "glm-5.1", "name": "GLM-5.1", @@ -1099,6 +1112,17 @@ "modalities": { "input": ["text"], "output": ["text"] }, "limit": { "context": 1000000, "output": 131072 } }, + "z-ai/glm-5.3-flash": { + "id": "z-ai/glm-5.3-flash", + "name": "GLM-5.3-Flash (OpenRouter)", + "family": "glm", + "attachment": true, + "reasoning": true, + "tool_call": true, + "modalities": { "input": ["text", "image", "video"], "output": ["text"] }, + "limit": { "context": 1000000, "output": 131072 }, + "cost": { "input": 0.15, "output": 0.50, "cache_read": 0.03 } + }, "dots-studio/dots-3-note-preview:free": { "id": "dots-studio/dots-3-note-preview:free", "name": "Dots Studio Dots3-Note Preview (OpenRouter, free endpoint)", diff --git a/crates/config/src/catalog/tests.rs b/crates/config/src/catalog/tests.rs index f34f930f4e..23cdb16106 100644 --- a/crates/config/src/catalog/tests.rs +++ b/crates/config/src/catalog/tests.rs @@ -851,9 +851,11 @@ fn bundled_asset_pricing_is_honest() { // GLM-5.3 is live on the Coding Plan, but Z.ai has published no USD PAYG // rate for it. Coding Plan credit multipliers are not USD, so every - // glm-5.3 row stays unpriced rather than inheriting glm-5.2's rates. + // glm-5.3 row *except Flash* stays unpriced rather than inheriting + // glm-5.2's rates. GLM-5.3-Flash has a published list (2026-08-26). for row in &rows { - if row.wire_model_id.to_ascii_lowercase().contains("glm-5.3") { + let wire = row.wire_model_id.to_ascii_lowercase(); + if wire.contains("glm-5.3") && !wire.contains("flash") { assert!( row.cost.is_none(), "{}/{}: glm-5.3 must stay unpriced until Z.ai publishes rates", @@ -863,6 +865,23 @@ fn bundled_asset_pricing_is_honest() { } } + let glm53_flash = find(&rows, "zai", "GLM-5.3-Flash"); + let cost = glm53_flash + .cost + .as_ref() + .expect("GLM-5.3-Flash must ship priced at durable list rates"); + assert_eq!(cost.input, Some(0.15)); + assert_eq!(cost.output, Some(0.50)); + assert_eq!(cost.cache_read, Some(0.03)); + assert_eq!( + glm53_flash.limit.as_ref().and_then(|l| l.context), + Some(1_000_000) + ); + assert!( + !glm53_flash.default_for_provider, + "GLM-5.3-Flash is a picker row, not the Z.ai default" + ); + // OpenRouter qwen3.8-flash lists durable (non-promo) rates on models.dev // as of 2026-08-26. Unlike GLM-5.3-Flash's explicit 50% promo, this row // must ship priced. It is not a family default. diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index c6384df9ea..5916cbd57b 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -4364,6 +4364,9 @@ fn canonical_zai_model_id(model: &str) -> Option<&'static str> { // moving the default (now GLM-5.3) must not silently re-point an // explicit GLM-5.2 route. "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => Some(ZAI_GLM_5_2_MODEL), + "glm-5.3-flash" | "glm-5-3-flash" | "zai-glm-5.3-flash" | "zai-glm-5-3-flash" => { + Some(ZAI_GLM_5_3_FLASH_MODEL) + } "glm-5.3" | "glm-5-3" | "zai-glm-5.3" | "zai-glm-5-3" => Some(ZAI_GLM_5_3_MODEL), "glm-5-turbo" | "glm-5turbo" | "zai-glm-5-turbo" => Some(ZAI_GLM_5_TURBO_MODEL), _ => None, @@ -4391,6 +4394,11 @@ fn canonical_openrouter_recent_model_id(model: &str) -> Option<&'static str> { OPENROUTER_GLM_5_2_MODEL | "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => { Some(OPENROUTER_GLM_5_2_MODEL) } + OPENROUTER_GLM_5_3_FLASH_MODEL + | "glm-5.3-flash" + | "glm-5-3-flash" + | "zai-glm-5.3-flash" + | "zai-glm-5-3-flash" => Some(OPENROUTER_GLM_5_3_FLASH_MODEL), OPENROUTER_GLM_5_3_MODEL | "glm-5.3" | "glm-5-3" | "zai-glm-5.3" | "zai-glm-5-3" => { Some(OPENROUTER_GLM_5_3_MODEL) } diff --git a/crates/config/src/provider_defaults.rs b/crates/config/src/provider_defaults.rs index 0349d5fa69..6e8c065c9f 100644 --- a/crates/config/src/provider_defaults.rs +++ b/crates/config/src/provider_defaults.rs @@ -49,6 +49,10 @@ pub(crate) const OPENROUTER_GLM_5_2_MODEL: &str = "z-ai/glm-5.2"; // resolves to OpenRouter rather than another vendor. See // models_dev.bundled.json `_meta.pending_release_metadata`. pub(crate) const OPENROUTER_GLM_5_3_MODEL: &str = "z-ai/glm-5.3"; +// GLM-5.3-Flash (2026-08-26): first natively multimodal GLM-5, 1M context, +// published USD list $0.15/$0.50 (50% promo until 2026-09-09 UTC+8 is not +// the durable row). OpenRouter mirror is z-ai/glm-5.3-flash. +pub(crate) const OPENROUTER_GLM_5_3_FLASH_MODEL: &str = "z-ai/glm-5.3-flash"; pub(crate) const OPENROUTER_KIMI_K2_7_CODE_MODEL: &str = "moonshotai/kimi-k2.7-code"; pub(crate) const OPENROUTER_KIMI_K2_6_MODEL: &str = "moonshotai/kimi-k2.6"; pub(crate) const OPENROUTER_MINIMAX_M3_MODEL: &str = "minimax/minimax-m3"; @@ -128,6 +132,7 @@ pub(crate) const DEFAULT_OLLAMA_CLOUD_BASE_URL: &str = "https://ollama.com/v1"; // own id: only the default moved. pub(crate) const DEFAULT_ZAI_MODEL: &str = ZAI_GLM_5_3_MODEL; pub(crate) const ZAI_GLM_5_3_MODEL: &str = "GLM-5.3"; +pub(crate) const ZAI_GLM_5_3_FLASH_MODEL: &str = "GLM-5.3-Flash"; pub(crate) const ZAI_GLM_5_2_MODEL: &str = "GLM-5.2"; pub(crate) const ZAI_GLM_5_1_MODEL: &str = "GLM-5.1"; pub(crate) const ZAI_GLM_5_TURBO_MODEL: &str = "GLM-5-Turbo"; diff --git a/crates/config/src/provider_templates.rs b/crates/config/src/provider_templates.rs index 1a59ffc10c..430dc94a93 100644 --- a/crates/config/src/provider_templates.rs +++ b/crates/config/src/provider_templates.rs @@ -44,6 +44,7 @@ pub const BASETEN_MODELS: &[&str] = &[ "deepseek-ai/DeepSeek-V4-Flash-0731", "deepseek-ai/DeepSeek-V4-Pro-0813", "zai-org/GLM-5.2", + "zai-org/GLM-5.3-Flash", "moonshotai/Kimi-K2.7-Code", ]; diff --git a/crates/config/src/tests.rs b/crates/config/src/tests.rs index 71913c6701..700ae8a956 100644 --- a/crates/config/src/tests.rs +++ b/crates/config/src/tests.rs @@ -5339,6 +5339,23 @@ fn zai_aliases_resolve_to_canonical_models() { "{alias} must not resolve to the Z.ai default" ); } + for alias in [ + "glm-5.3-flash", + "glm-5-3-flash", + "zai-glm-5.3-flash", + "GLM-5.3-Flash", + ] { + assert_eq!( + normalize_model_for_provider(ProviderKind::Zai, alias), + ZAI_GLM_5_3_FLASH_MODEL, + "{alias} must canonicalize to GLM-5.3-Flash" + ); + assert_ne!( + normalize_model_for_provider(ProviderKind::Zai, alias), + ZAI_GLM_5_3_MODEL, + "{alias} must not collapse onto GLM-5.3" + ); + } assert_eq!( normalize_model_for_provider(ProviderKind::Zai, "glm-5-turbo"), ZAI_GLM_5_TURBO_MODEL @@ -7107,6 +7124,7 @@ fn openrouter_provider_normalizes_recent_large_model_aliases() { ("glm-5.1", OPENROUTER_GLM_5_1_MODEL), ("glm-5.2", OPENROUTER_GLM_5_2_MODEL), ("glm-5.3", OPENROUTER_GLM_5_3_MODEL), + ("glm-5.3-flash", OPENROUTER_GLM_5_3_FLASH_MODEL), ] { let cli = CliRuntimeOverrides { provider: Some(ProviderKind::Openrouter), diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index aa5dbd220c..1217aa60ef 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Z.ai `GLM-5.3-Flash` and OpenRouter `z-ai/glm-5.3-flash` are first-class + picker rows (`/model GLM-5.3-Flash`). Flash is the faster/explore sibling + of `GLM-5.3`; the Z.ai default stays `GLM-5.3`. - Baseten, Groq, and Cerebras are bundled OpenAI-compatible setup templates (`[providers.] kind = "openai-compatible"`), not new `ProviderKind` variants. `/provider` fills URL, model, and env from one catalog row. diff --git a/crates/tui/assets/model_catalog.bundled.json b/crates/tui/assets/model_catalog.bundled.json index 818775e365..1da883fc43 100644 --- a/crates/tui/assets/model_catalog.bundled.json +++ b/crates/tui/assets/model_catalog.bundled.json @@ -170,6 +170,17 @@ "supported_parameters": [], "provenance": "bundled" }, + "z-ai/glm-5.3-flash": { + "id": "z-ai/glm-5.3-flash", + "context_window": 1000000, + "max_output": 131072, + "supports_reasoning": true, + "input_usd_per_million": 0.15, + "output_usd_per_million": 0.5, + "modalities": ["text", "image", "video"], + "supported_parameters": ["reasoning"], + "provenance": "bundled" + }, "glm-5.2": { "id": "glm-5.2", "context_window": 1000000, @@ -190,6 +201,17 @@ "supported_parameters": [], "provenance": "bundled" }, + "glm-5.3-flash": { + "id": "glm-5.3-flash", + "context_window": 1000000, + "max_output": 131072, + "supports_reasoning": true, + "input_usd_per_million": 0.15, + "output_usd_per_million": 0.5, + "modalities": ["text", "image", "video"], + "supported_parameters": ["reasoning"], + "provenance": "bundled" + }, "minimax/minimax-m3": { "id": "minimax/minimax-m3", "context_window": 1000000, diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index c415991040..7796f0f842 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -5994,11 +5994,12 @@ mod alias_thinking_detection_tests { #[test] fn zai_tiered_effort_applies_to_glm_5_2_and_glm_5_3_but_not_5_1() { let zai = crate::config::DEFAULT_ZAI_BASE_URL; - // GLM-5.3 inherits GLM-5.2's reasoning_options (effort high/max), so it - // must take the same tiered wire path — not the generic toggle. + // GLM-5.3 and GLM-5.3-Flash inherit GLM-5.2's reasoning_options + // (effort high/max), so they must take the same tiered wire path. for model in [ crate::config::ZAI_GLM_5_2_MODEL, crate::config::ZAI_GLM_5_3_MODEL, + crate::config::ZAI_GLM_5_3_FLASH_MODEL, ] { let mut body = json!({}); apply_route_reasoning_controls(&mut body, ApiProvider::Zai, zai, model, Some("max")); diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 0984d2e8b0..bb109cdc27 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -1022,6 +1022,11 @@ fn canonical_openrouter_recent_model_id(model: &str) -> Option<&'static str> { OPENROUTER_GLM_5_2_MODEL | "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => { Some(OPENROUTER_GLM_5_2_MODEL) } + OPENROUTER_GLM_5_3_FLASH_MODEL + | "glm-5.3-flash" + | "glm-5-3-flash" + | "zai-glm-5.3-flash" + | "zai-glm-5-3-flash" => Some(OPENROUTER_GLM_5_3_FLASH_MODEL), OPENROUTER_GLM_5_3_MODEL | "glm-5.3" | "glm-5-3" | "zai-glm-5.3" | "zai-glm-5-3" => { Some(OPENROUTER_GLM_5_3_MODEL) } @@ -1204,6 +1209,9 @@ fn canonical_zai_model_id(model: &str) -> Option<&'static str> { // `DEFAULT_ZAI_MODEL`: moving the default (now GLM-5.3) must not // silently re-point an explicit GLM-5.2 request. "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => Some(ZAI_GLM_5_2_MODEL), + "glm-5.3-flash" | "glm-5-3-flash" | "zai-glm-5.3-flash" | "zai-glm-5-3-flash" => { + Some(ZAI_GLM_5_3_FLASH_MODEL) + } "glm-5.3" | "glm-5-3" | "zai-glm-5.3" | "zai-glm-5-3" => Some(ZAI_GLM_5_3_MODEL), "glm-5-turbo" | "glm-5turbo" | "zai-glm-5-turbo" => Some(ZAI_GLM_5_TURBO_MODEL), _ => None, @@ -1521,6 +1529,7 @@ pub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'stati ApiProvider::Openmodel => vec![DEFAULT_OPENMODEL_MODEL], ApiProvider::Zai => vec![ DEFAULT_ZAI_MODEL, + ZAI_GLM_5_3_FLASH_MODEL, ZAI_GLM_5_2_MODEL, ZAI_GLM_5_1_MODEL, ZAI_GLM_5_TURBO_MODEL, @@ -9600,10 +9609,10 @@ pub(crate) fn is_exact_zai_chat_route(provider: ApiProvider, base_url: &str) -> /// reasoning effort (`reasoning_effort: high | max`) rather than only the /// generic thinking toggle. /// -/// GLM-5.2 is the verified member. GLM-5.3 inherits it because its catalog row -/// inherits GLM-5.2's `reasoning_options` wholesale — see the -/// `INHERITED FROM glm-5.2` marker in `config/models.rs`. If Z.ai publishes -/// different reasoning controls for 5.3, this predicate is where they split. +/// GLM-5.2 is the verified member. GLM-5.3 and GLM-5.3-Flash inherit it +/// because their catalog rows inherit GLM-5.2's `reasoning_options` +/// wholesale. If Z.ai publishes different reasoning controls, this +/// predicate is where they split. #[must_use] pub(crate) fn is_exact_zai_tiered_effort_route( provider: ApiProvider, @@ -9612,7 +9621,8 @@ pub(crate) fn is_exact_zai_tiered_effort_route( ) -> bool { is_exact_zai_chat_route(provider, base_url) && (model.trim().eq_ignore_ascii_case(ZAI_GLM_5_2_MODEL) - || model.trim().eq_ignore_ascii_case(ZAI_GLM_5_3_MODEL)) + || model.trim().eq_ignore_ascii_case(ZAI_GLM_5_3_MODEL) + || model.trim().eq_ignore_ascii_case(ZAI_GLM_5_3_FLASH_MODEL)) } /// Whether a route is exactly first-party Z.ai GLM-5-Turbo. @@ -9627,8 +9637,8 @@ pub(crate) fn is_exact_zai_glm_5_turbo_route( } /// Whether a route is an exact first-party Z.ai model with a verified -/// reasoning control. GLM-5.2 and GLM-5.3 have tiered effort; GLM-5.1 and -/// GLM-5-Turbo only expose the generic thinking toggle. +/// reasoning control. GLM-5.2, GLM-5.3, and GLM-5.3-Flash have tiered +/// effort; GLM-5.1 and GLM-5-Turbo only expose the generic thinking toggle. #[must_use] pub(crate) fn is_exact_known_zai_reasoning_route( provider: ApiProvider, diff --git a/crates/tui/src/config/models.rs b/crates/tui/src/config/models.rs index fdb9e9efb6..d583573694 100644 --- a/crates/tui/src/config/models.rs +++ b/crates/tui/src/config/models.rs @@ -32,6 +32,7 @@ pub const OPENROUTER_GEMMA_4_26B_A4B_MODEL: &str = "google/gemma-4-26b-a4b-it"; pub const OPENROUTER_GLM_5_1_MODEL: &str = "z-ai/glm-5.1"; pub const OPENROUTER_GLM_5_2_MODEL: &str = "z-ai/glm-5.2"; pub const OPENROUTER_GLM_5_3_MODEL: &str = "z-ai/glm-5.3"; +pub const OPENROUTER_GLM_5_3_FLASH_MODEL: &str = "z-ai/glm-5.3-flash"; pub const OPENROUTER_GLM_5_TURBO_MODEL: &str = "z-ai/glm-5-turbo"; pub const OPENROUTER_KIMI_K2_7_CODE_MODEL: &str = "moonshotai/kimi-k2.7-code"; pub const OPENROUTER_KIMI_K2_6_MODEL: &str = "moonshotai/kimi-k2.6"; @@ -71,6 +72,7 @@ pub const RECENT_OPENROUTER_LARGE_MODELS: &[&str] = &[ OPENROUTER_GLM_5_1_MODEL, OPENROUTER_GLM_5_2_MODEL, OPENROUTER_GLM_5_3_MODEL, + OPENROUTER_GLM_5_3_FLASH_MODEL, OPENROUTER_TENCENT_HY3_PREVIEW_MODEL, OPENROUTER_GEMMA_4_31B_MODEL, OPENROUTER_GEMMA_4_26B_A4B_MODEL, @@ -196,6 +198,7 @@ pub const DEFAULT_ZAI_MODEL: &str = ZAI_GLM_5_3_MODEL; pub const ZAI_GLM_5_1_MODEL: &str = "GLM-5.1"; pub const ZAI_GLM_5_2_MODEL: &str = "GLM-5.2"; pub const ZAI_GLM_5_3_MODEL: &str = "GLM-5.3"; +pub const ZAI_GLM_5_3_FLASH_MODEL: &str = "GLM-5.3-Flash"; pub const ZAI_GLM_5_TURBO_MODEL: &str = "GLM-5-Turbo"; pub const DEFAULT_ZAI_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4"; pub const DEFAULT_STEPFUN_MODEL: &str = "step-3.7-flash"; diff --git a/crates/tui/src/config/tests.rs b/crates/tui/src/config/tests.rs index 04e39413b9..08f699d74e 100644 --- a/crates/tui/src/config/tests.rs +++ b/crates/tui/src/config/tests.rs @@ -6688,6 +6688,7 @@ fn model_completion_names_for_zai_lists_default_5_1_and_turbo() { assert_eq!(models.first().copied(), Some(DEFAULT_ZAI_MODEL)); assert_eq!(DEFAULT_ZAI_MODEL, ZAI_GLM_5_3_MODEL); assert!(models.contains(&ZAI_GLM_5_1_MODEL)); + assert!(models.contains(&ZAI_GLM_5_3_FLASH_MODEL)); assert!(models.contains(&ZAI_GLM_5_TURBO_MODEL)); // GLM-5.2 is still offered alongside the others but no longer takes the // default slot; explicit 5.2 routes are untouched. @@ -6711,6 +6712,9 @@ fn normalize_model_name_for_zai_canonicalizes_current_glm_models() { ("glm-5.3", DEFAULT_ZAI_MODEL), ("glm-5-3", ZAI_GLM_5_3_MODEL), ("zai-glm-5-3", ZAI_GLM_5_3_MODEL), + ("glm-5.3-flash", ZAI_GLM_5_3_FLASH_MODEL), + ("glm-5-3-flash", ZAI_GLM_5_3_FLASH_MODEL), + ("zai-glm-5.3-flash", ZAI_GLM_5_3_FLASH_MODEL), ("glm-5-turbo", ZAI_GLM_5_TURBO_MODEL), ("zai-glm-5-turbo", ZAI_GLM_5_TURBO_MODEL), ] { @@ -11050,7 +11054,14 @@ fn provider_capability_zai_defaults_to_5_3_and_tracks_5_2_5_1_and_turbo() { assert_eq!(v51.max_output, Some(131_072)); assert!(v51.thinking_supported); - // GLM-5-Turbo is the faster sub-agent sibling. + // GLM-5.3-Flash is the published 1M multimodal sibling. + let flash = provider_capability(ApiProvider::Zai, ZAI_GLM_5_3_FLASH_MODEL); + assert_eq!(flash.resolved_model, ZAI_GLM_5_3_FLASH_MODEL); + assert_eq!(flash.context_window, 1_000_000); + assert_eq!(flash.max_output, Some(131_072)); + assert!(flash.thinking_supported); + + // GLM-5-Turbo is the faster sub-agent sibling of GLM-5.2. let turbo = provider_capability(ApiProvider::Zai, ZAI_GLM_5_TURBO_MODEL); assert_eq!(turbo.resolved_model, ZAI_GLM_5_TURBO_MODEL); } diff --git a/crates/tui/src/model_registry.rs b/crates/tui/src/model_registry.rs index 9af53f08d0..4e3556a40a 100644 --- a/crates/tui/src/model_registry.rs +++ b/crates/tui/src/model_registry.rs @@ -164,9 +164,11 @@ const SEED_MODEL_IDS: &[(&str, ModelProvider)] = &[ ("z-ai/glm-5.1", ModelProvider::Zai), ("z-ai/glm-5.2", ModelProvider::Zai), ("z-ai/glm-5.3", ModelProvider::Zai), + ("z-ai/glm-5.3-flash", ModelProvider::Zai), ("glm-5.1", ModelProvider::Zai), ("glm-5.2", ModelProvider::Zai), ("glm-5.3", ModelProvider::Zai), + ("glm-5.3-flash", ModelProvider::Zai), // --- MiniMax (config DEFAULT_MINIMAX_MODEL) --- ("minimax/minimax-m3", ModelProvider::Minimax), ("minimax-m3", ModelProvider::Minimax), @@ -312,6 +314,7 @@ mod tests { ("z-ai/glm-5.1", Some(202_752)), ("z-ai/glm-5.2", Some(1_000_000)), ("z-ai/glm-5.3", Some(1_000_000)), + ("z-ai/glm-5.3-flash", Some(1_000_000)), ("minimax/minimax-m3", Some(1_000_000)), ("minimax-m2.7", Some(204_800)), ("qwen/qwen3.6-flash", Some(1_000_000)), diff --git a/crates/tui/src/model_routing.rs b/crates/tui/src/model_routing.rs index 967ce65e2f..72c61751ec 100644 --- a/crates/tui/src/model_routing.rs +++ b/crates/tui/src/model_routing.rs @@ -141,13 +141,12 @@ pub(crate) fn provider_router_candidates( let normalized = crate::config::normalize_model_name_for_provider(provider, current_model) .unwrap_or_else(|| current_model.to_string()); return RouterCandidates { - // GLM-5.3 (the default) and GLM-5.2 route faster/explore children - // to GLM-5-Turbo, the same-family fast sibling. GLM-5.1 and - // GLM-5-Turbo itself have no cheaper tier and keep children on the - // parent model. - cheap: if normalized == crate::config::ZAI_GLM_5_2_MODEL - || normalized == crate::config::ZAI_GLM_5_3_MODEL - { + // GLM-5.3 routes faster/explore children to GLM-5.3-Flash. + // GLM-5.2 still uses GLM-5-Turbo. Flash, Turbo, and 5.1 have no + // cheaper tier and keep children on the parent model. + cheap: if normalized == crate::config::ZAI_GLM_5_3_MODEL { + Some(crate::config::ZAI_GLM_5_3_FLASH_MODEL.to_string()) + } else if normalized == crate::config::ZAI_GLM_5_2_MODEL { Some(crate::config::ZAI_GLM_5_TURBO_MODEL.to_string()) } else { None @@ -164,16 +163,17 @@ pub(crate) fn provider_router_candidates( crate::config::OPENROUTER_GLM_5_1_MODEL | crate::config::OPENROUTER_GLM_5_2_MODEL | crate::config::OPENROUTER_GLM_5_3_MODEL + | crate::config::OPENROUTER_GLM_5_3_FLASH_MODEL | crate::config::OPENROUTER_GLM_5_TURBO_MODEL ) { return RouterCandidates { - // z-ai/glm-5.2 and z-ai/glm-5.3 route faster children to - // z-ai/glm-5-turbo; the 5.1 and turbo ids have no cheaper tier and - // keep children on parent. - cheap: if normalized == crate::config::OPENROUTER_GLM_5_2_MODEL - || normalized == crate::config::OPENROUTER_GLM_5_3_MODEL - { + // z-ai/glm-5.3 routes faster children to z-ai/glm-5.3-flash; + // z-ai/glm-5.2 still uses z-ai/glm-5-turbo. Flash, turbo, and 5.1 + // have no cheaper tier and keep children on parent. + cheap: if normalized == crate::config::OPENROUTER_GLM_5_3_MODEL { + Some(crate::config::OPENROUTER_GLM_5_3_FLASH_MODEL.to_string()) + } else if normalized == crate::config::OPENROUTER_GLM_5_2_MODEL { Some(crate::config::OPENROUTER_GLM_5_TURBO_MODEL.to_string()) } else { None @@ -1345,7 +1345,7 @@ mod tests { assert!(!balanced.contains("Cost-saving mode is ON")); assert!( cost_saving.contains( - "For the active provider `zai`, `GLM-5-Turbo` is the fast tier and `GLM-5.3` is the strong tier" + "For the active provider `zai`, `GLM-5.3-Flash` is the fast tier and `GLM-5.3` is the strong tier" ), "cost-saving classifier policy must name the provider-safe pair: {cost_saving}" ); @@ -2123,14 +2123,21 @@ mod tests { assert_eq!(openrouter_glm.big, "z-ai/glm-5.2"); assert_eq!(openrouter_glm.cheap.as_deref(), Some("z-ai/glm-5-turbo")); - // GLM-5.3 inherits the same fast sibling without displacing GLM-5.2's. + // GLM-5.3's fast sibling is Flash; GLM-5.2 still uses Turbo. let zai_53 = provider_router_candidates(ApiProvider::Zai, "GLM-5.3"); assert_eq!(zai_53.big, "GLM-5.3"); - assert_eq!(zai_53.cheap.as_deref(), Some("GLM-5-Turbo")); + assert_eq!(zai_53.cheap.as_deref(), Some("GLM-5.3-Flash")); let openrouter_glm_53 = provider_router_candidates(ApiProvider::Openrouter, "z-ai/glm-5.3"); assert_eq!(openrouter_glm_53.big, "z-ai/glm-5.3"); - assert_eq!(openrouter_glm_53.cheap.as_deref(), Some("z-ai/glm-5-turbo")); + assert_eq!( + openrouter_glm_53.cheap.as_deref(), + Some("z-ai/glm-5.3-flash") + ); + + let zai_flash = provider_router_candidates(ApiProvider::Zai, "GLM-5.3-Flash"); + assert_eq!(zai_flash.big, "GLM-5.3-Flash"); + assert_eq!(zai_flash.cheap, None); // GLM-5.1 has no cheaper tier; faster children stay on the parent. let zai_51 = provider_router_candidates(ApiProvider::Zai, "GLM-5.1"); diff --git a/crates/tui/src/models.rs b/crates/tui/src/models.rs index 5fb6364056..7aaaff16a2 100644 --- a/crates/tui/src/models.rs +++ b/crates/tui/src/models.rs @@ -258,7 +258,9 @@ fn known_context_window_for_model(model_lower: &str) -> Option { "z-ai/glm-5-turbo" | "glm-5-turbo" => Some(202_752), // GLM-5.3 limits are inherited from GLM-5.2 pending official Z.ai // release metadata (see `INHERITED FROM glm-5.2` in config/models.rs). - "z-ai/glm-5.2" | "glm-5.2" | "z-ai/glm-5.3" | "glm-5.3" => Some(1_000_000), + // GLM-5.3-Flash is the published 1M multimodal sibling (2026-08-26). + "z-ai/glm-5.2" | "glm-5.2" | "z-ai/glm-5.3" | "glm-5.3" | "z-ai/glm-5.3-flash" + | "glm-5.3-flash" => Some(1_000_000), "minimax/minimax-m3" | "minimax-m3" | "qwen/qwen3.8-flash" | "qwen/qwen3.6-flash" | "qwen/qwen3.6-plus" => Some(1_000_000), // Alibaba Cloud Model Studio (Token Plan console + curated catalog, @@ -399,8 +401,9 @@ pub fn max_output_tokens_for_model(model: &str) -> Option { Some(131_072) } "qwen3.7-plus" | "qwen3.7-max" | "qwen3.6-flash" => Some(65_536), - "z-ai/glm-5.1" | "z-ai/glm-5.2" | "z-ai/glm-5.3" | "z-ai/glm-5-turbo" | "glm-5.1" - | "glm-5.2" | "glm-5.3" | "glm-5-turbo" => Some(131_072), + "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" | "glm-5.1" | "glm-5.2" | "glm-5.3" | "glm-5.3-flash" + | "glm-5-turbo" => Some(131_072), "xiaomi/mimo-v2.5-pro" | "xiaomi/mimo-v2.5" | "mimo-v2.5-pro" @@ -522,10 +525,12 @@ pub fn model_supports_reasoning(model: &str) -> bool { | "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" | "glm-5.1" | "glm-5.2" | "glm-5.3" + | "glm-5.3-flash" | "glm-5-turbo" | "grok-4.6" | "grok-4.5" @@ -920,6 +925,7 @@ mod tests { ("z-ai/glm-5.1", 202_752), ("z-ai/glm-5.2", 1_000_000), ("z-ai/glm-5.3", 1_000_000), + ("z-ai/glm-5.3-flash", 1_000_000), ] { assert_eq!(context_window_for_model(model), Some(expected_window)); assert!(model_supports_reasoning(model)); @@ -1322,6 +1328,7 @@ mod tests { ("glm-5.2", 1_000_000), // Inherited from glm-5.2 pending official Z.ai release metadata. ("glm-5.3", 1_000_000), + ("glm-5.3-flash", 1_000_000), ("glm-5-turbo", 202_752), ] { assert_eq!(context_window_for_model(model), Some(expected_window)); @@ -1347,6 +1354,7 @@ mod tests { assert_eq!(max_output_tokens_for_model("glm-5.1"), Some(131_072)); assert_eq!(max_output_tokens_for_model("glm-5.2"), Some(131_072)); assert_eq!(max_output_tokens_for_model("glm-5.3"), Some(131_072)); + assert_eq!(max_output_tokens_for_model("glm-5.3-flash"), Some(131_072)); } #[test] diff --git a/crates/tui/src/pricing.rs b/crates/tui/src/pricing.rs index 13a12f393b..b13d696c99 100644 --- a/crates/tui/src/pricing.rs +++ b/crates/tui/src/pricing.rs @@ -625,6 +625,9 @@ fn known_pricing_for_model(model_lower: &str) -> Option { // Z.ai GLM-5.2 cache-read rate per https://docs.z.ai/guides/overview/pricing // (cache storage limited-time free). "z-ai/glm-5.2" | "glm-5.2" => Some(usd_only_pricing(0.26, 1.40, 4.40)), + // GLM-5.3-Flash list rates (2026-08-26). Promo 50% off until + // 2026-09-09 UTC+8 is not the durable row. + "z-ai/glm-5.3-flash" | "glm-5.3-flash" => Some(usd_only_pricing(0.03, 0.15, 0.50)), // Moonshot K2.7 Code cache-read rate per // https://platform.kimi.ai/docs/pricing/chat-k27-code "moonshotai/kimi-k2.7-code" | "kimi-k2.7-code" => Some(usd_only_pricing(0.19, 0.95, 4.00)), @@ -1835,7 +1838,11 @@ fn provider_owned_hand_pricing_at( // owns a *hand-written price row* for the model, and no GLM-5.3 rate // has been published. An absent price is honest; an owned-but-empty // row is not. See `glm_5_3_has_no_hardcoded_price` below. - ApiProvider::Zai => matches!(model_lower.as_str(), "glm-5.1" | "glm-5.2" | "glm-5-turbo"), + // GLM-5.3-Flash *does* have a published USD list (2026-08-26). + ApiProvider::Zai => matches!( + model_lower.as_str(), + "glm-5.1" | "glm-5.2" | "glm-5.3-flash" | "glm-5-turbo" + ), // `k3` (Kimi Code membership) is deliberately absent: it is quota // billed and must never inherit the direct-platform kimi-k3 rate. ApiProvider::Moonshot => matches!( @@ -3298,6 +3305,8 @@ mod tests { ("z-ai/glm-5.1", 0.26, 1.40, 4.40), ("glm-5.2", 0.26, 1.40, 4.40), ("z-ai/glm-5.2", 0.26, 1.40, 4.40), + ("glm-5.3-flash", 0.03, 0.15, 0.50), + ("z-ai/glm-5.3-flash", 0.03, 0.15, 0.50), ("glm-5-turbo", 0.24, 1.20, 4.00), ("z-ai/glm-5-turbo", 0.24, 1.20, 4.00), ("qwen/qwen3.6-plus", 0.325, 0.325, 1.95), diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 4d9fffbd4f..5048e6c7fc 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -581,7 +581,7 @@ overlay and lets DSH resolve its own keys. | `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. `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. | | `antigravity` | `[providers.antigravity]` | `ANTIGRAVITY_API_KEY` | `ANTIGRAVITY_BASE_URL`; default `https://cloudcode-pa.googleapis.com/v1internal` | none advertised — requests fail closed until the cloud-code wire protocol exists | Antigravity (`agy` 1.1.13) credential plane: consent-gated read-only import of the official CLI's `state.vscdb` OAuth token (`antigravityUnifiedStateSync.oauthToken`), pinned to the exact per-OS app-profile path. The store is opened read-only through the secure no-follow boundary with an inode recheck; Codewhale never writes, refreshes, or re-authenticates. Precedence: `ANTIGRAVITY_API_KEY` > process `AGY_ADC_AUTH` > consented file. Not an embed of any other harness. No live calls made in this environment. | | `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. | -| `zai` | `[providers.zai]` | `ZAI_API_KEY`, `Z_AI_API_KEY` | `ZAI_BASE_URL`, `Z_AI_BASE_URL`; default `https://api.z.ai/api/coding/paas/v4`; general API `https://api.z.ai/api/paas/v4` | `GLM-5.3` default; `/model` also lists `GLM-5.2`, `GLM-5.1`, and `GLM-5-Turbo` | Z.AI GLM Coding Plan route. `GLM-5.3` is the default and a first-class picker row (`model = "GLM-5.3"` or `ZAI_MODEL=GLM-5.3`); an explicit `GLM-5.2` selection keeps its own id. Limits and reasoning options are inherited from `GLM-5.2` until Z.ai publishes distinct 5.3 metadata; it carries no price. A live call can still 429 with entitlement code 1311 on accounts that are not provisioned for 5.3. | +| `zai` | `[providers.zai]` | `ZAI_API_KEY`, `Z_AI_API_KEY` | `ZAI_BASE_URL`, `Z_AI_BASE_URL`; default `https://api.z.ai/api/coding/paas/v4`; general API `https://api.z.ai/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. `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. | | `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. | | `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. | | `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. | @@ -738,15 +738,17 @@ large models verified through OpenRouter's model metadata: context multimodal model for coding, tool use, and long-horizon agentic work. `GLM-5.3` is now the default direct Z.AI Coding Plan model; `GLM-5.2` / `z-ai/glm-5.2` remain available (explicit selections keep their own id), -`GLM-5.1` / `z-ai/glm-5.1` remain available as the smaller model, and -`GLM-5-Turbo` / `z-ai/glm-5-turbo` serve as the faster same-family sibling -used by faster/explore sub-agents. -`GLM-5.3` / `z-ai/glm-5.3` are first-class picker ids on the Z.ai and -OpenRouter routes (`/model` after `/provider zai`, or `model = "GLM-5.3"`). -Limits and reasoning options are inherited from -`GLM-5.2` until Z.ai publishes distinct 5.3 metadata, and they carry no -price. A live call can still 429 with entitlement code 1311 on accounts -that are not provisioned for 5.3. +`GLM-5.1` / `z-ai/glm-5.1` remain available as the smaller model, +`GLM-5.3-Flash` / `z-ai/glm-5.3-flash` is the faster/explore sibling of +`GLM-5.3`, and `GLM-5-Turbo` / `z-ai/glm-5-turbo` remains the faster sibling +of `GLM-5.2`. +`GLM-5.3` / `z-ai/glm-5.3` and `GLM-5.3-Flash` / `z-ai/glm-5.3-flash` are +first-class picker ids on the Z.ai and OpenRouter routes (`/model` after +`/provider zai`, or `model = "GLM-5.3-Flash"`). +Limits and reasoning options for 5.3 are inherited from +`GLM-5.2` until Z.ai publishes distinct 5.3 metadata, and 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. ## Static Model Registry @@ -763,7 +765,7 @@ endpoint when the endpoint supports model listing. | `atlascloud` | `deepseek-ai/deepseek-v4-flash`, `deepseek-ai/deepseek-v4-pro` | yes | yes | | `wanjie-ark` | `deepseek-reasoner` | yes | yes | | `volcengine` | `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash` | yes | yes | -| `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-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 | +| `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 | | `orcarouter` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`, `orcarouter/auto` | yes | yes | | `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 | | `novita` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | yes | yes | @@ -771,7 +773,7 @@ endpoint when the endpoint supports model listing. | `siliconflow` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes | | `arcee` | `trinity-large-thinking`, `trinity-large-preview`; provider-hinted custom model IDs pass through | yes | yes for `trinity-large-thinking`; no for `trinity-large-preview` | | `moonshot` | `kimi-k2.7-code`, `kimi-k2.6` | yes | yes | -| `zai` | `GLM-5.3`, `GLM-5.2`, `GLM-5.1`, `GLM-5-Turbo`; provider-hinted custom model IDs pass through | yes | yes | +| `zai` | `GLM-5.3`, `GLM-5.3-Flash`, `GLM-5.2`, `GLM-5.1`, `GLM-5-Turbo`; provider-hinted custom model IDs pass through | yes | yes | | `stepfun` | `step-3.7-flash` | yes | no | | `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 | | `minimax-anthropic` | `MiniMax-M3`, `MiniMax-M2.7` | yes | yes | @@ -865,6 +867,7 @@ while bare `k3` can use an entitled 1M override. | 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 | | 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 | | 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 | +| 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` | | Direct Z.AI `GLM-5.2` | 1,000,000 | 131,072 | yes | no | not documented in code | | Direct Z.AI `GLM-5.1` | 202,752 | 131,072 | yes | no | not documented in code | | Direct Z.AI `GLM-5-Turbo` | 202,752 | 131,072 | yes | no | faster/explore sub-agent sibling | @@ -981,6 +984,7 @@ Providers marked "omitted" receive no reasoning fields at all for that tier. | 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 | | 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"` | | 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"` | +| 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"` | | First-party Z.ai `GLM-5-Turbo` | `thinking: {type: disabled}` | enabled thinking; effort granularity unavailable | enabled thinking; effort granularity unavailable | | Compatible gateways configured as `zai` | omitted; effective unavailable | omitted; effective unavailable | omitted; effective unavailable | | `nvidia-nim` | `chat_template_kwargs.thinking: false` | `chat_template_kwargs`: `thinking: true` + `reasoning_effort: "high"` | `chat_template_kwargs`: `thinking: true` + `reasoning_effort: "max"` | From 3c9523bba5527b59e179596a46f84b067638a16e Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Thu, 27 Aug 2026 18:33:26 -0700 Subject: [PATCH 14/14] fix(ci): align GLM flash routing and release receipts Update the stale Z.ai fast-tier expectations to the newly registered GLM-5.3-Flash route, including its high-effort capability receipt. Remove three Clippy failures in the catalog and live-model paths, add the durable Signed-off-by: CodeWhale Bot #5643/#5655 changelog receipt, and regenerate the packaged TUI changelog. --- CHANGELOG.md | 4 ++++ crates/config/src/catalog.rs | 2 +- crates/tui/CHANGELOG.md | 7 ++++++- crates/tui/src/model_routing.rs | 8 ++++---- crates/tui/src/models_dev_live.rs | 23 ++++++++++++----------- crates/tui/src/provider_lake.rs | 5 ++--- crates/tui/src/tui/ui/tests.rs | 13 +++++++------ 7 files changed, 36 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c0187a0c7..b5c38c17d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 variants. `/provider` fills URL, model, and env from one catalog row. - MCP manager copy now names the server, the failure, and one recovery command (`The X MCP server requires OAuth reauthentication. Run /mcp login X`). +- Settings Advanced exposes clickable MCP Connect, Reconnect, and Diagnose + actions, while Extensions and Problems route recovery through the existing + `/mcp login`, `/mcp reload`, `/mcp validate`, and `/plugin validate` commands + (#5643, #5655). - Added `/import-claude` (#5557): reads `~/.claude.json` and `~/.claude/settings.json` read-only and renders an explicit, reviewable diff --git a/crates/config/src/catalog.rs b/crates/config/src/catalog.rs index 62011c8721..fbf0eafc00 100644 --- a/crates/config/src/catalog.rs +++ b/crates/config/src/catalog.rs @@ -220,7 +220,7 @@ pub fn bundled_models_dev_catalog() -> &'static ModelsDevCatalog { /// rows override these on `(provider, wire_model_id)` when available. #[must_use] pub fn bundled_catalog_offerings() -> Vec { - bundled_offerings_from_models_dev(&bundled_models_dev_catalog()) + bundled_offerings_from_models_dev(bundled_models_dev_catalog()) } /// Hydrate bundled [`CatalogOffering`] rows from a parsed Models.dev catalog. diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 1217aa60ef..131b300715 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -11,12 +11,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Z.ai `GLM-5.3-Flash` and OpenRouter `z-ai/glm-5.3-flash` are first-class picker rows (`/model GLM-5.3-Flash`). Flash is the faster/explore sibling - of `GLM-5.3`; the Z.ai default stays `GLM-5.3`. + of `GLM-5.3`; the Z.ai default stays `GLM-5.3`. List price is $0.15/$0.50 + per 1M (durable; the 50% promo through 2026-09-09 is not the catalog row). - Baseten, Groq, and Cerebras are bundled OpenAI-compatible setup templates (`[providers.] kind = "openai-compatible"`), not new `ProviderKind` variants. `/provider` fills URL, model, and env from one catalog row. - MCP manager copy now names the server, the failure, and one recovery command (`The X MCP server requires OAuth reauthentication. Run /mcp login X`). +- Settings Advanced exposes clickable MCP Connect, Reconnect, and Diagnose + actions, while Extensions and Problems route recovery through the existing + `/mcp login`, `/mcp reload`, `/mcp validate`, and `/plugin validate` commands + (#5643, #5655). - Added `/import-claude` (#5557): reads `~/.claude.json` and `~/.claude/settings.json` read-only and renders an explicit, reviewable diff --git a/crates/tui/src/model_routing.rs b/crates/tui/src/model_routing.rs index 72c61751ec..45a78dd356 100644 --- a/crates/tui/src/model_routing.rs +++ b/crates/tui/src/model_routing.rs @@ -1605,7 +1605,7 @@ mod tests { .expect("inventory route should resolve with authenticated active provider"); assert_eq!(route.provider, ApiProvider::Zai); - assert_eq!(route.model, crate::config::ZAI_GLM_5_TURBO_MODEL); + assert_eq!(route.model, crate::config::ZAI_GLM_5_3_FLASH_MODEL); assert_eq!(route.source, AutoRouteSource::Heuristic); let receipt = route.receipt.expect("Auto route receipt"); assert_eq!(receipt.tier, AutoRouteTier::Fast); @@ -1618,7 +1618,7 @@ mod tests { assert_eq!(receipt.pair.strong, crate::config::DEFAULT_ZAI_MODEL); assert_eq!( receipt.pair.fast.as_deref(), - Some(crate::config::ZAI_GLM_5_TURBO_MODEL) + Some(crate::config::ZAI_GLM_5_3_FLASH_MODEL) ); } @@ -1753,7 +1753,7 @@ mod tests { .await .expect("fast-tier route"); assert_eq!(fast.provider, ApiProvider::Zai); - assert_eq!(fast.model, crate::config::ZAI_GLM_5_TURBO_MODEL); + assert_eq!(fast.model, crate::config::ZAI_GLM_5_3_FLASH_MODEL); assert_eq!( fast.receipt.expect("fast receipt").tier, AutoRouteTier::Fast @@ -1939,7 +1939,7 @@ mod tests { assert_eq!(cost_saving_route.provider, ApiProvider::Zai); assert_eq!( cost_saving_route.model, - crate::config::ZAI_GLM_5_TURBO_MODEL + crate::config::ZAI_GLM_5_3_FLASH_MODEL ); assert_eq!(cost_saving_route.source, AutoRouteSource::Heuristic); assert_eq!( diff --git a/crates/tui/src/models_dev_live.rs b/crates/tui/src/models_dev_live.rs index 0531f2269f..52af6f348d 100644 --- a/crates/tui/src/models_dev_live.rs +++ b/crates/tui/src/models_dev_live.rs @@ -430,17 +430,18 @@ fn load_cache_file(path: &Path) -> Option { // catalog body. One small parse, zero body copies. if bytes.first() == Some(&b'{') && bytes.contains(&b'\n') { let split = bytes.iter().position(|b| *b == b'\n')?; - if let Ok(header) = serde_json::from_slice::(&bytes[..split]) { - if header.schema_version == CACHE_SCHEMA_VERSION_V2 && !bytes[split + 1..].is_empty() { - let body = String::from_utf8(bytes[split + 1..].to_vec()).ok()?; - return Some(PersistedModelsDevCache { - schema_version: CACHE_SCHEMA_VERSION, - fetched_at: header.fetched_at, - source_fingerprint: header.source_fingerprint, - source_label: header.source_label, - body, - }); - } + if let Ok(header) = serde_json::from_slice::(&bytes[..split]) + && header.schema_version == CACHE_SCHEMA_VERSION_V2 + && !bytes[split + 1..].is_empty() + { + let body = String::from_utf8(bytes[split + 1..].to_vec()).ok()?; + return Some(PersistedModelsDevCache { + schema_version: CACHE_SCHEMA_VERSION, + fetched_at: header.fetched_at, + source_fingerprint: header.source_fingerprint, + source_label: header.source_label, + body, + }); } } // v1 fallback: whole-file JSON envelope with the body escaped inside. diff --git a/crates/tui/src/provider_lake.rs b/crates/tui/src/provider_lake.rs index 3944958c29..9f85872f6d 100644 --- a/crates/tui/src/provider_lake.rs +++ b/crates/tui/src/provider_lake.rs @@ -138,10 +138,9 @@ fn apply_provider_model_cutlines(mut snapshot: CatalogSnapshot) -> CatalogSnapsh .offerings .into_iter() .filter_map(|mut offering| { - let parsed = resolved + let parsed = *resolved .entry(offering.provider.clone()) - .or_insert_with(|| ApiProvider::parse(&offering.provider)) - .clone(); + .or_insert_with(|| ApiProvider::parse(&offering.provider)); if parsed == Some(ApiProvider::OpencodeGo) { let canonical = opencode_go_chat_model_id(&offering.wire_model_id)?; offering.provider = ApiProvider::OpencodeGo.as_str().to_string(); diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 572ee9f74b..ba2b1960c6 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -8454,7 +8454,11 @@ async fn auto_dispatch_keeps_last_and_pending_receipts_aligned() { app.pending_turn_route .as_ref() .map(|(provider, model, auto)| (*provider, model.as_str(), *auto)), - Some((ApiProvider::Zai, crate::config::ZAI_GLM_5_TURBO_MODEL, true)) + Some(( + ApiProvider::Zai, + crate::config::ZAI_GLM_5_3_FLASH_MODEL, + true, + )) ); assert_eq!( app.last_auto_route_receipt, app.pending_auto_route_receipt, @@ -8468,13 +8472,10 @@ async fn auto_dispatch_keeps_last_and_pending_receipts_aligned() { ); assert_eq!( app.last_effective_reasoning_effort, - Some(EffectiveReasoningEffort::ThinkingEnabledGranularityUnavailable), + Some(EffectiveReasoningEffort::Tier(ReasoningEffort::High)), "the post-turn receipt must retain exact route capability constraints" ); - assert_eq!( - app.reasoning_effort_display_label(), - "low→thinking enabled; granularity unavailable" - ); + assert_eq!(app.reasoning_effort_display_label(), "low→high"); } #[tokio::test]