diff --git a/src/commands.rs b/src/commands.rs index 3fca29c..1b805ca 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -9,7 +9,8 @@ use crate::config::{ use crate::help::{command_help, resolve_bin, root_help, set_invoked_bin}; use crate::http::{ create_key, delete_key, fetch_credits, fetch_keys, fetch_models, format_models_list, - format_usage_report, is_active_key_row, reveal_key, validate_key, + format_usage_report, is_active_key_row, most_used_model_id, reveal_key, validate_key, + CatalogModel, }; use crate::install::{ agent_available, available_agents, ensure_tool_installed, missing_agents, KNOWN_AGENTS, @@ -19,9 +20,10 @@ use crate::key::{ }; use crate::parse::{get_string_flag, parse_cli_args, FlagValue, ParsedArgs}; use crate::spawn::{ - build_tool_env, canonical_tool, default_profile_for_env, display_model_id, effort_args_for, - env_command_path, is_auto_model, model_args_for, normalize_effort, prepare_pi_wrapper, - provider_args_for, render_dry_run, resolve_tool, spawn_child, BuildToolEnvInput, + build_tool_env, canonical_tool, catalog_model_id, default_profile_for_env, display_model_id, + effort_args_for, env_command_path, is_auto_model, model_args_for, normalize_effort, + prepare_pi_wrapper, provider_args_for, render_dry_run, resolve_tool, session_model_label, + spawn_child, BuildToolEnvInput, }; use crate::term; use crate::VERSION; @@ -691,20 +693,30 @@ fn run_login(parsed: &ParsedArgs, env: &BTreeMap) -> Result String { +fn model_pick_label(id: &str, models: &[CatalogModel]) -> String { if is_auto_model(id) { - "anyrouter/auto · smart pick".into() + match most_used_model_id(models) { + Some(top) => format!("auto · most used · {top}"), + None => "auto · most used".into(), + } + } else if models + .iter() + .find(|m| catalog_model_id(&m.id) == id) + .and_then(|m| m.context_length) + .is_some_and(|n| n >= 1_000_000) + { + format!("{id} · 1M") } else { id.to_string() } } -fn pick_ids(models: &[crate::http::CatalogModel]) -> Vec { +fn pick_ids(models: &[CatalogModel]) -> Vec { let mut ids = Vec::new(); - ids.push("anyrouter/auto".into()); + ids.push("auto".into()); for model in models { - let id = display_model_id(&model.id).to_string(); - if !ids.iter().any(|existing| existing == &id) { + let id = catalog_model_id(&model.id); + if !id.is_empty() && !is_auto_model(&id) && !ids.iter().any(|existing| existing == &id) { ids.push(id); } } @@ -729,7 +741,7 @@ fn pick_list( } fn pick_model( - models: &[crate::http::CatalogModel], + models: &[CatalogModel], current: Option<&str>, title: &str, ) -> Result { @@ -737,12 +749,15 @@ fn pick_model( if ids.is_empty() { return Err("No models in catalog.".into()); } - let current_id = current.map(display_model_id); - let labels: Vec = ids.iter().map(|id| model_pick_label(id)).collect(); - let shown_idx = current_id.and_then(|id| ids.iter().position(|s| s == id)); + let current_id = current.map(catalog_model_id); + let labels: Vec = ids.iter().map(|id| model_pick_label(id, models)).collect(); + let shown_idx = current_id.and_then(|id| { + ids.iter() + .position(|s| s == &id || (is_auto_model(&id) && is_auto_model(s))) + }); let idx = pick_list( title, - &["type to search, enter to pin".into()], + &["type to search, enter to pin · x in config clears to auto".into()], &labels, shown_idx, )?; @@ -750,19 +765,24 @@ fn pick_model( } fn set_model_slot(profile: &mut Profile, slot: &str, id: String) { - let id = display_model_id(&id).to_string(); + let id = catalog_model_id(&id); match slot { "haiku" => profile.claude_haiku = Some(id), "sonnet" => profile.claude_sonnet = Some(id), "opus" => profile.claude_opus = Some(id), "fable" => profile.claude_fable = Some(id), - _ => profile.default_model = Some(id), + _ => { + profile.default_model = if is_auto_model(&id) { None } else { Some(id) }; + } } } fn pick_claude_slot(profile: &Profile) -> Result<&'static str, String> { let items = vec![ - format!("Default · {}", display_model_id(profile.default_model())), + format!( + "Default · {}", + session_model_label(profile.default_model()) + ), format!("Haiku · {}", profile.claude_haiku()), format!("Sonnet · {}", profile.claude_sonnet()), format!("Opus · {}", profile.claude_opus()), @@ -818,8 +838,9 @@ fn apply_claude_alias_flags(profile: &mut Profile, parsed: &ParsedArgs) -> bool changed } -fn known_model_id(models: &[crate::http::CatalogModel], id: &str) -> bool { - is_auto_model(id) || models.iter().any(|m| m.id == id) +fn known_model_id(models: &[CatalogModel], id: &str) -> bool { + let id = catalog_model_id(id); + is_auto_model(&id) || models.iter().any(|m| catalog_model_id(&m.id) == id) } fn save_model_slot( @@ -842,7 +863,12 @@ fn save_model_slot( "fable" => "fable", _ => "default model", }; - println!("{} {} {}", term::ok("Saved"), label, term::model_id(id)); + println!( + "{} {} {}", + term::ok("Saved"), + label, + term::model_id(&session_model_label(id)) + ); Ok(0) } @@ -874,7 +900,7 @@ fn run_models(parsed: &ParsedArgs, env: &BTreeMap) -> Result) -> Result) -> Result) -> Result>(); let preset = profile.map(|p| p.pinned_preset().to_string()); @@ -1002,7 +1028,7 @@ fn run_whoami(parsed: &ParsedArgs, env: &BTreeMap) -> Result) -> Result #[cfg(feature = "native")] { if tui_wants_dump(parsed, env) { - let (state, _) = config_settings_frame( - parsed, - env, - &path, - false, - &mut CreditsCache::fresh(), - 0, - ); + let (state, _) = + config_settings_frame(parsed, env, &path, false, &mut CreditsCache::fresh(), 0); print!("{}", tui_dump_settings(state, env)); return Ok(0); } @@ -1301,7 +1321,7 @@ fn fill_general_settings( rows, kinds, "default", - display_model_id(profile.map(|p| p.default_model()).unwrap_or("auto")).into(), + session_model_label(profile.map(|p| p.default_model()).unwrap_or("auto")), Tone::Model, SettingKind::Model("default"), ); @@ -1329,7 +1349,11 @@ fn fill_general_settings( if present.is_empty() { "none detected".into() } else { - present.iter().map(|(id, _)| *id).collect::>().join(", ") + present + .iter() + .map(|(id, _)| *id) + .collect::>() + .join(", ") }, if present.is_empty() { Tone::Warn @@ -1402,9 +1426,8 @@ fn fill_agent_settings( } let cfg = load_config_if_present(path); - let tool = resolve_tool(cfg.as_ref(), id).unwrap_or_else(|_| { - crate::spawn::resolve_tool(None, id).expect("known agent") - }); + let tool = resolve_tool(cfg.as_ref(), id) + .unwrap_or_else(|_| crate::spawn::resolve_tool(None, id).expect("known agent")); let present = agent_available(id, &tool.command, env); section(rows, kinds, label); @@ -1496,7 +1519,7 @@ fn fill_agent_settings( _ => nonempty_slot(&profile.and_then(|p| p.claude_fable.clone())), }; let value = match &pinned { - Some(mid) => display_model_id(mid).to_string(), + Some(mid) => catalog_model_id(mid), None => format!("{} · default", slot_current_opt(profile, slot)), }; let tone = if pinned.is_some() { @@ -1633,9 +1656,7 @@ fn config_edit_row( .iter() .map(|(id, label)| format!("{id} — {label}")) .collect(); - let current = KNOWN_AGENTS - .iter() - .position(|(id, _)| *id == last.as_str()); + let current = KNOWN_AGENTS.iter().position(|(id, _)| *id == last.as_str()); let idx = term::pick("Coding agent", &labels, current)?; let (tool, label) = KNOWN_AGENTS[idx]; let mut cfg = load_config_if_present(path).unwrap_or_default(); @@ -1749,7 +1770,11 @@ fn config_account_actions( .map(|n| { let p = cfg.profiles.get(n); let key = mask_api_key(p.and_then(|p| p.api_key.as_deref())); - let mark = if n == &cfg.active_profile { " ●" } else { "" }; + let mark = if n == &cfg.active_profile { + " ●" + } else { + "" + }; format!("{n} · {key}{mark}") }) .collect(); @@ -1851,7 +1876,14 @@ fn config_reset_row(path: &std::path::Path, kind: SettingKind) -> Result Vec { ), format!( "model {}", - display_model_id(profile.map(|p| p.default_model()).unwrap_or("auto")) + session_model_label(profile.map(|p| p.default_model()).unwrap_or("auto")) ), format!("file {}", path.display()), ] @@ -1990,6 +2022,51 @@ fn run_config(parsed: &ParsedArgs, env: &BTreeMap) -> Result) -> bool { + match env.get("ANYR_NO_CATALOG").map(|s| s.as_str()) { + Some("1" | "true" | "TRUE" | "yes") => false, + _ => true, + } +} + +struct ResolvedModel { + id: String, + context_window: Option, +} + +/// Auto (unset / `auto` / `anyrouter/auto`) resolves to the catalog's most-used +/// model this week. A user-pinned id is kept. Failures keep the requested id. +fn resolve_session_model( + requested: &str, + base: &str, + key: Option<&str>, + env: &BTreeMap, +) -> ResolvedModel { + let requested = catalog_model_id(requested); + if !catalog_lookup_enabled(env) { + return ResolvedModel { + id: requested, + context_window: None, + }; + } + let Ok(models) = fetch_models(base, key) else { + return ResolvedModel { + id: requested, + context_window: None, + }; + }; + let id = if is_auto_model(&requested) { + most_used_model_id(&models).unwrap_or(requested) + } else { + requested + }; + let context_window = models + .iter() + .find(|m| catalog_model_id(&m.id) == id) + .and_then(|m| m.context_length); + ResolvedModel { id, context_window } +} + fn run_launch( tool_name: &str, parsed: &ParsedArgs, @@ -2016,13 +2093,15 @@ fn run_launch( let mut profile = stored .cloned() .unwrap_or_else(|| default_profile_for_env(Some(&base), Some(&key))); - profile.base_url = Some(base); + profile.base_url = Some(base.clone()); let aliases_changed = apply_claude_alias_flags(&mut profile, parsed); let tool = resolve_tool(existing.as_ref(), tool_name)?; - let model = crate::spawn::sanitize_model_id( + let requested = catalog_model_id( &get_string_flag(&parsed.flags, "model") .unwrap_or_else(|| profile.default_model().to_string()), ); + let resolved = resolve_session_model(&requested, &base, Some(&key), env); + let model = resolved.id; let effort = normalize_effort(get_string_flag(&parsed.flags, "effort").as_deref())?; let model_mode = if is_auto_model(&model) { "auto" @@ -2036,7 +2115,7 @@ fn run_launch( api_key: &key, model: &model, effort: effort.as_deref(), - context_window: None, + context_window: resolved.context_window, model_map: None, }); if tool_name == "pi" { @@ -2083,9 +2162,10 @@ fn run_launch( // Remember the model this launch used as the session default, so a bare // `{bin} claude` next time starts with it. if let Some(flag_model) = get_string_flag(&parsed.flags, "model") { - let id = display_model_id(&crate::spawn::sanitize_model_id(&flag_model)).to_string(); + let id = catalog_model_id(&flag_model); if let Some(p) = cfg.profiles.get_mut(&cfg.active_profile) { - p.default_model = Some(id); + // Auto stays unset so the next launch re-picks the most-used model. + p.default_model = if is_auto_model(&id) { None } else { Some(id) }; } } let _ = write_config(&cfg, &path); @@ -2171,7 +2251,7 @@ fn run_account(parsed: &ParsedArgs, env: &BTreeMap) -> Result) -> Result) -> Result { let missing = missing_agents(env, |id| tool_command_for(path, id)); if missing.is_empty() { - println!("{}", term::dim("Every known coding agent is already on PATH.")); + println!( + "{}", + term::dim("Every known coding agent is already on PATH.") + ); return Ok(0); } let labels: Vec = missing diff --git a/src/help.rs b/src/help.rs index b5c9a70..edf1a49 100644 --- a/src/help.rs +++ b/src/help.rs @@ -14,7 +14,7 @@ Add --ok to skip the launcher and start with current settings. Options: --ok, --yes Skip the launcher and start with current settings --no-check Skip the pre-launch reachability probe - --model auto| Session model. \"auto\" is anyrouter/auto (smart pick) + --model auto| Session model. \"auto\" picks the most-used catalog model --haiku Claude /model haiku and subagents --sonnet Claude /model sonnet --opus Claude /model opus diff --git a/src/http.rs b/src/http.rs index a840df0..2a08f66 100644 --- a/src/http.rs +++ b/src/http.rs @@ -121,7 +121,16 @@ pub struct CatalogModel { } pub fn fetch_models(base_url: &str, api_key: Option<&str>) -> Result, String> { - let url = join_api(base_url, "/v1/models?privacy=0"); + fetch_models_sorted(base_url, api_key, "usage") +} + +pub fn fetch_models_sorted( + base_url: &str, + api_key: Option<&str>, + sort: &str, +) -> Result, String> { + let path = format!("/v1/models?privacy=0&sort={sort}"); + let url = join_api(base_url, &path); let (status, body) = http_get(&url, api_key)?; if !(200..300).contains(&status) { return Err(format!("Could not fetch models (HTTP {status}).")); @@ -129,6 +138,18 @@ pub fn fetch_models(base_url: &str, api_key: Option<&str>) -> Result Option { + models.iter().find_map(|m| { + let id = crate::spawn::catalog_model_id(&m.id); + if id.is_empty() || crate::spawn::is_auto_model(&id) { + None + } else { + Some(id) + } + }) +} + fn parse_models_body(body: &str) -> Result, String> { let value: serde_json::Value = serde_json::from_str(body).map_err(|e| format!("Invalid models response: {e}"))?; @@ -498,6 +519,23 @@ mod tests { assert_eq!(me.display_label(), "duyet · a@b.co"); } + #[test] + fn parse_models_reads_context_and_most_used_is_first() { + let models = parse_models_body( + r#"{"data":[ + {"id":"stealth/ox-alpha","context_length":1000000}, + {"id":"openai/gpt-5.4-mini","context_length":128000} + ]}"#, + ) + .unwrap(); + assert_eq!(models[0].id, "stealth/ox-alpha"); + assert_eq!(models[0].context_length, Some(1_000_000)); + assert_eq!( + most_used_model_id(&models).as_deref(), + Some("stealth/ox-alpha") + ); + } + #[test] fn keys_newest_first_orders_by_created_at() { let keys = vec![ @@ -531,10 +569,7 @@ mod tests { ]; let sorted = keys_newest_first(keys); assert_eq!( - sorted - .iter() - .map(|k| k.name.as_str()) - .collect::>(), + sorted.iter().map(|k| k.name.as_str()).collect::>(), vec!["new", "old", "undated"] ); } diff --git a/src/spawn.rs b/src/spawn.rs index 435686c..b5dc3e3 100644 --- a/src/spawn.rs +++ b/src/spawn.rs @@ -6,6 +6,10 @@ use std::process::{Command, Stdio}; use crate::config::{Profile, YamlValue, DEFAULT_BASE_URL, DEFAULT_PRESET, DEFAULT_TIMEOUT_MS}; pub const PI_DEFAULT_MODEL: &str = "anthropic/claude-sonnet-4.6"; +/// Claude Code 1M-context suffix. Claude strips it before the gateway; other +/// agents must never send it (Pi/Codex 404 on `id[1m]`). +pub const CLAUDE_1M_SUFFIX: &str = "[1m]"; +const MIN_1M_CONTEXT: i64 = 1_000_000; const REASONING_LEVELS: &[&str] = &["minimal", "low", "medium", "high", "xhigh", "max"]; const CLAUDE_LEVELS: &[&str] = &["low", "medium", "high", "xhigh", "max"]; @@ -325,7 +329,10 @@ pub fn build_tool_env(input: BuildToolEnvInput<'_>) -> BTreeMap input.profile.timeout_ms().to_string(), ); if let Some(model_env) = &input.tool.model_env { - env.insert(model_env.clone(), input.model.to_string()); + env.insert( + model_env.clone(), + model_id_for_tool(input.tool_name, input.model, input.context_window), + ); } if let Some(effort) = input.effort { env.insert("ANYROUTER_EFFORT".into(), effort.to_string()); @@ -354,10 +361,11 @@ pub fn build_tool_env(input: BuildToolEnvInput<'_>) -> BTreeMap "$schema": "https://opencode.ai/config.json", "provider": { "anyrouter": provider.clone() } }); - if !input.model.is_empty() && input.model != "auto" { - provider["models"][input.model] = serde_json::json!({ "name": input.model }); + let catalog = catalog_model_id(input.model); + if !catalog.is_empty() && !is_auto_model(&catalog) { + provider["models"][&catalog] = serde_json::json!({ "name": catalog }); config["provider"]["anyrouter"] = provider; - config["model"] = serde_json::json!(format!("anyrouter/{}", input.model)); + config["model"] = serde_json::json!(format!("anyrouter/{catalog}")); } env.insert( "OPENCODE_CONFIG_CONTENT".into(), @@ -367,7 +375,7 @@ pub fn build_tool_env(input: BuildToolEnvInput<'_>) -> BTreeMap if input.tool_name == "claude" { env.insert( "ANTHROPIC_MODEL".into(), - display_model_id(input.model).to_string(), + model_id_for_tool("claude", input.model, input.context_window), ); env.insert( "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY".into(), @@ -387,8 +395,10 @@ pub fn build_tool_env(input: BuildToolEnvInput<'_>) -> BTreeMap let alias = |slot: &Option, default: &str| -> String { let explicit = slot.as_deref().map(str::trim).filter(|s| !s.is_empty()); match (pinned, explicit) { - (Some(id), None) => id.to_string(), - _ => default.to_string(), + (Some(id), None) => model_id_for_tool("claude", id, input.context_window), + (None, Some(id)) => model_id_for_tool("claude", id, None), + (Some(_), Some(id)) => model_id_for_tool("claude", id, None), + (None, None) => default.to_string(), } }; let haiku = alias(&input.profile.claude_haiku, input.profile.claude_haiku()); @@ -406,6 +416,9 @@ pub fn build_tool_env(input: BuildToolEnvInput<'_>) -> BTreeMap alias(&input.profile.claude_fable, input.profile.claude_fable()), ); env.insert("CLAUDE_CODE_SUBAGENT_MODEL".into(), haiku); + if !is_auto_model(input.model) && claude_wants_1m(input.context_window) { + env.insert("CLAUDE_CODE_AUTO_COMPACT_WINDOW".into(), "1000000".into()); + } // Label each picker entry with its role; otherwise four identical IDs // all render as "Custom model". for (key, value) in [ @@ -428,7 +441,6 @@ pub fn build_tool_env(input: BuildToolEnvInput<'_>) -> BTreeMap } } } - let _ = input.context_window; let _ = input.model_map; env } @@ -444,21 +456,63 @@ pub fn effort_args_for(tool_name: &str, effort: Option<&str>) -> Vec { } pub fn is_auto_model(model: &str) -> bool { - let value = sanitize_model_id(model); + let value = catalog_model_id(model); value.is_empty() || value == "auto" || value == "anyrouter/auto" } -pub fn display_model_id(model: &str) -> &str { +/// Catalog id for display and config. Auto is `anyrouter/auto`. +pub fn display_model_id(model: &str) -> String { + let id = catalog_model_id(model); + if is_auto_model(&id) { + "anyrouter/auto".into() + } else { + id + } +} + +/// Launcher / settings label: auto means "pick most used", not a stored id. +pub fn session_model_label(model: &str) -> String { if is_auto_model(model) { - "anyrouter/auto" + "auto · most used".into() + } else { + catalog_model_id(model) + } +} + +pub fn claude_wants_1m(context_window: Option) -> bool { + match context_window { + Some(n) => n >= MIN_1M_CONTEXT, + // Unknown: Claude Code strips `[1m]` before the provider, so appending + // is safe for the gateway and unlocks 1M when the model supports it. + None => true, + } +} + +/// Agent-specific model id. Claude Code gets `[1m]` (1M context). Pi/Codex/etc +/// get the catalog id — the suffix 404s on the OpenAI-compatible API. +pub fn model_id_for_tool(tool_name: &str, model: &str, context_window: Option) -> String { + let id = catalog_model_id(model); + if is_auto_model(&id) { + return display_model_id(&id); + } + if tool_name == "claude" && claude_wants_1m(context_window) { + if id.ends_with(CLAUDE_1M_SUFFIX) { + id + } else { + format!("{id}{CLAUDE_1M_SUFFIX}") + } } else { - model + id } } -/// Strip CSI color/bold sequences (and dangling `[1m` tails if ESC was already -/// dropped). A TUI-copied id like `stealth/ox-alpha[1m` 404s at the gateway. +/// Strip CSI, Claude's `[1m]` 1M suffix, and dangling SGR tails (`[1m` without +/// `]`). Config and non-Claude agents store/send the catalog id only. pub fn sanitize_model_id(model: &str) -> String { + catalog_model_id(model) +} + +pub fn catalog_model_id(model: &str) -> String { let mut s = String::with_capacity(model.len()); let mut chars = model.trim().chars().peekable(); while let Some(c) = chars.next() { @@ -475,6 +529,9 @@ pub fn sanitize_model_id(model: &str) -> String { } s.push(c); } + if let Some(stripped) = s.strip_suffix(CLAUDE_1M_SUFFIX) { + s = stripped.to_string(); + } if let Some(i) = s.rfind('[') { let tail = &s[i + 1..]; if tail.ends_with('m') @@ -490,7 +547,7 @@ pub fn sanitize_model_id(model: &str) -> String { } pub fn pi_resolved_model(model: &str) -> String { - let s = sanitize_model_id(model); + let s = catalog_model_id(model); if is_auto_model(&s) { PI_DEFAULT_MODEL.to_string() } else { @@ -592,13 +649,14 @@ pub fn model_args_for(tool_name: &str, model: &str, model_mode: &str) -> Vec Vec { @@ -713,19 +771,56 @@ mod tests { } #[test] - fn sanitize_model_id_strips_ansi_and_dangling_sgr() { + fn sanitize_model_id_strips_ansi_and_claude_1m_suffix() { assert_eq!(sanitize_model_id("stealth/ox-alpha"), "stealth/ox-alpha"); assert_eq!( sanitize_model_id("stealth/ox-alpha\u{1b}[1m"), "stealth/ox-alpha" ); assert_eq!(sanitize_model_id("stealth/ox-alpha[1m"), "stealth/ox-alpha"); + assert_eq!( + sanitize_model_id("stealth/ox-alpha[1m]"), + "stealth/ox-alpha" + ); assert_eq!( sanitize_model_id("\u{1b}[1mstealth/ox-alpha\u{1b}[0m"), "stealth/ox-alpha" ); assert_eq!(pi_resolved_model("anyrouter/auto"), PI_DEFAULT_MODEL); assert_eq!(pi_resolved_model("auto"), PI_DEFAULT_MODEL); + assert_eq!( + pi_resolved_model("stealth/ox-alpha[1m]"), + "stealth/ox-alpha" + ); + } + + #[test] + fn model_id_for_tool_appends_1m_only_for_claude() { + assert_eq!( + model_id_for_tool("claude", "stealth/ox-alpha", None), + "stealth/ox-alpha[1m]" + ); + assert_eq!( + model_id_for_tool("claude", "stealth/ox-alpha[1m]", None), + "stealth/ox-alpha[1m]" + ); + assert_eq!( + model_id_for_tool("claude", "stealth/ox-alpha", Some(200_000)), + "stealth/ox-alpha" + ); + assert_eq!( + model_id_for_tool("claude", "stealth/ox-alpha", Some(1_000_000)), + "stealth/ox-alpha[1m]" + ); + assert_eq!( + model_id_for_tool("pi", "stealth/ox-alpha[1m]", None), + "stealth/ox-alpha" + ); + assert_eq!( + model_id_for_tool("codex", "stealth/ox-alpha[1m]", Some(1_000_000)), + "stealth/ox-alpha" + ); + assert_eq!(model_id_for_tool("claude", "auto", None), "anyrouter/auto"); } #[test] @@ -792,7 +887,12 @@ mod tests { }); assert_eq!( env.get("ANTHROPIC_MODEL").map(String::as_str), - Some("stealth/ox-alpha") + Some("stealth/ox-alpha[1m]") + ); + assert_eq!( + env.get("CLAUDE_CODE_AUTO_COMPACT_WINDOW") + .map(String::as_str), + Some("1000000") ); // Every unset alias slot follows the pinned model so nothing // (subagents, automatic fallback) silently falls back to another model. @@ -805,7 +905,7 @@ mod tests { ] { assert_eq!( env.get(key).map(String::as_str), - Some("stealth/ox-alpha"), + Some("stealth/ox-alpha[1m]"), "{key} should follow the pinned model" ); } @@ -827,8 +927,9 @@ mod tests { model_map: None, }); assert_eq!( - env.get("ANTHROPIC_DEFAULT_SONNET_MODEL").map(String::as_str), - Some("z-ai/glm-4.7-flash") + env.get("ANTHROPIC_DEFAULT_SONNET_MODEL") + .map(String::as_str), + Some("z-ai/glm-4.7-flash[1m]") ); for key in [ "ANTHROPIC_DEFAULT_HAIKU_MODEL", @@ -838,7 +939,7 @@ mod tests { ] { assert_eq!( env.get(key).map(String::as_str), - Some("stealth/ox-alpha"), + Some("stealth/ox-alpha[1m]"), "{key} should follow the pinned model" ); } @@ -867,11 +968,11 @@ mod tests { ); assert_eq!( env.get("ANTHROPIC_DEFAULT_HAIKU_MODEL").map(String::as_str), - Some("z-ai/glm-4.7-flash") + Some("z-ai/glm-4.7-flash[1m]") ); assert_eq!( env.get("CLAUDE_CODE_SUBAGENT_MODEL").map(String::as_str), - Some("z-ai/glm-4.7-flash") + Some("z-ai/glm-4.7-flash[1m]") ); assert_eq!( env.get("ANYROUTER_MODEL_MODE").map(String::as_str), @@ -973,6 +1074,10 @@ mod tests { model_args_for("pi", "stealth/ox-alpha[1m", "concrete"), vec!["--model".to_string(), "stealth/ox-alpha".to_string()] ); + assert_eq!( + model_args_for("pi", "stealth/ox-alpha[1m]", "concrete"), + vec!["--model".to_string(), "stealth/ox-alpha".to_string()] + ); assert_eq!( model_args_for("pi", "auto", "auto"), vec!["--model".to_string(), PI_DEFAULT_MODEL.to_string()] diff --git a/tests/cli.rs b/tests/cli.rs index 14f304f..6a886aa 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -3,6 +3,8 @@ use std::process::Command; fn anyr() -> Command { let mut cmd = Command::new(env!("CARGO_BIN_EXE_anyr")); cmd.env("ANYR_NO_UPDATE", "1"); + // Skip live catalog lookup so auto stays anyrouter/auto in tests. + cmd.env("ANYR_NO_CATALOG", "1"); cmd } @@ -449,16 +451,16 @@ fn claude_dry_run_pinned_model_collapses_aliases() { }; assert_eq!(code, 0, "stderr={stderr}"); assert!( - stdout.contains("ANTHROPIC_MODEL=stealth/ox-alpha"), + stdout.contains("ANTHROPIC_MODEL=stealth/ox-alpha[1m]"), "{stdout}" ); // Unset alias slots follow the pinned model so nothing falls back to // haiku/sonnet/opus behind the user's back. for key_line in [ - "ANTHROPIC_DEFAULT_HAIKU_MODEL=stealth/ox-alpha", - "ANTHROPIC_DEFAULT_SONNET_MODEL=stealth/ox-alpha", - "ANTHROPIC_DEFAULT_OPUS_MODEL=stealth/ox-alpha", - "CLAUDE_CODE_SUBAGENT_MODEL=stealth/ox-alpha", + "ANTHROPIC_DEFAULT_HAIKU_MODEL=stealth/ox-alpha[1m]", + "ANTHROPIC_DEFAULT_SONNET_MODEL=stealth/ox-alpha[1m]", + "ANTHROPIC_DEFAULT_OPUS_MODEL=stealth/ox-alpha[1m]", + "CLAUDE_CODE_SUBAGENT_MODEL=stealth/ox-alpha[1m]", ] { assert!(stdout.contains(key_line), "missing {key_line}:\n{stdout}"); } @@ -492,11 +494,11 @@ fn claude_dry_run_haiku_flag_beats_pinned_model() { }; assert_eq!(code, 0, "stderr={stderr}"); assert!( - stdout.contains("ANTHROPIC_DEFAULT_HAIKU_MODEL=z-ai/glm-4.7-flash"), + stdout.contains("ANTHROPIC_DEFAULT_HAIKU_MODEL=z-ai/glm-4.7-flash[1m]"), "{stdout}" ); assert!( - stdout.contains("ANTHROPIC_DEFAULT_SONNET_MODEL=stealth/ox-alpha"), + stdout.contains("ANTHROPIC_DEFAULT_SONNET_MODEL=stealth/ox-alpha[1m]"), "{stdout}" ); } @@ -506,14 +508,7 @@ fn claude_yolo_flag_expands_to_dangerously_skip_permissions() { let key = "sk-ar-v1-testkey"; let (code, stdout, stderr) = { let out = anyr() - .args([ - "claude", - "--dry-run", - "--yes", - "--key", - key, - "--yolo", - ]) + .args(["claude", "--dry-run", "--yes", "--key", key, "--yolo"]) .env("ANYROUTER_HOME", temp_home()) .env_remove("ANYROUTER_API_KEY") .output() @@ -536,14 +531,7 @@ fn codex_yolo_flag_is_accepted_but_not_forwarded() { let key = "sk-ar-v1-testkey"; let (code, stdout, stderr) = { let out = anyr() - .args([ - "codex", - "--dry-run", - "--yes", - "--key", - key, - "--yolo", - ]) + .args(["codex", "--dry-run", "--yes", "--key", key, "--yolo"]) .env("ANYROUTER_HOME", temp_home()) .env_remove("ANYROUTER_API_KEY") .output() @@ -590,13 +578,13 @@ fn claude_dry_run_fable_flag_beats_pinned_model() { assert_eq!(code, 0, "stderr={stderr}"); // Explicit --fable wins over the pinned session model... assert!( - stdout.contains("ANTHROPIC_DEFAULT_FABLE_MODEL=anthropic/claude-fable-5"), + stdout.contains("ANTHROPIC_DEFAULT_FABLE_MODEL=anthropic/claude-fable-5[1m]"), "{stdout}" ); // ...while every other unset slot still follows the pin. for key_line in [ - "ANTHROPIC_DEFAULT_SONNET_MODEL=stealth/ox-alpha", - "ANTHROPIC_DEFAULT_OPUS_MODEL=stealth/ox-alpha", + "ANTHROPIC_DEFAULT_SONNET_MODEL=stealth/ox-alpha[1m]", + "ANTHROPIC_DEFAULT_OPUS_MODEL=stealth/ox-alpha[1m]", ] { assert!(stdout.contains(key_line), "missing {key_line}:\n{stdout}"); } @@ -632,7 +620,7 @@ fn claude_dry_run_haiku_flag_overrides_alias() { }; assert_eq!(code, 0, "stderr={stderr}"); assert!( - stdout.contains("ANTHROPIC_DEFAULT_HAIKU_MODEL=z-ai/glm-4.7-flash"), + stdout.contains("ANTHROPIC_DEFAULT_HAIKU_MODEL=z-ai/glm-4.7-flash[1m]"), "{stdout}" ); assert!( @@ -671,6 +659,35 @@ fn launch_remembers_explicit_model_as_session_default() { .expect("launch"); assert_eq!(out.status.code().unwrap_or(1), 0); + // A Claude `[1m]` suffix must not be written into config (Pi/Codex 404 on it). + let home_suffix = temp_home(); + let out = anyr() + .args([ + "claude", + "--yes", + "--key", + "sk-ar-v1-testkey", + "--model", + "stealth/ox-alpha[1m]", + ]) + .env("ANYROUTER_HOME", &home_suffix) + .env("ANYROUTER_CLAUDE_PATH", stub) + .env_remove("ANYROUTER_API_KEY") + .output() + .expect("launch suffix"); + assert_eq!(out.status.code().unwrap_or(1), 0); + let cfg_suffix = + std::fs::read_to_string(home_suffix.join("config.yaml")).expect("config written"); + assert!( + cfg_suffix.contains("default_model: stealth/ox-alpha"), + "{cfg_suffix}" + ); + assert!( + !cfg_suffix.contains("stealth/ox-alpha[1m]"), + "catalog id only:\n{cfg_suffix}" + ); + let _ = std::fs::remove_dir_all(&home_suffix); + // Second launch with no --model: dry-run reveals which model was picked. let out = anyr() .args(["claude", "--yes", "--dry-run", "--key", "sk-ar-v1-testkey"]) @@ -680,7 +697,7 @@ fn launch_remembers_explicit_model_as_session_default() { .expect("relaunch"); let stdout = String::from_utf8_lossy(&out.stdout); assert!( - stdout.contains("ANTHROPIC_MODEL=z-ai/glm-4.7-flash"), + stdout.contains("ANTHROPIC_MODEL=z-ai/glm-4.7-flash[1m]"), "session default not remembered:\n{stdout}" ); @@ -1193,7 +1210,10 @@ profiles: assert!(stdout.contains("model…"), "{stdout}"); assert!(stdout.contains("install…"), "{stdout}"); assert!(stdout.contains("quit"), "{stdout}"); - assert!(stdout.contains('❯'), "palette must show the input line: {stdout}"); + assert!( + stdout.contains('❯'), + "palette must show the input line: {stdout}" + ); assert!( stdout.contains('╭') && stdout.contains('╯'), "dump should look like a dialog card: {stdout}"