From 08f1bd7cc5c7e676d3bd9332f5fdcfd73bfcaa85 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Wed, 16 Sep 2026 05:00:59 +0800 Subject: [PATCH] Source the Claude Code model catalog from the t3code manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-written Claude catalog (models, option descriptors, effort special cases, version gates and the [1m]/context-window table) with the t3code model manifest. A verbatim copy is bundled as the offline fallback; `list_models` refreshes it from t3code main at most hourly (5 min back-off, 10 s timeout, 1 MiB cap), caches the last good copy in the data dir, and skips the network when provider update checks are disabled. A bundle newer by `updatedAt` outranks the cache, and invalid data never replaces a usable manifest. The manifest's `effort` id is translated to `reasoningEffort` at the boundary; effortMap drives the --effort flag, modelSuffixes the [1m] suffix, and contextWindowTokens/fixedContextWindowTokens the window a selection resolves to. Upstream data is adopted wholesale, so some defaults change (Fable 5.1 effort medium, Sonnet 5 window 200k, Opus 4.7 and 4.8 fixed 1M without a selector, fastMode on Opus 4.5–4.8 and Opus 5). Also initialises the `title_generating` field in a sidebar test fixture that no longer compiled after #428. --- crates/agent/examples/probe.rs | 2 +- crates/agent/src/claude.rs | 655 ++++---------- crates/agent/src/claude_context.rs | 18 +- crates/agent/src/claude_manifest.rs | 944 ++++++++++++++++++++ crates/agent/src/claude_model_manifest.json | 677 ++++++++++++++ crates/agent/src/lib.rs | 31 +- crates/agent/src/process.rs | 16 +- crates/runtime/src/app/mod.rs | 9 +- crates/runtime/src/app/providers.rs | 8 +- docs/claude-models.md | 23 + locales/en.yml | 2 +- locales/zh-CN.yml | 2 +- 12 files changed, 1887 insertions(+), 500 deletions(-) create mode 100644 crates/agent/src/claude_manifest.rs create mode 100644 crates/agent/src/claude_model_manifest.json create mode 100644 docs/claude-models.md diff --git a/crates/agent/examples/probe.rs b/crates/agent/examples/probe.rs index 2c6c9a70..252ee317 100644 --- a/crates/agent/examples/probe.rs +++ b/crates/agent/examples/probe.rs @@ -92,7 +92,7 @@ fn main() { if args.first().map(String::as_str) == Some("--list-models") { let provider = parse_provider(args.get(1).map(String::as_str)); let exit_code = smol::block_on(async move { - match list_models(provider, None, Default::default()).await { + match list_models(provider, None, Default::default(), Default::default()).await { Ok(models) => { println!("{}", serde_json::to_string_pretty(&models).unwrap()); 0 diff --git a/crates/agent/src/claude.rs b/crates/agent/src/claude.rs index 99428b9c..b4ac8ef5 100644 --- a/crates/agent/src/claude.rs +++ b/crates/agent/src/claude.rs @@ -32,16 +32,16 @@ use smol::prelude::*; use smol::process::Stdio; pub use crate::claude_context::{ - format_context_window, native_context_window, parse_context_window_tokens, - resolved_context_window, + format_context_window, parse_context_window_tokens, resolved_context_window, }; +use crate::claude_manifest::{CatalogModel, ClaudeCatalog}; use crate::{ AgentError, AgentEvent, ApprovalDecision, ApprovalKind, ApprovalMode, ApprovalRequest, - Attachment, ClassifierCategory, DeltaKind, FileChange, FileChangeKind, InteractionMode, - ItemContent, ItemStatus, LaunchEnv, ModelSpec, OptionDescriptor, OptionSelection, PlanStep, - PlanStepStatus, ProviderCommand, ProviderCommandKind, ProviderKind, ResumeCursor, RewindMode, - SelectOption, SessionCommand, SessionHandle, SessionOptions, ThreadItem, TokenUsage, - TurnStatus, UserInputOption, UserInputQuestion, selection_bool, selection_str, + Attachment, CatalogRefresh, ClassifierCategory, DeltaKind, FileChange, FileChangeKind, + InteractionMode, ItemContent, ItemStatus, LaunchEnv, ModelSpec, OptionDescriptor, + OptionSelection, PlanStep, PlanStepStatus, ProviderCommand, ProviderCommandKind, ProviderKind, + ResumeCursor, RewindMode, SessionCommand, SessionHandle, SessionOptions, ThreadItem, + TokenUsage, TurnStatus, UserInputOption, UserInputQuestion, selection_bool, selection_str, }; /// Denial returned to `ExitPlanMode` after the client captures the plan. @@ -100,10 +100,9 @@ fn effective_permission_mode(internal: &str, extra_args: &[String]) -> String { /// Start (or resume) a Claude Code session. pub async fn start(opts: SessionOptions) -> Result { - let native_rewind = version_ge( - claude_version(opts.binary_path.as_deref(), &opts.launch_env).await, - NATIVE_REWIND_MIN_VERSION, - ); + let native_rewind = claude_version(opts.binary_path.as_deref(), &opts.launch_env) + .await + .is_some_and(|version| version >= NATIVE_REWIND_MIN_VERSION); // Absolute path: a bare name would be resolved against the session cwd we // set below, which breaks PATH lookup (see `resolve_binary`). let binary = crate::resolve_binary(opts.binary_path.as_deref(), "claude")?; @@ -401,10 +400,11 @@ fn mcp_args(registrations: &[crate::McpRegistration]) -> Vec { /// Model-scoped launch flags resolved from the session's option selections. struct ClaudeLaunchOptions { - /// Model id with a `[1m]` suffix appended for the 1M context window. + /// Model id with the manifest's context-window suffix (e.g. `[1m]`) when + /// the selected window needs it. model_id: Option, - /// Normalized `--effort` value (`None` when the selection is `ultrathink`, - /// which is a prompt-prefix mode). + /// `--effort` value after the manifest's `effortMap` (`None` when the + /// selection maps to no flag, e.g. `ultrathink`, a prompt-prefix mode). effort: Option, /// `--settings` JSON string (fastMode / ultracode / alwaysThinkingEnabled). settings_json: Option, @@ -437,39 +437,42 @@ fn launch_settings_json( impl ClaudeLaunchOptions { fn resolve(model: Option<&str>, selections: &[OptionSelection]) -> Self { - let spec = model.and_then(model_spec); + Self::resolve_with(&crate::claude_manifest::current(), model, selections) + } + + fn resolve_with( + catalog: &ClaudeCatalog, + model: Option<&str>, + selections: &[OptionSelection], + ) -> Self { + let entry = model.and_then(|model| catalog.model(model)); + let spec = entry.map(|entry| &entry.spec); let raw_effort = selection_str(selections, "reasoningEffort"); - let resolved_effort = resolve_claude_effort(spec.as_ref(), raw_effort.as_deref()); + let resolved_effort = resolve_claude_effort(spec, raw_effort.as_deref()); let ultrathink = resolved_effort.as_deref() == Some("ultrathink"); let ultracode = resolved_effort.as_deref() == Some("ultracode"); - let effort = normalize_claude_cli_effort(resolved_effort.as_deref(), model); - - let window = resolved_context_window(model.unwrap_or_default(), selections); - let native_window = native_context_window(model.unwrap_or_default()); - let effective_model_window = if native_window == 200_000 && window > native_window { - 1_000_000 - } else { - native_window + let effort = match (entry, resolved_effort.as_deref()) { + (Some(entry), Some(effort)) => entry.cli_effort(effort), + _ => None, }; - let model_id = model.map(|m| { - let base = m.strip_suffix("[1m]").unwrap_or(m); - if native_window == 200_000 && window > native_window { - format!("{base}[1m]") - } else { - base.to_owned() - } + + let window = catalog.resolved_context_window(model.unwrap_or_default(), selections); + let model_id = model.map(|model| { + let base = model.split('[').next().unwrap_or(model); + let suffix = entry.map_or("", |entry| entry.context_window_suffix(window)); + format!("{base}{suffix}") }); - let auto_compact_window = (window < effective_model_window).then_some(window); + // Anything below the model's largest window is enforced through + // `autoCompactWindow`: some bare slugs (e.g. Opus 5) already run at + // 1M, so a smaller selection must be told to compact early. + let largest = entry + .and_then(CatalogModel::largest_context_window) + .unwrap_or(window); + let auto_compact_window = (window < largest).then_some(window); // `--settings` object: only supported/true keys are emitted. - let fast_supported = spec - .as_ref() - .map(|s| has_boolean_option(s, "fastMode")) - .unwrap_or(false); - let thinking_supported = spec - .as_ref() - .map(|s| has_boolean_option(s, "thinking")) - .unwrap_or(false); + let fast_supported = spec.is_some_and(|spec| has_boolean_option(spec, "fastMode")); + let thinking_supported = spec.is_some_and(|spec| has_boolean_option(spec, "thinking")); let fast_mode = fast_supported && selection_bool(selections, "fastMode") == Some(true); let thinking = if thinking_supported { selection_bool(selections, "thinking") @@ -3166,253 +3169,6 @@ fn resolve_claude_effort(spec: Option<&ModelSpec>, raw: Option<&str>) -> Option< default_value.clone() } -/// Normalize special effort modes for the Claude CLI: `ultrathink` → no flag -/// (prompt prefix); `ultracode` → `xhigh`; `xhigh` → `max` except Fable 5.x / -/// Opus 5 / Opus 4.8 / Sonnet 5; Sonnet 4.6 `max` → `high`; otherwise -/// passthrough. -fn normalize_claude_cli_effort(effort: Option<&str>, model: Option<&str>) -> Option { - let effort = effort?; - if effort == "ultrathink" { - return None; - } - if effort == "ultracode" { - return Some("xhigh".to_owned()); - } - if effort == "xhigh" - && model != Some("claude-fable-5-1") - && model != Some("claude-fable-5") - && model != Some("claude-opus-5") - && model != Some("claude-opus-4-8") - && model != Some("claude-sonnet-5") - { - return Some("max".to_owned()); - } - if effort == "max" && model == Some("claude-sonnet-4-6") { - return Some("high".to_owned()); - } - Some(effort.to_owned()) -} - -fn effort_option(value: &str) -> SelectOption { - let label = match value { - "low" => "Low", - "medium" => "Medium", - "high" => "High", - "xhigh" => "Extra High", - "max" => "Max", - "ultracode" => "Ultracode", - "ultrathink" => "Ultrathink", - other => other, - }; - SelectOption { - value: value.to_owned(), - label: label.to_owned(), - description: None, - } -} - -fn reasoning(values: &[&str], default: &str) -> OptionDescriptor { - OptionDescriptor::Select { - id: "reasoningEffort".to_owned(), - label: "Reasoning".to_owned(), - options: values.iter().map(|v| effort_option(v)).collect(), - default_value: Some(default.to_owned()), - } -} - -fn context_window(default: &str) -> OptionDescriptor { - OptionDescriptor::Select { - id: "contextWindow".to_owned(), - label: "Context Window".to_owned(), - options: vec![ - SelectOption { - value: "200k".to_owned(), - label: "200k".to_owned(), - description: None, - }, - SelectOption { - value: "1m".to_owned(), - label: "1M".to_owned(), - description: None, - }, - ], - default_value: Some(default.to_owned()), - } -} - -fn boolean(id: &str, label: &str) -> OptionDescriptor { - OptionDescriptor::Boolean { - id: id.to_owned(), - label: label.to_owned(), - default_value: false, - } -} - -fn model(id: &str, display_name: &str, options: Vec) -> ModelSpec { - ModelSpec { - id: id.to_owned(), - display_name: display_name.to_owned(), - is_default: false, - options, - } -} - -/// The full static Claude catalog, unfiltered by installed CLI version. -fn built_in_models() -> Vec { - vec![ - model( - "claude-fable-5-1", - "Claude Fable 5.1", - vec![ - reasoning( - &[ - "low", - "medium", - "high", - "xhigh", - "max", - "ultracode", - "ultrathink", - ], - "high", - ), - context_window("1m"), - ], - ), - model( - "claude-fable-5", - "Claude Fable 5", - vec![ - reasoning( - &[ - "low", - "medium", - "high", - "xhigh", - "max", - "ultracode", - "ultrathink", - ], - "high", - ), - context_window("1m"), - ], - ), - model( - "claude-opus-5", - "Claude Opus 5", - vec![ - reasoning( - &[ - "low", - "medium", - "high", - "xhigh", - "max", - "ultracode", - "ultrathink", - ], - "high", - ), - boolean("fastMode", "Fast Mode"), - context_window("1m"), - ], - ), - model( - "claude-opus-4-8", - "Claude Opus 4.8", - vec![ - reasoning( - &[ - "low", - "medium", - "high", - "xhigh", - "max", - "ultracode", - "ultrathink", - ], - "high", - ), - boolean("fastMode", "Fast Mode"), - context_window("1m"), - ], - ), - model( - "claude-opus-4-7", - "Claude Opus 4.7", - vec![ - reasoning( - &["low", "medium", "high", "xhigh", "max", "ultrathink"], - "xhigh", - ), - context_window("1m"), - ], - ), - model( - "claude-opus-4-6", - "Claude Opus 4.6", - vec![ - reasoning(&["low", "medium", "high", "max", "ultrathink"], "high"), - context_window("200k"), - ], - ), - model( - "claude-opus-4-5", - "Claude Opus 4.5", - vec![reasoning(&["low", "medium", "high", "max"], "high")], - ), - model( - "claude-sonnet-5", - "Claude Sonnet 5", - vec![ - reasoning( - &["low", "medium", "high", "xhigh", "max", "ultrathink"], - "high", - ), - context_window("1m"), - ], - ), - model( - "claude-sonnet-4-6", - "Claude Sonnet 4.6", - vec![ - reasoning(&["low", "medium", "high", "max", "ultrathink"], "high"), - context_window("200k"), - ], - ), - model( - "claude-haiku-4-5", - "Claude Haiku 4.5", - vec![boolean("thinking", "Thinking")], - ), - ] -} - -/// Capabilities for one model id (from the unfiltered catalog). -fn model_spec(id: &str) -> Option { - let id = id.trim(); - built_in_models().into_iter().find(|m| m.id == id) -} - -/// Whether a version-gated model is available at the installed Claude version. -fn model_available(id: &str, version: Option<(u32, u32, u32)>) -> bool { - match id { - "claude-fable-5-1" => version_ge(version, (2, 1, 257)), - "claude-opus-5" => version_ge(version, (2, 1, 219)), - "claude-fable-5" => version_ge(version, (2, 1, 169)), - "claude-opus-4-8" => version_ge(version, (2, 1, 154)), - "claude-opus-4-7" => version_ge(version, (2, 1, 111)), - _ => true, - } -} - -fn version_ge(version: Option<(u32, u32, u32)>, min: (u32, u32, u32)) -> bool { - version.is_some_and(|v| v >= min) -} - -/// Parse a `MAJOR.MINOR.PATCH` triple from `claude --version` output -/// (e.g. `"2.1.206 (Claude Code)"`). /// Run `claude --version` and parse the semver triple; `None` on any failure. async fn claude_version(binary: Option<&Path>, launch_env: &LaunchEnv) -> Option<(u32, u32, u32)> { // Resolve through the PATH search (PATHEXT-aware: on Windows the CLI only @@ -3423,16 +3179,19 @@ async fn claude_version(binary: Option<&Path>, launch_env: &LaunchEnv) -> Option crate::process::probe_version(&bin, launch_env, ProviderKind::ClaudeCode).await } -/// List Claude's models: the static catalog, gated by the installed CLI version. +/// List Claude's models: the manifest catalog (refreshed per `refresh`), +/// gated by the installed CLI version. pub async fn list_models( binary_path: Option, launch_env: LaunchEnv, + refresh: CatalogRefresh, ) -> Result, AgentError> { + crate::process::unblock(move || { + crate::claude_manifest::refresh(refresh.cache_dir.as_deref(), refresh.network) + }) + .await; let version = claude_version(binary_path.as_deref(), &launch_env).await; - Ok(built_in_models() - .into_iter() - .filter(|m| model_available(&m.id, version)) - .collect()) + Ok(crate::claude_manifest::current().models_for_version(version)) } #[cfg(test)] @@ -3736,113 +3495,109 @@ mod tests { )); } + fn select(id: &str, value: Value) -> OptionSelection { + OptionSelection { + id: id.into(), + value, + } + } + + fn settings(launch: &ClaudeLaunchOptions) -> Value { + launch + .settings_json + .as_deref() + .map(|settings| serde_json::from_str(settings).unwrap()) + .unwrap_or(Value::Null) + } + #[test] - fn effort_compat_transforms() { - // ultrathink → no flag (prompt-prefix mode) - assert_eq!( - normalize_claude_cli_effort(Some("ultrathink"), Some("claude-opus-4-8")), - None - ); - // ultracode → xhigh - assert_eq!( - normalize_claude_cli_effort(Some("ultracode"), Some("claude-opus-4-8")).as_deref(), - Some("xhigh") - ); - // xhigh → max EXCEPT on fable-5 / opus-4-8 / sonnet-5 - assert_eq!( - normalize_claude_cli_effort(Some("xhigh"), Some("claude-opus-4-7")).as_deref(), - Some("max") - ); - assert_eq!( - normalize_claude_cli_effort(Some("xhigh"), Some("claude-fable-5-1")).as_deref(), - Some("xhigh") - ); - assert_eq!( - normalize_claude_cli_effort(Some("xhigh"), Some("claude-fable-5")).as_deref(), - Some("xhigh") - ); - assert_eq!( - normalize_claude_cli_effort(Some("xhigh"), Some("claude-opus-4-8")).as_deref(), - Some("xhigh") - ); - assert_eq!( - normalize_claude_cli_effort(Some("xhigh"), Some("claude-sonnet-5")).as_deref(), - Some("xhigh") - ); - // sonnet-4-6 max → high + fn effort_map_drives_the_effort_flag() { + let catalog = crate::claude_manifest::test_catalog(); + let resolve = |model: &str, effort: &str| { + ClaudeLaunchOptions::resolve_with( + &catalog, + Some(model), + &[select("reasoningEffort", json!(effort))], + ) + }; + // Mapped to another value: ultracode → xhigh, plus the ultracode setting. + let launch = resolve("test-wide", "ultracode"); + assert_eq!(launch.effort.as_deref(), Some("xhigh")); + assert_eq!(settings(&launch)["ultracode"], true); + assert!(!launch.ultrathink); + // Mapped to null: no --effort flag, prompt-prefix mode. + let launch = resolve("test-wide", "ultrathink"); + assert_eq!(launch.effort, None); + assert!(launch.ultrathink); + assert!(launch.settings_json.is_none()); + // Per-profile downgrade (max → high) and passthrough. assert_eq!( - normalize_claude_cli_effort(Some("max"), Some("claude-sonnet-4-6")).as_deref(), + resolve("test-narrow", "max").effort.as_deref(), Some("high") ); - // passthrough + assert_eq!(resolve("test-wide", "max").effort.as_deref(), Some("max")); assert_eq!( - normalize_claude_cli_effort(Some("low"), Some("claude-opus-4-6")).as_deref(), - Some("low") + resolve("test-fixed", "xhigh").effort.as_deref(), + Some("max") ); + // Unknown model: no descriptor to resolve against, so no flag. + assert_eq!(resolve("test-unknown", "high").effort, None); } #[test] fn resolve_effort_uses_listed_value_or_default() { - let fable = model_spec("claude-fable-5"); - // Listed value wins. + let catalog = crate::claude_manifest::test_catalog(); + let wide = catalog.model("test-wide").map(|entry| &entry.spec); assert_eq!( - resolve_claude_effort(fable.as_ref(), Some("max")).as_deref(), + resolve_claude_effort(wide, Some("max")).as_deref(), Some("max") ); - // Unknown value falls back to the descriptor default (high). assert_eq!( - resolve_claude_effort(fable.as_ref(), Some("bogus")).as_deref(), - Some("high") - ); - // No selection → default. - assert_eq!( - resolve_claude_effort(fable.as_ref(), None).as_deref(), - Some("high") + resolve_claude_effort(wide, Some("bogus")).as_deref(), + Some("medium") ); - // Haiku has no reasoning selector. - let haiku = model_spec("claude-haiku-4-5"); - assert_eq!(resolve_claude_effort(haiku.as_ref(), Some("low")), None); + assert_eq!(resolve_claude_effort(wide, None).as_deref(), Some("medium")); + // A profile without a reasoning selector has no effort at all. + let plain = catalog.model("test-plain").map(|entry| &entry.spec); + assert_eq!(resolve_claude_effort(plain, Some("low")), None); } #[test] - fn version_gating_filters_new_models() { + fn version_gating_follows_manifest_bounds() { + let catalog = crate::claude_manifest::test_catalog(); let ids = |version: Option<(u32, u32, u32)>| -> Vec { - built_in_models() + catalog + .models_for_version(version) .into_iter() - .filter(|m| model_available(&m.id, version)) - .map(|m| m.id) + .map(|model| model.id) .collect() }; - // Current version exposes everything. - assert!(ids(Some((2, 1, 219))).contains(&"claude-opus-5".to_string())); - assert!(ids(Some((2, 1, 206))).contains(&"claude-fable-5".to_string())); - // Below every gate: opus-5 / fable-5 / opus-4-8 / opus-4-7 hidden, rest visible. - let old = ids(Some((2, 1, 100))); - assert!(!old.contains(&"claude-opus-5".to_string())); - assert!(!old.contains(&"claude-fable-5".to_string())); - assert!(!old.contains(&"claude-opus-4-8".to_string())); - assert!(!old.contains(&"claude-opus-4-7".to_string())); - assert!(old.contains(&"claude-opus-4-6".to_string())); - assert!(old.contains(&"claude-haiku-4-5".to_string())); - // Exact boundary is inclusive. - assert!(ids(Some((2, 1, 257))).contains(&"claude-fable-5-1".to_string())); - assert!(!ids(Some((2, 1, 256))).contains(&"claude-fable-5-1".to_string())); - assert!(ids(Some((2, 1, 154))).contains(&"claude-opus-4-8".to_string())); - assert!(!ids(Some((2, 1, 153))).contains(&"claude-opus-4-8".to_string())); - assert!(ids(Some((2, 1, 219))).contains(&"claude-opus-5".to_string())); - assert!(!ids(Some((2, 1, 218))).contains(&"claude-opus-5".to_string())); - // Unknown version hides gated models. - assert!(!ids(None).contains(&"claude-fable-5".to_string())); + // Ungated models are always listed, even without a known version. + assert_eq!(ids(None), ["test-narrow", "test-plain"]); + // minVersion is inclusive; maxVersionExclusive is not. + assert_eq!( + ids(Some((2, 1, 256))), + ["test-fixed", "test-narrow", "test-plain"] + ); + assert_eq!( + ids(Some((2, 1, 257))), + ["test-wide", "test-fixed", "test-narrow", "test-plain"] + ); + assert_eq!(ids(Some((2, 1, 110))), ["test-narrow", "test-plain"]); + assert_eq!( + ids(Some((3, 0, 0))), + ["test-wide", "test-narrow", "test-plain"] + ); } #[test] fn parse_semver_from_version_output() { assert_eq!( - crate::process::parse_semver("2.1.206 (Claude Code)"), + crate::parse_semver("2.1.206 (Claude Code)"), Some((2, 1, 206)) ); - assert_eq!(crate::process::parse_semver("2.1.169"), Some((2, 1, 169))); - assert_eq!(crate::process::parse_semver("nonsense"), None); + assert_eq!(crate::parse_semver("2.1.169"), Some((2, 1, 169))); + assert_eq!(crate::parse_semver("nonsense"), None); } #[test] @@ -3861,8 +3616,6 @@ mod tests { assert_eq!(parse_context_window_tokens(&json!("garbage")), None); assert_eq!(parse_context_window_tokens(&json!(-200_000)), None); assert_eq!(parse_context_window_tokens(&json!(null)), None); - assert_eq!(native_context_window("claude-opus-5[1m]"), 1_000_000); - assert_eq!(native_context_window("claude-sonnet-4-6[1m]"), 200_000); assert_eq!(format_context_window(200_000), "200k"); assert_eq!(format_context_window(750_000), "750k"); assert_eq!(format_context_window(1_000_000), "1M"); @@ -3870,108 +3623,88 @@ mod tests { #[test] fn context_window_launch_semantics() { - let resolve = |model, value: Option| { - let selections = value - .map(|value| { - vec![OptionSelection { - id: "contextWindow".into(), - value, - }] - }) - .unwrap_or_default(); - ClaudeLaunchOptions::resolve(Some(model), &selections) - }; - let auto_compact = |launch: &ClaudeLaunchOptions| { - launch.settings_json.as_deref().map(|settings| { - serde_json::from_str::(settings).unwrap()["autoCompactWindow"].clone() - }) + let catalog = crate::claude_manifest::test_catalog(); + let resolve = |model: &str, value: Option| { + let selections: Vec<_> = value + .map(|value| select("contextWindow", value)) + .into_iter() + .collect(); + ClaudeLaunchOptions::resolve_with(&catalog, Some(model), &selections) }; + let auto_compact = + |launch: &ClaudeLaunchOptions| settings(launch)["autoCompactWindow"].clone(); - let launch = resolve("claude-opus-5", Some(json!("200k"))); - assert_eq!(launch.model_id.as_deref(), Some("claude-opus-5")); - assert_eq!(auto_compact(&launch), Some(json!(200_000))); - - let launch = resolve("claude-opus-5", Some(json!("1m"))); - assert_eq!(launch.model_id.as_deref(), Some("claude-opus-5")); - assert!(launch.settings_json.is_none()); - - let launch = resolve("claude-opus-5", Some(json!(500_000))); - assert_eq!(launch.model_id.as_deref(), Some("claude-opus-5")); - assert_eq!(auto_compact(&launch), Some(json!(500_000))); - - let launch = resolve("claude-sonnet-4-6", Some(json!("1m"))); - assert_eq!(launch.model_id.as_deref(), Some("claude-sonnet-4-6[1m]")); + // Default window of a 1M-default profile: the manifest suffix is sent. + let launch = resolve("test-wide", None); + assert_eq!(launch.model_id.as_deref(), Some("test-wide[1m]")); assert!(launch.settings_json.is_none()); + assert_eq!(catalog.resolved_context_window("test-wide", &[]), 1_000_000); + // A suffixed id resolves to the same model. + assert_eq!( + catalog.resolved_context_window("test-wide[1m]", &[]), + 1_000_000 + ); - let launch = resolve("claude-sonnet-4-6", Some(json!(500_000))); - assert_eq!(launch.model_id.as_deref(), Some("claude-sonnet-4-6[1m]")); - assert_eq!(auto_compact(&launch), Some(json!(500_000))); + // Below the largest window: bare slug plus early compaction. + let launch = resolve("test-wide", Some(json!("200k"))); + assert_eq!(launch.model_id.as_deref(), Some("test-wide")); + assert_eq!(auto_compact(&launch), json!(200_000)); + let launch = resolve("test-wide", Some(json!(500_000))); + assert_eq!(launch.model_id.as_deref(), Some("test-wide[1m]")); + assert_eq!(auto_compact(&launch), json!(500_000)); + // 200k-default profile: only the 1M expansion carries the suffix. for value in [Some(json!("200k")), None] { - let launch = resolve("claude-sonnet-4-6", value); - assert_eq!(launch.model_id.as_deref(), Some("claude-sonnet-4-6")); - assert!(launch.settings_json.is_none()); + let launch = resolve("test-narrow", value); + assert_eq!(launch.model_id.as_deref(), Some("test-narrow")); + assert_eq!(auto_compact(&launch), json!(200_000)); } - - let launch = resolve("claude-fable-5", Some(json!("1m"))); - assert_eq!(launch.model_id.as_deref(), Some("claude-fable-5")); + let launch = resolve("test-narrow", Some(json!("1m"))); + assert_eq!(launch.model_id.as_deref(), Some("test-narrow[1m]")); assert!(launch.settings_json.is_none()); - } + let launch = resolve("test-narrow", Some(json!(500_000))); + assert_eq!(launch.model_id.as_deref(), Some("test-narrow[1m]")); + assert_eq!(auto_compact(&launch), json!(500_000)); - #[test] - fn launch_options_resolve_effort_context_and_settings() { - // Ultracode → effort xhigh + settings.ultracode. - let launch = ClaudeLaunchOptions::resolve( - Some("claude-opus-4-8"), - &[ - OptionSelection { - id: "reasoningEffort".into(), - value: json!("ultracode"), - }, - OptionSelection { - id: "fastMode".into(), - value: json!(true), - }, - ], + // Fixed window: no selector, no suffix, custom values still compact early. + assert_eq!( + catalog.resolved_context_window("test-fixed", &[]), + 1_000_000 ); - assert_eq!(launch.model_id.as_deref(), Some("claude-opus-4-8")); - assert_eq!(launch.effort.as_deref(), Some("xhigh")); - assert!(!launch.ultrathink); - let settings: Value = - serde_json::from_str(launch.settings_json.as_deref().unwrap()).unwrap(); - assert_eq!(settings["ultracode"], true); - assert_eq!(settings["fastMode"], true); - - // ultrathink → no --effort, prompt-prefix flag set. - let launch = ClaudeLaunchOptions::resolve( - Some("claude-fable-5"), - &[ - OptionSelection { - id: "reasoningEffort".into(), - value: json!("ultrathink"), - }, - OptionSelection { - id: "contextWindow".into(), - value: json!("1m"), - }, - ], + let launch = resolve("test-fixed", Some(json!("1m"))); + assert_eq!(launch.model_id.as_deref(), Some("test-fixed")); + assert!(launch.settings_json.is_none()); + let launch = resolve("test-fixed", Some(json!(500_000))); + assert_eq!(launch.model_id.as_deref(), Some("test-fixed")); + assert_eq!(auto_compact(&launch), json!(500_000)); + + // No context data at all falls back to 200k and never emits settings. + assert_eq!(catalog.resolved_context_window("test-plain", &[]), 200_000); + assert_eq!( + catalog.resolved_context_window("test-unknown", &[]), + 200_000 ); - assert_eq!(launch.model_id.as_deref(), Some("claude-fable-5")); - assert_eq!(launch.effort, None); - assert!(launch.ultrathink); + let launch = resolve("test-plain", Some(json!(500_000))); + assert_eq!(launch.model_id.as_deref(), Some("test-plain")); assert!(launch.settings_json.is_none()); + } - // Haiku thinking → settings.alwaysThinkingEnabled. - let launch = ClaudeLaunchOptions::resolve( - Some("claude-haiku-4-5"), - &[OptionSelection { - id: "thinking".into(), - value: json!(true), - }], - ); - let settings: Value = - serde_json::from_str(launch.settings_json.as_deref().unwrap()).unwrap(); - assert_eq!(settings["alwaysThinkingEnabled"], true); + #[test] + fn boolean_options_reach_settings_only_when_supported() { + let catalog = crate::claude_manifest::test_catalog(); + let both = [ + select("fastMode", json!(true)), + select("thinking", json!(true)), + ]; + let launch = ClaudeLaunchOptions::resolve_with(&catalog, Some("test-fixed"), &both); + let fixed = settings(&launch); + assert_eq!(fixed["fastMode"], true); + assert!(fixed.get("alwaysThinkingEnabled").is_none()); + + let launch = ClaudeLaunchOptions::resolve_with(&catalog, Some("test-plain"), &both); + let plain = settings(&launch); + assert_eq!(plain["alwaysThinkingEnabled"], true); + assert!(plain.get("fastMode").is_none()); } #[test] diff --git a/crates/agent/src/claude_context.rs b/crates/agent/src/claude_context.rs index 7ee1aee4..6e54393f 100644 --- a/crates/agent/src/claude_context.rs +++ b/crates/agent/src/claude_context.rs @@ -28,15 +28,6 @@ pub fn parse_context_window_tokens(value: &Value) -> Option { (100_000..=1_000_000).contains(&tokens).then_some(tokens) } -/// Return the model's native context-window size in tokens. -pub fn native_context_window(model_id: &str) -> u64 { - match model_id.strip_suffix("[1m]").unwrap_or(model_id) { - "claude-fable-5" | "claude-fable-5-1" | "claude-opus-5" | "claude-sonnet-5" - | "claude-opus-4-7" | "claude-opus-4-8" => 1_000_000, - _ => 200_000, - } -} - /// Format a context-window token count for display. pub fn format_context_window(tokens: u64) -> String { if tokens == 1_000_000 { @@ -46,11 +37,8 @@ pub fn format_context_window(tokens: u64) -> String { } } -/// Resolve the selected context window, falling back to the model's native size. +/// Resolve the selected context window, falling back to the model's default +/// window in the current Claude model manifest. pub fn resolved_context_window(model_id: &str, selections: &[OptionSelection]) -> u64 { - selections - .iter() - .find(|selection| selection.id == "contextWindow") - .and_then(|selection| parse_context_window_tokens(&selection.value)) - .unwrap_or_else(|| native_context_window(model_id)) + crate::claude_manifest::current().resolved_context_window(model_id, selections) } diff --git a/crates/agent/src/claude_manifest.rs b/crates/agent/src/claude_manifest.rs new file mode 100644 index 00000000..6a373cdb --- /dev/null +++ b/crates/agent/src/claude_manifest.rs @@ -0,0 +1,944 @@ +//! Claude Code's model catalog, sourced from the t3code model manifest. +//! +//! `claude_model_manifest.json` is a verbatim copy of +//! +//! (t3code, MIT licensed) and ships with every release as the offline +//! fallback. With the `process` feature, [`refresh`] re-fetches the same file +//! and keeps the last good copy on disk in the tcode data dir. Preference +//! order is remote, then disk cache, then bundle — except that a bundle whose +//! `updatedAt` is newer than the cache outranks it, so a release can correct +//! model data before the next fetch. Invalid data never replaces a usable +//! catalog, and no failure here ever fails `list_models`. +//! +//! The catalog is process-shared state, as upstream: sessions resolve their +//! launch flags and the UI resolves context windows against [`current`]. + +// Type-only builds (web and mobile clients) use this module solely to resolve +// context windows against the bundle; launch mapping, version gating and the +// refresh bookkeeping only have callers under `process`. +#![cfg_attr(not(feature = "process"), allow(dead_code))] + +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex}; + +use serde::Deserialize; +use serde_json::Value; + +use crate::{ModelSpec, OptionDescriptor, OptionSelection, SelectOption}; + +const BUNDLED: &str = include_str!("claude_model_manifest.json"); + +/// Only manifest version 1 is understood; `version` gates breaking changes. +const SUPPORTED_VERSION: u64 = 1; + +/// The manifest calls the reasoning selector `effort`; everything in tcode +/// keys on `reasoningEffort`. Translated once here, at the boundary. +fn option_id(id: &str) -> &str { + match id { + "effort" => "reasoningEffort", + other => other, + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ManifestFile { + version: u64, + updated_at: Option, + #[serde(default)] + providers: ProvidersFile, +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct ProvidersFile { + claude_agent: Option, +} + +#[derive(Deserialize)] +struct CatalogFile { + #[serde(default)] + defaults: DefaultsFile, + #[serde(default)] + profiles: HashMap, + models: Vec, +} + +#[derive(Deserialize, Default)] +struct DefaultsFile { + chat: Option, +} + +#[derive(Deserialize, Default)] +struct ProfileFile { + #[serde(default)] + capabilities: CapabilitiesFile, + #[serde(default)] + adapter: ProfileAdapterFile, +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct CapabilitiesFile { + #[serde(default)] + option_descriptors: Vec, +} + +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +enum OptionFile { + Select { + id: String, + label: String, + options: Vec, + }, + Boolean { + id: String, + label: String, + }, + /// Descriptor types tcode cannot render are dropped rather than + /// rejecting the whole manifest. + #[serde(other)] + Unknown, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct SelectOptionFile { + id: String, + label: String, + description: Option, + #[serde(default)] + is_default: bool, +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct ProfileAdapterFile { + claude_code: Option, +} + +/// How a capability profile maps onto Claude Code launch flags. +#[derive(Deserialize, Default, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ClaudeCodeProfile { + /// Effort selection → `--effort` value; `None` means no flag at all + /// (`ultrathink` is a prompt-prefix mode). + #[serde(default)] + effort_map: HashMap>, + /// Option id → option value → suffix appended to the model slug + /// (`contextWindow` / `1m` / `[1m]`). + #[serde(default)] + model_suffixes: HashMap>, + /// Context window option value → token count. + #[serde(default)] + context_window_tokens: HashMap, + /// Token count for models without a context window selector. + fixed_context_window_tokens: Option, +} + +#[derive(Deserialize)] +struct ModelFile { + slug: String, + name: String, + profile: Option, + #[serde(default)] + adapter: ModelAdapterFile, +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct ModelAdapterFile { + claude_code: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CompatFile { + min_version: Option, + max_version_exclusive: Option, +} + +type Version = (u32, u32, u32); + +/// One catalog model with its tcode-shaped spec and Claude Code adapter data. +#[derive(Debug, Clone)] +pub(crate) struct CatalogModel { + pub(crate) spec: ModelSpec, + runtime: ClaudeCodeProfile, + min_version: Option, + max_version_exclusive: Option, +} + +impl CatalogModel { + /// Whether the installed CLI can run this model. Gated models need a + /// known version; unknown versions hide them. + fn available(&self, version: Option) -> bool { + if self.min_version.is_none() && self.max_version_exclusive.is_none() { + return true; + } + let Some(version) = version else { + return false; + }; + self.min_version.is_none_or(|min| version >= min) + && self.max_version_exclusive.is_none_or(|max| version < max) + } + + /// The `--effort` value for an accepted effort selection: the profile's + /// `effortMap` entry when present (`None` = no flag), else passthrough. + pub(crate) fn cli_effort(&self, effort: &str) -> Option { + match self.runtime.effort_map.get(effort) { + Some(mapped) => mapped.clone(), + None => Some(effort.to_owned()), + } + } + + fn default_select(&self, id: &str) -> Option<&str> { + self.spec.options.iter().find_map(|option| match option { + OptionDescriptor::Select { + id: option_id, + default_value, + .. + } if option_id == id => default_value.as_deref(), + _ => None, + }) + } + + /// Tokens of the default context window selection. + fn default_context_window(&self) -> Option { + self.runtime.fixed_context_window_tokens.or_else(|| { + let default = self.default_select("contextWindow")?; + self.runtime.context_window_tokens.get(default).copied() + }) + } + + /// The largest window this model can run with. + pub(crate) fn largest_context_window(&self) -> Option { + self.runtime + .fixed_context_window_tokens + .or_else(|| self.runtime.context_window_tokens.values().copied().max()) + } + + /// Slug suffix that opens a window of at least `window` tokens: the + /// smallest listed context window option that fits, e.g. `[1m]` for both + /// the `1m` option and a custom 500k window on a 200k model. + pub(crate) fn context_window_suffix(&self, window: u64) -> &str { + let Some(suffixes) = self.runtime.model_suffixes.get("contextWindow") else { + return ""; + }; + self.runtime + .context_window_tokens + .iter() + .filter(|(_, tokens)| **tokens >= window) + .min_by_key(|(_, tokens)| **tokens) + .and_then(|(id, _)| suffixes.get(id)) + .map_or("", String::as_str) + } +} + +/// The decoded, validated Claude catalog of one manifest. +#[derive(Debug, Clone)] +pub(crate) struct ClaudeCatalog { + /// ISO-8601 UTC edit date. Same-format timestamps order lexicographically, + /// and an undated manifest counts as older than any dated one. + updated_at: Option, + pub(crate) models: Vec, +} + +fn non_empty(value: &str, what: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(format!("empty {what}")); + } + Ok(trimmed.to_owned()) +} + +fn parse_version(value: &str) -> Result { + crate::parse_semver(value).ok_or_else(|| format!("invalid version {value:?}")) +} + +fn descriptor(option: &OptionFile) -> Result, String> { + Ok(Some(match option { + OptionFile::Select { id, label, options } => { + let id = non_empty(id, "option id")?; + let mut default_value = None; + let mut mapped = Vec::with_capacity(options.len()); + for option in options { + let value = non_empty(&option.id, "option value")?; + if option.is_default && default_value.is_none() { + default_value = Some(value.clone()); + } + mapped.push(SelectOption { + value, + label: non_empty(&option.label, "option label")?, + description: option.description.clone(), + }); + } + OptionDescriptor::Select { + id: option_id(&id).to_owned(), + label: non_empty(label, "option label")?, + options: mapped, + default_value, + } + } + OptionFile::Boolean { id, label } => OptionDescriptor::Boolean { + id: option_id(&non_empty(id, "option id")?).to_owned(), + label: non_empty(label, "option label")?, + default_value: false, + }, + OptionFile::Unknown => return Ok(None), + })) +} + +impl ClaudeCatalog { + pub(crate) fn from_json(text: &str) -> Result { + serde_json::from_str(text) + .map_err(|error| error.to_string()) + .and_then(Self::from_value) + } + + pub(crate) fn from_value(value: Value) -> Result { + serde_json::from_value(value) + .map_err(|error| error.to_string()) + .and_then(Self::from_file) + } + + fn from_file(file: ManifestFile) -> Result { + if file.version != SUPPORTED_VERSION { + return Err(format!("unsupported manifest version {}", file.version)); + } + let catalog = file + .providers + .claude_agent + .ok_or("manifest has no claudeAgent catalog")?; + let mut seen = HashSet::new(); + let mut models = Vec::with_capacity(catalog.models.len()); + let no_profile = ProfileFile::default(); + for model in &catalog.models { + let slug = non_empty(&model.slug, "model slug")?; + if !seen.insert(slug.clone()) { + return Err(format!("duplicate model slug {slug:?}")); + } + let profile = match &model.profile { + Some(name) => catalog + .profiles + .get(name) + .ok_or_else(|| format!("model {slug:?} references unknown profile {name:?}"))?, + None => &no_profile, + }; + let runtime = profile.adapter.claude_code.clone().unwrap_or_default(); + for (effort, mapped) in &runtime.effort_map { + non_empty(effort, "effortMap key")?; + if let Some(mapped) = mapped { + non_empty(mapped, "effortMap value")?; + } + } + let compat = model.adapter.claude_code.as_ref(); + let min_version = compat + .and_then(|compat| compat.min_version.as_deref()) + .map(parse_version) + .transpose()?; + let max_version_exclusive = compat + .and_then(|compat| compat.max_version_exclusive.as_deref()) + .map(parse_version) + .transpose()?; + if let (Some(min), Some(max)) = (min_version, max_version_exclusive) + && min >= max + { + return Err(format!( + "model {slug:?} minVersion is not below maxVersionExclusive" + )); + } + let options = profile + .capabilities + .option_descriptors + .iter() + .filter_map(|option| descriptor(option).transpose()) + .collect::, _>>()?; + models.push(CatalogModel { + spec: ModelSpec { + is_default: catalog.defaults.chat.as_deref() == Some(slug.as_str()), + display_name: non_empty(&model.name, "model name")?, + id: slug, + options, + }, + runtime, + min_version, + max_version_exclusive, + }); + } + if let Some(chat) = &catalog.defaults.chat + && !seen.contains(chat) + { + return Err(format!("default model {chat:?} is not in the catalog")); + } + Ok(Self { + updated_at: file.updated_at, + models, + }) + } + + /// Look up a model by slug; a context suffix such as `[1m]` is ignored. + pub(crate) fn model(&self, id: &str) -> Option<&CatalogModel> { + let id = id.trim().split('[').next().unwrap_or_default(); + self.models.iter().find(|model| model.spec.id == id) + } + + /// Models the installed CLI version can run, in manifest order. + pub(crate) fn models_for_version(&self, version: Option) -> Vec { + self.models + .iter() + .filter(|model| model.available(version)) + .map(|model| model.spec.clone()) + .collect() + } + + /// The selected context window in tokens, else the model's default (200k + /// when the manifest says nothing about it). + pub(crate) fn resolved_context_window( + &self, + model_id: &str, + selections: &[OptionSelection], + ) -> u64 { + selections + .iter() + .find(|selection| selection.id == "contextWindow") + .and_then(|selection| { + crate::claude_context::parse_context_window_tokens(&selection.value) + }) + .or_else(|| self.model(model_id)?.default_context_window()) + .unwrap_or(200_000) + } +} + +fn bundled() -> ClaudeCatalog { + ClaudeCatalog::from_json(BUNDLED).expect("bundled Claude model manifest is valid") +} + +/// In-memory manifest plus the fetch bookkeeping that paces refreshes. +pub(crate) struct ManifestState { + catalog: Arc, + /// Wall-clock millis of the fetch that produced `catalog`; `None` for the + /// bundle. Persisted with the disk cache so a restart does not refetch. + fetched_at_ms: Option, + last_attempt_ms: Option, + disk_loaded: bool, +} + +static STATE: Mutex> = Mutex::new(None); + +fn with_state(f: impl FnOnce(&mut ManifestState) -> R) -> R { + let mut guard = STATE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + f(guard.get_or_insert_with(|| ManifestState::new(bundled()))) +} + +/// The catalog in effect right now; never waits on the network. +pub(crate) fn current() -> Arc { + with_state(|state| state.catalog.clone()) +} + +impl ManifestState { + pub(crate) fn new(catalog: ClaudeCatalog) -> Self { + Self { + catalog: Arc::new(catalog), + fetched_at_ms: None, + last_attempt_ms: None, + disk_loaded: false, + } + } +} + +#[cfg(feature = "process")] +pub(crate) use refresh::refresh; + +#[cfg(feature = "process")] +mod refresh { + use std::io::Read as _; + use std::path::Path; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + use serde::{Deserialize, Serialize}; + use serde_json::Value; + + use super::{ClaudeCatalog, ManifestState, with_state}; + + const MANIFEST_URL: &str = "https://raw.githubusercontent.com/pingdotgg/t3code/main/apps/server/src/provider/model-manifest.json"; + pub(super) const CACHE_FILE: &str = "claude-model-manifest.json"; + /// How long a fetched manifest stays fresh. + pub(super) const TTL_MS: u64 = 60 * 60 * 1000; + /// Minimum gap between attempts after a failure, so an offline machine + /// does not pay a network timeout on every catalog refresh. + pub(super) const RETRY_MS: u64 = 5 * 60 * 1000; + const FETCH_TIMEOUT: Duration = Duration::from_secs(10); + const MAX_RESPONSE_BYTES: u64 = 1024 * 1024; + + /// On-disk shape of the last good remote manifest. + #[derive(Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub(super) struct CacheFile { + pub(super) fetched_at_ms: u64, + pub(super) manifest: Value, + } + + fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_millis() as u64) + } + + impl ManifestState { + /// Adopt the disk cache once, unless the bundle is newer by `updatedAt`. + pub(crate) fn load_cache(&mut self, path: &Path) { + if std::mem::replace(&mut self.disk_loaded, true) { + return; + } + let Some(cache) = std::fs::read_to_string(path) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()) + else { + return; + }; + match ClaudeCatalog::from_value(cache.manifest) { + Ok(catalog) if catalog.updated_at >= self.catalog.updated_at => { + self.catalog = catalog.into(); + self.fetched_at_ms = Some(cache.fetched_at_ms); + } + Ok(_) => log::info!("bundled Claude model manifest is newer than the cache"), + Err(error) => log::warn!("ignoring cached Claude model manifest: {error}"), + } + } + + /// Whether a fetch is due now; records the attempt when it is. + pub(crate) fn begin_fetch(&mut self, now_ms: u64, network: bool) -> bool { + // A timestamp in the future means the wall clock moved backwards; + // treat it as expired so the refetch rewrites both timestamps. + let within = |since: Option, window: u64| { + since.is_some_and(|since| now_ms >= since && now_ms - since < window) + }; + if !network + || within(self.fetched_at_ms, TTL_MS) + || within(self.last_attempt_ms, RETRY_MS) + { + return false; + } + self.last_attempt_ms = Some(now_ms); + true + } + + /// Replace the catalog with a fetched manifest; an undecodable or + /// invalid body leaves the current catalog in place. + pub(crate) fn install(&mut self, now_ms: u64, body: &[u8]) -> Result { + let value: Value = serde_json::from_slice(body).map_err(|error| error.to_string())?; + self.catalog = ClaudeCatalog::from_value(value.clone())?.into(); + self.fetched_at_ms = Some(now_ms); + Ok(value) + } + } + + fn fetch() -> Result, String> { + let response = ureq::get(MANIFEST_URL) + .set("User-Agent", "tcode") + .timeout(FETCH_TIMEOUT) + .call() + .map_err(|error| error.to_string())?; + let mut body = Vec::new(); + response + .into_reader() + .take(MAX_RESPONSE_BYTES + 1) + .read_to_end(&mut body) + .map_err(|error| error.to_string())?; + if body.len() as u64 > MAX_RESPONSE_BYTES { + return Err("response exceeds 1 MiB".into()); + } + Ok(body) + } + + /// Load the disk cache under `cache_dir` and, when the TTL allows and + /// `network` is on, fetch the remote manifest. Blocking; never fails. + pub(crate) fn refresh(cache_dir: Option<&Path>, network: bool) { + let cache_path = cache_dir.map(|dir| dir.join(CACHE_FILE)); + let now = now_ms(); + let due = with_state(|state| { + if let Some(path) = &cache_path { + state.load_cache(path); + } + state.begin_fetch(now, network) + }); + if !due { + return; + } + // The lock is not held across the fetch: `current()` must stay + // instant for the UI while a 10 s timeout plays out. + let installed = fetch().and_then(|body| with_state(|state| state.install(now, &body))); + match installed { + Ok(manifest) => { + let Some(path) = cache_path else { return }; + let cache = CacheFile { + fetched_at_ms: now, + manifest, + }; + if let Err(error) = serde_json::to_vec(&cache) + .map_err(std::io::Error::other) + .and_then(|bytes| std::fs::write(&path, bytes)) + { + log::warn!("failed to cache Claude model manifest: {error}"); + } + } + Err(error) => log::warn!("Claude model manifest refresh failed: {error}"), + } + } +} + +/// A synthetic manifest exercising every adapter feature tcode reads, so +/// catalog tests never depend on the bundled model list. +#[cfg(test)] +pub(crate) const TEST_MANIFEST: &str = r#"{ + "version": 1, + "updatedAt": "2030-01-01T00:00:00Z", + "providers": { + "claudeAgent": { + "defaults": { "chat": "test-wide" }, + "profiles": { + "wide": { + "capabilities": { "optionDescriptors": [ + { "id": "effort", "label": "Reasoning", "type": "select", "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium", "isDefault": true }, + { "id": "max", "label": "Max" }, + { "id": "ultracode", "label": "Ultracode", "description": "xhigh plus orchestration" }, + { "id": "ultrathink", "label": "Ultrathink" } + ], "promptInjectedValues": ["ultrathink"] }, + { "id": "contextWindow", "label": "Context Window", "type": "select", "options": [ + { "id": "200k", "label": "200k" }, + { "id": "1m", "label": "1M", "isDefault": true } + ] }, + { "id": "tone", "label": "Tone", "type": "slider" } + ] }, + "adapter": { "claudeCode": { + "effortMap": { "ultracode": "xhigh", "ultrathink": null }, + "modelSuffixes": { "contextWindow": { "1m": "[1m]" } }, + "contextWindowTokens": { "200k": 200000, "1m": 1000000 } + } } + }, + "narrow": { + "capabilities": { "optionDescriptors": [ + { "id": "effort", "label": "Reasoning", "type": "select", "options": [ + { "id": "high", "label": "High", "isDefault": true }, + { "id": "max", "label": "Max" } + ] }, + { "id": "contextWindow", "label": "Context Window", "type": "select", "options": [ + { "id": "200k", "label": "200k", "isDefault": true }, + { "id": "1m", "label": "1M" } + ] } + ] }, + "adapter": { "claudeCode": { + "effortMap": { "max": "high" }, + "modelSuffixes": { "contextWindow": { "1m": "[1m]" } }, + "contextWindowTokens": { "200k": 200000, "1m": 1000000 } + } } + }, + "fixed": { + "capabilities": { "optionDescriptors": [ + { "id": "effort", "label": "Reasoning", "type": "select", "options": [ + { "id": "high", "label": "High", "isDefault": true }, + { "id": "xhigh", "label": "Extra High" } + ] }, + { "id": "fastMode", "label": "Fast Mode", "type": "boolean" } + ] }, + "adapter": { "claudeCode": { "effortMap": { "xhigh": "max" }, "fixedContextWindowTokens": 1000000 } } + }, + "plain": { + "capabilities": { "optionDescriptors": [ + { "id": "thinking", "label": "Thinking", "type": "boolean" } + ] }, + "adapter": { "claudeCode": {} } + } + }, + "models": [ + { "slug": "test-wide", "name": "Test Wide", "status": "current", "badge": "new", "profile": "wide", + "adapter": { "claudeCode": { "minVersion": "2.1.257" } } }, + { "slug": "test-fixed", "name": "Test Fixed", "status": "legacy", "profile": "fixed", + "adapter": { "claudeCode": { "minVersion": "2.1.111", "maxVersionExclusive": "3.0.0" } } }, + { "slug": "test-narrow", "name": "Test Narrow", "status": "legacy", "profile": "narrow" }, + { "slug": "test-plain", "name": "Test Plain", "status": "legacy", "profile": "plain" } + ] + } + } +}"#; + +#[cfg(test)] +pub(crate) fn test_catalog() -> ClaudeCatalog { + ClaudeCatalog::from_json(TEST_MANIFEST).expect("test manifest is valid") +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn bundled_manifest_decodes_into_a_usable_catalog() { + let catalog = bundled(); + assert!(!catalog.models.is_empty()); + assert_eq!( + catalog + .models + .iter() + .filter(|model| model.spec.is_default) + .count(), + 1 + ); + // The offline fallback must carry the two selectors the composer keys on. + let default = catalog.models.iter().find(|m| m.spec.is_default).unwrap(); + let ids: Vec<&str> = default + .spec + .options + .iter() + .map(|option| match option { + OptionDescriptor::Select { id, .. } | OptionDescriptor::Boolean { id, .. } => { + id.as_str() + } + }) + .collect(); + assert!(ids.contains(&"reasoningEffort"), "{ids:?}"); + assert!(ids.contains(&"contextWindow"), "{ids:?}"); + assert!(!ids.contains(&"effort"), "manifest id must be translated"); + } + + #[test] + fn manifest_descriptors_map_onto_tcode_options() { + let catalog = test_catalog(); + let wide = catalog.model("test-wide").unwrap(); + assert_eq!(wide.spec.display_name, "Test Wide"); + assert!(wide.spec.is_default); + assert_eq!( + wide.spec.options, + vec![ + OptionDescriptor::Select { + id: "reasoningEffort".into(), + label: "Reasoning".into(), + options: vec![ + SelectOption { + value: "low".into(), + label: "Low".into(), + description: None, + }, + SelectOption { + value: "medium".into(), + label: "Medium".into(), + description: None, + }, + SelectOption { + value: "max".into(), + label: "Max".into(), + description: None, + }, + SelectOption { + value: "ultracode".into(), + label: "Ultracode".into(), + description: Some("xhigh plus orchestration".into()), + }, + SelectOption { + value: "ultrathink".into(), + label: "Ultrathink".into(), + description: None, + }, + ], + default_value: Some("medium".into()), + }, + OptionDescriptor::Select { + id: "contextWindow".into(), + label: "Context Window".into(), + options: vec![ + SelectOption { + value: "200k".into(), + label: "200k".into(), + description: None, + }, + SelectOption { + value: "1m".into(), + label: "1M".into(), + description: None, + }, + ], + default_value: Some("1m".into()), + }, + ], + "unknown descriptor types are dropped, not fatal" + ); + assert!(!catalog.model("test-fixed").unwrap().spec.is_default); + assert_eq!( + catalog.model("test-fixed").unwrap().spec.options[1], + OptionDescriptor::Boolean { + id: "fastMode".into(), + label: "Fast Mode".into(), + default_value: false, + } + ); + // Lookups tolerate whitespace and a context suffix. + assert!(catalog.model(" test-plain ").is_some()); + assert!(catalog.model("test-narrow[1m]").is_some()); + assert!(catalog.model("test-missing").is_none()); + } + + #[test] + fn invalid_manifests_are_rejected() { + let mutate = |edit: fn(&mut Value)| { + let mut value: Value = serde_json::from_str(TEST_MANIFEST).unwrap(); + edit(&mut value); + ClaudeCatalog::from_value(value).expect_err("must be rejected") + }; + assert!(mutate(|v| v["version"] = json!(2)).contains("version")); + assert!(mutate(|v| v["providers"] = json!({})).contains("claudeAgent")); + assert!( + mutate(|v| v["providers"]["claudeAgent"]["models"][1]["slug"] = json!("test-wide")) + .contains("duplicate") + ); + assert!( + mutate(|v| v["providers"]["claudeAgent"]["models"][0]["profile"] = json!("nope")) + .contains("unknown profile") + ); + assert!( + mutate(|v| v["providers"]["claudeAgent"]["defaults"]["chat"] = json!("nope")) + .contains("default model") + ); + assert!( + mutate(|v| { + v["providers"]["claudeAgent"]["models"][0]["adapter"]["claudeCode"]["minVersion"] = + json!("latest") + }) + .contains("invalid version") + ); + assert!(mutate(|v| { + v["providers"]["claudeAgent"]["models"][1]["adapter"]["claudeCode"] + ["maxVersionExclusive"] = json!("2.1.111") + }) + .contains("maxVersionExclusive")); + assert!(mutate(|v| { + v["providers"]["claudeAgent"]["profiles"]["wide"]["capabilities"] + ["optionDescriptors"][0]["id"] = json!(" ") + }) + .contains("option id")); + assert!(mutate(|v| { + v["providers"]["claudeAgent"]["profiles"]["wide"]["capabilities"] + ["optionDescriptors"][0]["options"][0]["id"] = json!("") + }) + .contains("option value")); + assert!(mutate(|v| { + v["providers"]["claudeAgent"]["profiles"]["wide"]["adapter"]["claudeCode"] + ["effortMap"][""] = json!("low") + }) + .contains("effortMap")); + assert!(ClaudeCatalog::from_json("not json").is_err()); + } + + #[cfg(feature = "process")] + mod refresh { + use super::super::refresh::{CACHE_FILE, CacheFile, RETRY_MS, TTL_MS}; + use super::*; + + fn fresh_state() -> ManifestState { + ManifestState::new(test_catalog()) + } + + fn model_ids(state: &ManifestState) -> Vec { + state + .catalog + .models + .iter() + .map(|model| model.spec.id.clone()) + .collect() + } + + fn manifest_with(updated_at: &str, slug: &str) -> Value { + let mut value: Value = serde_json::from_str(TEST_MANIFEST).unwrap(); + value["updatedAt"] = json!(updated_at); + value["providers"]["claudeAgent"]["defaults"]["chat"] = json!(slug); + value["providers"]["claudeAgent"]["models"][0]["slug"] = json!(slug); + value + } + + #[test] + fn invalid_fetch_keeps_the_previous_catalog() { + let mut state = fresh_state(); + let before = model_ids(&state); + assert!(state.install(1_000, b"{ not json").is_err()); + let mut broken = manifest_with("2031-01-01T00:00:00Z", "test-wide"); + broken["providers"]["claudeAgent"]["models"][0]["profile"] = json!("missing"); + assert!(state.install(1_000, broken.to_string().as_bytes()).is_err()); + assert_eq!(model_ids(&state), before); + assert_eq!(state.fetched_at_ms, None); + + let fresh = manifest_with("2031-01-01T00:00:00Z", "test-remote"); + state.install(2_000, fresh.to_string().as_bytes()).unwrap(); + assert_eq!(model_ids(&state)[0], "test-remote"); + assert_eq!(state.fetched_at_ms, Some(2_000)); + } + + #[test] + fn fetches_are_paced_by_ttl_retry_and_setting() { + let mut state = fresh_state(); + assert!(!state.begin_fetch(1_000, false), "network disabled"); + assert!(state.begin_fetch(1_000, true)); + assert!(!state.begin_fetch(1_000 + RETRY_MS - 1, true), "retry gap"); + assert!(state.begin_fetch(1_000 + RETRY_MS, true)); + state.fetched_at_ms = Some(10_000); + assert!(!state.begin_fetch(10_000 + TTL_MS - 1, true), "fresh"); + assert!(state.begin_fetch(10_000 + TTL_MS, true)); + // A future timestamp (clock moved backwards) counts as expired. + state.fetched_at_ms = Some(u64::MAX); + state.last_attempt_ms = Some(u64::MAX); + assert!(state.begin_fetch(5, true)); + } + + #[test] + fn disk_cache_is_adopted_unless_the_bundle_is_newer() { + let dir = std::env::temp_dir().join(format!( + "tcode-manifest-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(CACHE_FILE); + let write = |updated_at: &str| { + let cache = CacheFile { + fetched_at_ms: 42, + manifest: manifest_with(updated_at, "test-cached"), + }; + std::fs::write(&path, serde_json::to_vec(&cache).unwrap()).unwrap(); + }; + + // Older than the bundle (test catalog dated 2030): ignored. + write("2029-12-31T23:59:59Z"); + let mut state = fresh_state(); + state.load_cache(&path); + assert_eq!(model_ids(&state)[0], "test-wide"); + assert_eq!(state.fetched_at_ms, None); + + // Same or newer: adopted, together with its fetch time. + write("2030-01-01T00:00:00Z"); + let mut state = fresh_state(); + state.load_cache(&path); + assert_eq!(model_ids(&state)[0], "test-cached"); + assert_eq!(state.fetched_at_ms, Some(42)); + + // Loaded once: a later cache write is not re-read. + write("2035-01-01T00:00:00Z"); + state.load_cache(&path); + assert_eq!(model_ids(&state)[0], "test-cached"); + + // Corrupt cache: bundle stays. + std::fs::write(&path, b"{}").unwrap(); + let mut state = fresh_state(); + state.load_cache(&path); + assert_eq!(model_ids(&state)[0], "test-wide"); + std::fs::remove_dir_all(&dir).unwrap(); + } + } +} diff --git a/crates/agent/src/claude_model_manifest.json b/crates/agent/src/claude_model_manifest.json new file mode 100644 index 00000000..9ea0c006 --- /dev/null +++ b/crates/agent/src/claude_model_manifest.json @@ -0,0 +1,677 @@ +{ + "version": 1, + "updatedAt": "2026-09-12T00:41:55Z", + "currentModels": { + "codex": [ + "gpt-6-astra", + "gpt-5.6-luna", + "gpt-5.6-terra", + "gpt-5.6-sol", + "gpt-daybreak-blue-latest", + "gpt-daybreak-red-latest" + ], + "claudeAgent": ["claude-fable-5-1", "claude-opus-5", "claude-sonnet-5"], + "antigravity": ["gemini-3.8-flash-high", "gemini-3.8-flash-medium", "gemini-3.8-flash-low"] + }, + "providers": { + "claudeAgent": { + "defaults": { + "chat": "claude-fable-5-1" + }, + "profiles": { + "fable-5": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { + "id": "low", + "label": "Low" + }, + { + "id": "medium", + "label": "Medium", + "isDefault": true + }, + { + "id": "high", + "label": "High" + }, + { + "id": "xhigh", + "label": "Extra High" + }, + { + "id": "max", + "label": "Max" + }, + { + "id": "ultracode", + "label": "Ultracode", + "description": "xhigh effort plus multi-agent workflow orchestration" + }, + { + "id": "ultrathink", + "label": "Ultrathink" + } + ], + "promptInjectedValues": ["ultrathink"] + }, + { + "id": "contextWindow", + "label": "Context Window", + "type": "select", + "options": [ + { + "id": "200k", + "label": "200k" + }, + { + "id": "1m", + "label": "1M", + "isDefault": true + } + ] + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { + "ultracode": "xhigh", + "ultrathink": null + }, + "modelSuffixes": { + "contextWindow": { + "1m": "[1m]" + } + }, + "contextWindowTokens": { + "200k": 200000, + "1m": 1000000 + } + } + } + }, + "opus-5": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { + "id": "low", + "label": "Low" + }, + { + "id": "medium", + "label": "Medium" + }, + { + "id": "high", + "label": "High", + "isDefault": true + }, + { + "id": "xhigh", + "label": "Extra High" + }, + { + "id": "max", + "label": "Max" + }, + { + "id": "ultracode", + "label": "Ultracode", + "description": "xhigh effort plus multi-agent workflow orchestration" + }, + { + "id": "ultrathink", + "label": "Ultrathink" + } + ], + "promptInjectedValues": ["ultrathink"] + }, + { + "id": "fastMode", + "label": "Fast Mode", + "type": "boolean" + }, + { + "id": "contextWindow", + "label": "Context Window", + "type": "select", + "options": [ + { + "id": "200k", + "label": "200k" + }, + { + "id": "1m", + "label": "1M", + "isDefault": true + } + ] + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { + "ultracode": "xhigh", + "ultrathink": null + }, + "modelSuffixes": { + "contextWindow": { + "1m": "[1m]" + } + }, + "contextWindowTokens": { + "200k": 200000, + "1m": 1000000 + } + } + } + }, + "opus-4-8": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { + "id": "low", + "label": "Low" + }, + { + "id": "medium", + "label": "Medium" + }, + { + "id": "high", + "label": "High", + "isDefault": true + }, + { + "id": "xhigh", + "label": "Extra High" + }, + { + "id": "max", + "label": "Max" + }, + { + "id": "ultracode", + "label": "Ultracode", + "description": "xhigh effort plus multi-agent workflow orchestration" + }, + { + "id": "ultrathink", + "label": "Ultrathink" + } + ], + "promptInjectedValues": ["ultrathink"] + }, + { + "id": "fastMode", + "label": "Fast Mode", + "type": "boolean" + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { + "ultracode": "xhigh", + "ultrathink": null + }, + "fixedContextWindowTokens": 1000000 + } + } + }, + "opus-4-7": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { + "id": "low", + "label": "Low" + }, + { + "id": "medium", + "label": "Medium" + }, + { + "id": "high", + "label": "High" + }, + { + "id": "xhigh", + "label": "Extra High", + "isDefault": true + }, + { + "id": "max", + "label": "Max" + }, + { + "id": "ultrathink", + "label": "Ultrathink" + } + ], + "promptInjectedValues": ["ultrathink"] + }, + { + "id": "fastMode", + "label": "Fast Mode", + "type": "boolean" + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { + "xhigh": "max", + "ultrathink": null + }, + "fixedContextWindowTokens": 1000000 + } + } + }, + "opus-4-6": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { + "id": "low", + "label": "Low" + }, + { + "id": "medium", + "label": "Medium" + }, + { + "id": "high", + "label": "High", + "isDefault": true + }, + { + "id": "max", + "label": "Max" + }, + { + "id": "ultrathink", + "label": "Ultrathink" + } + ], + "promptInjectedValues": ["ultrathink"] + }, + { + "id": "fastMode", + "label": "Fast Mode", + "type": "boolean" + }, + { + "id": "contextWindow", + "label": "Context Window", + "type": "select", + "options": [ + { + "id": "200k", + "label": "200k" + }, + { + "id": "1m", + "label": "1M", + "isDefault": true + } + ] + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { + "ultrathink": null + }, + "modelSuffixes": { + "contextWindow": { + "1m": "[1m]" + } + }, + "contextWindowTokens": { + "200k": 200000, + "1m": 1000000 + } + } + } + }, + "opus-4-5": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { + "id": "low", + "label": "Low" + }, + { + "id": "medium", + "label": "Medium" + }, + { + "id": "high", + "label": "High", + "isDefault": true + }, + { + "id": "max", + "label": "Max" + } + ] + }, + { + "id": "fastMode", + "label": "Fast Mode", + "type": "boolean" + } + ] + }, + "adapter": { + "claudeCode": {} + } + }, + "sonnet-5": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { + "id": "low", + "label": "Low" + }, + { + "id": "medium", + "label": "Medium" + }, + { + "id": "high", + "label": "High", + "isDefault": true + }, + { + "id": "xhigh", + "label": "Extra High" + }, + { + "id": "max", + "label": "Max" + }, + { + "id": "ultrathink", + "label": "Ultrathink" + } + ], + "promptInjectedValues": ["ultrathink"] + }, + { + "id": "contextWindow", + "label": "Context Window", + "type": "select", + "options": [ + { + "id": "200k", + "label": "200k", + "isDefault": true + }, + { + "id": "1m", + "label": "1M" + } + ] + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { + "ultrathink": null + }, + "modelSuffixes": { + "contextWindow": { + "1m": "[1m]" + } + }, + "contextWindowTokens": { + "200k": 200000, + "1m": 1000000 + } + } + } + }, + "sonnet-4-6": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { + "id": "low", + "label": "Low" + }, + { + "id": "medium", + "label": "Medium" + }, + { + "id": "high", + "label": "High", + "isDefault": true + }, + { + "id": "max", + "label": "Max" + }, + { + "id": "ultrathink", + "label": "Ultrathink" + } + ], + "promptInjectedValues": ["ultrathink"] + }, + { + "id": "contextWindow", + "label": "Context Window", + "type": "select", + "options": [ + { + "id": "200k", + "label": "200k", + "isDefault": true + }, + { + "id": "1m", + "label": "1M" + } + ] + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { + "max": "high", + "ultrathink": null + }, + "modelSuffixes": { + "contextWindow": { + "1m": "[1m]" + } + }, + "contextWindowTokens": { + "200k": 200000, + "1m": 1000000 + } + } + } + }, + "haiku-4-5": { + "capabilities": { + "optionDescriptors": [ + { + "id": "thinking", + "label": "Thinking", + "type": "boolean" + } + ] + }, + "adapter": { + "claudeCode": {} + } + } + }, + "models": [ + { + "slug": "claude-fable-5-1", + "name": "Claude Fable 5.1", + "aliases": ["fable", "fable-5.1", "claude-fable-5.1"], + "status": "current", + "badge": "new", + "profile": "fable-5", + "adapter": { + "claudeCode": { + "minVersion": "2.1.257" + } + } + }, + { + "slug": "claude-fable-5", + "name": "Claude Fable 5", + "status": "legacy", + "profile": "fable-5", + "adapter": { + "claudeCode": { + "minVersion": "2.1.169" + } + } + }, + { + "slug": "claude-opus-5", + "name": "Claude Opus 5", + "aliases": ["opus", "opus-5", "claude-opus-5.0", "claude-opus-5-0"], + "status": "current", + "profile": "opus-5", + "adapter": { + "claudeCode": { + "minVersion": "2.1.219" + } + } + }, + { + "slug": "claude-opus-4-8", + "name": "Claude Opus 4.8", + "aliases": ["opus-4.8", "claude-opus-4.8"], + "status": "legacy", + "profile": "opus-4-8", + "adapter": { + "claudeCode": { + "minVersion": "2.1.154" + } + } + }, + { + "slug": "claude-opus-4-7", + "name": "Claude Opus 4.7", + "aliases": ["opus-4.7", "claude-opus-4.7"], + "status": "legacy", + "profile": "opus-4-7", + "adapter": { + "claudeCode": { + "minVersion": "2.1.111" + } + } + }, + { + "slug": "claude-opus-4-6", + "name": "Claude Opus 4.6", + "aliases": ["opus-4.6", "claude-opus-4.6", "claude-opus-4-6-20251117"], + "status": "legacy", + "profile": "opus-4-6" + }, + { + "slug": "claude-opus-4-5", + "name": "Claude Opus 4.5", + "status": "legacy", + "profile": "opus-4-5" + }, + { + "slug": "claude-sonnet-5", + "name": "Claude Sonnet 5", + "aliases": ["sonnet", "sonnet-5", "claude-sonnet-5.0", "claude-sonnet-5-0"], + "status": "current", + "profile": "sonnet-5" + }, + { + "slug": "claude-sonnet-4-6", + "name": "Claude Sonnet 4.6", + "aliases": ["sonnet-4.6", "claude-sonnet-4.6", "claude-sonnet-4-6-20251117"], + "status": "legacy", + "profile": "sonnet-4-6" + }, + { + "slug": "claude-haiku-4-5", + "name": "Claude Haiku 4.5", + "aliases": ["haiku", "haiku-4.5", "claude-haiku-4.5", "claude-haiku-4-5-20251001"], + "status": "legacy", + "profile": "haiku-4-5" + } + ] + }, + "antigravity": { + "defaults": { + "chat": "gemini-3.8-flash-high" + }, + "profiles": {}, + "models": [ + { + "slug": "gemini-3.8-flash-high", + "name": "Gemini 3.8 Flash (High)", + "status": "current" + }, + { + "slug": "gemini-3.8-flash-medium", + "name": "Gemini 3.8 Flash (Medium)", + "status": "current" + }, + { + "slug": "gemini-3.8-flash-low", + "name": "Gemini 3.8 Flash (Low)", + "status": "current" + } + ] + } + } +} diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index e998dd49..f377df57 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -13,6 +13,7 @@ mod actor; #[cfg(feature = "process")] pub mod claude; mod claude_context; +mod claude_manifest; #[cfg(not(feature = "process"))] pub mod claude { pub use crate::claude_context::*; @@ -690,6 +691,17 @@ pub struct TurnOptions { pub interaction_mode: Option, } +/// Where a static model catalog may refresh its data from. Claude's catalog is +/// the t3code model manifest (see `claude_manifest`); the other providers +/// discover their models from the CLI and ignore this. +#[derive(Debug, Clone, Default)] +pub struct CatalogRefresh { + /// Directory holding the last good remote manifest (the tcode data dir). + pub cache_dir: Option, + /// Whether a network fetch is allowed (the provider update-check setting). + pub network: bool, +} + /// List the provider's models (spawn, query, teardown). `launch_env` carries the /// provider's configured environment/home so the catalog reflects the same CLI /// (and account) a session would actually run against. @@ -698,10 +710,11 @@ pub async fn list_models( provider: ProviderKind, binary_path: Option, launch_env: LaunchEnv, + refresh: CatalogRefresh, ) -> Result, AgentError> { match provider { ProviderKind::Codex => codex::list_models(binary_path, launch_env).await, - ProviderKind::ClaudeCode => claude::list_models(binary_path, launch_env).await, + ProviderKind::ClaudeCode => claude::list_models(binary_path, launch_env, refresh).await, ProviderKind::Pi => pi::list_models(binary_path, launch_env).await, ProviderKind::OpenCode => opencode::list_models(binary_path, launch_env).await, // ACP agents advertise their models over the wire at session start @@ -1931,3 +1944,19 @@ mod usage_compatibility_tests { assert_eq!(wire["pre_tokens"], 500); } } + +/// Parse a `MAJOR.MINOR.PATCH` triple from `--version` output such as +/// `"2.1.206 (Claude Code)"` or a bare manifest version string. +pub(crate) fn parse_semver(text: &str) -> Option<(u32, u32, u32)> { + let token = text.split_whitespace().find(|token| token.contains('.'))?; + let mut parts = token.trim_start_matches('v').split('.'); + Some(( + parts.next()?.parse().ok()?, + parts.next()?.parse().ok()?, + parts + .next() + .and_then(|part| part.split(['-', '+']).next())? + .parse() + .ok()?, + )) +} diff --git a/crates/agent/src/process.rs b/crates/agent/src/process.rs index 7353f2b0..77bec969 100644 --- a/crates/agent/src/process.rs +++ b/crates/agent/src/process.rs @@ -52,21 +52,7 @@ pub(crate) async fn probe_version( command.env(key, value); } let output = command.output().await.ok()?; - parse_semver(&String::from_utf8_lossy(&output.stdout)) -} - -pub(crate) fn parse_semver(text: &str) -> Option<(u32, u32, u32)> { - let token = text.split_whitespace().find(|token| token.contains('.'))?; - let mut parts = token.trim_start_matches('v').split('.'); - Some(( - parts.next()?.parse().ok()?, - parts.next()?.parse().ok()?, - parts - .next() - .and_then(|part| part.split(['-', '+']).next())? - .parse() - .ok()?, - )) + crate::parse_semver(&String::from_utf8_lossy(&output.stdout)) } pub(crate) enum ChildOutput { diff --git a/crates/runtime/src/app/mod.rs b/crates/runtime/src/app/mod.rs index 5c01fbce..028da26a 100644 --- a/crates/runtime/src/app/mod.rs +++ b/crates/runtime/src/app/mod.rs @@ -9,10 +9,11 @@ use std::sync::Arc; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use agent::{ - AgentError, AgentEvent, ApprovalDecision, ApprovalMode, Attachment, InteractionMode, - ItemContent, ItemStatus, LaunchEnv, ModelSpec, OptionDescriptor, OptionDescriptors, - OptionSelection, PlanResolution, ProviderCommand, ProviderKind, RewindMode, SessionCommand, - SessionHandle, SessionOptions, ThreadItem, TurnOptions, TurnStatus, list_models, + AgentError, AgentEvent, ApprovalDecision, ApprovalMode, Attachment, CatalogRefresh, + InteractionMode, ItemContent, ItemStatus, LaunchEnv, ModelSpec, OptionDescriptor, + OptionDescriptors, OptionSelection, PlanResolution, ProviderCommand, ProviderKind, RewindMode, + SessionCommand, SessionHandle, SessionOptions, ThreadItem, TurnOptions, TurnStatus, + list_models, }; use base64::Engine as _; use serde::{Deserialize, Serialize}; diff --git a/crates/runtime/src/app/providers.rs b/crates/runtime/src/app/providers.rs index 521e2399..7e8eeb14 100644 --- a/crates/runtime/src/app/providers.rs +++ b/crates/runtime/src/app/providers.rs @@ -124,6 +124,12 @@ impl AppState { let settings_store = self.settings_store.clone(); self.providers.models_loading.insert(provider, true); let store = self.store.clone(); + // Claude's manifest cache lives next to the persisted catalogs and + // phones home only under the same switch as CLI update checks. + let refresh = CatalogRefresh { + cache_dir: Some(store.root().clone()), + network: self.provider_update_checks_enabled(), + }; let host_cx = cx.clone(); HostCx::spawn_detached(cx, async move { let profile_id = Settings::builtin_profile_id(provider).to_string(); @@ -133,7 +139,7 @@ impl AppState { launch_env_for_profile(&settings, &profile_id, secrets) }) .await; - let result = list_models(provider, binary, launch_env).await; + let result = list_models(provider, binary, launch_env, refresh).await; host_cx.enqueue(move |state, _cx| { state.providers.models_loading.insert(provider, false); match result { diff --git a/docs/claude-models.md b/docs/claude-models.md new file mode 100644 index 00000000..d36d2fc4 --- /dev/null +++ b/docs/claude-models.md @@ -0,0 +1,23 @@ +# Claude Code model list + +Tcode does not hand-maintain the Claude Code model catalog. It reads the +[t3code model manifest](https://github.com/pingdotgg/t3code/blob/main/apps/server/src/provider/model-manifest.json) +(MIT licensed): the `claudeAgent` section supplies each model's name, options +(reasoning effort, context window, fast mode, thinking), the CLI flag mapping +(`effortMap`, `[1m]` suffixes, context window sizes) and the minimum Claude Code +version that can run it. Upstream data is adopted as is; there is no local +override table. + +The owner is `crates/agent/src/claude_manifest.rs`, which bundles a verbatim +copy (`claude_model_manifest.json`) as the offline fallback and refreshes it +from t3code `main` when the model catalog is listed (app start and provider +reload). The fetch runs at most once an hour, backs off five minutes after a +failure, times out after 10 s, caps the response at 1 MiB and is skipped +entirely when **Settings → Provider update checks** is off. The last good +remote manifest is cached as `claude-model-manifest.json` in the Tcode data dir +(`TCODE_DATA_DIR` or the platform data dir); a bundled copy with a newer +`updatedAt` outranks that cache. Invalid data never replaces a usable manifest, +and a failed fetch never fails the model list. + +To refresh the bundle, copy the upstream file over +`crates/agent/src/claude_model_manifest.json`. diff --git a/locales/en.yml b/locales/en.yml index 7c15ad84..ad3a3b92 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -282,7 +282,7 @@ settings: description: "Automatically expand the latest command output while the agent is working." provider_updates: title: "Provider update checks" - description: "Check for newer Tcode and provider CLI versions on launch." + description: "Check for newer Tcode and provider CLI versions on launch, and refresh the Claude Code model list from t3code." inactive_frame_throttle: title: "Background frame throttle" description: "Drop to ~2 FPS while the window is unfocused to save energy. Takes effect after restart." diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 9a80544e..0457b047 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -282,7 +282,7 @@ settings: description: "智能体工作时自动展开最新命令的输出。" provider_updates: title: "提供方更新检查" - description: "启动时检查 Tcode 和提供方 CLI 的新版本。" + description: "启动时检查 Tcode 和提供方 CLI 的新版本,并从 t3code 刷新 Claude Code 模型列表。" inactive_frame_throttle: title: "后台帧率限制" description: "窗口失焦时将刷新率降至约 2 FPS 以节省能耗。重启后生效。"