From 8638395cf236d6b7f2fe59788381c12a9334e80c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 07:09:40 +0000 Subject: [PATCH] fix(cli): keep anyrouter/auto[1m] as auto plus min_context Claude Code strips [1m] and catalog discovery remapped virtual auto onto a concrete SKU (Laguna). Peel the floor into provider.min_context, leave ANTHROPIC_MODEL as anyrouter/auto, and disable discovery for auto. Fixes #65 --- src/cmd/launch.rs | 36 ++++++---------- src/config.rs | 13 +++++- src/key.rs | 28 ++++++++++-- src/spawn.rs | 107 +++++++++++++++++++++++++++++++++++++++++++++- tests/cli.rs | 45 +++++++++++++++++++ 5 files changed, 201 insertions(+), 28 deletions(-) diff --git a/src/cmd/launch.rs b/src/cmd/launch.rs index 16cc095..8ed387b 100644 --- a/src/cmd/launch.rs +++ b/src/cmd/launch.rs @@ -10,9 +10,10 @@ use crate::key::{ }; use crate::parse::{get_string_flag, ParsedArgs}; use crate::spawn::{ - apply_routing_env, build_tool_env, catalog_model_id, default_profile_for_env, 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, + apply_model_id_routing, apply_routing_env, build_tool_env, catalog_model_id, + default_profile_for_env, 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, }; use crate::term; @@ -88,12 +89,15 @@ pub(crate) fn run_launch( 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 requested = catalog_model_id(&resolve_launch_model( - &parsed.flags, - existing.as_ref(), - &profile, - tool_name, - )); + let mut routing = existing + .as_ref() + .and_then(|c| c.agent_binding(tool_name)) + .map(|b| b.routing.clone()) + .unwrap_or_default(); + let requested = apply_model_id_routing( + &resolve_launch_model(&parsed.flags, existing.as_ref(), &profile, tool_name), + &mut routing, + ); 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())?; @@ -112,20 +116,6 @@ pub(crate) fn run_launch( context_window: resolved.context_window, model_map: None, }); - let mut routing = existing - .as_ref() - .and_then(|c| c.agent_binding(tool_name)) - .map(|b| b.routing.clone()) - .unwrap_or_default(); - if let Some(raw) = get_string_flag(&parsed.flags, "model") { - routing.apply_model_id_context_suffix(&raw); - } else if let Some(raw) = existing - .as_ref() - .and_then(|c| c.agent_binding(tool_name)) - .and_then(|b| b.default_model.as_deref()) - { - routing.apply_model_id_context_suffix(raw); - } apply_routing_env(&mut env_map, &routing, tool_name); if tool_name == "pi" { let catalog = fetch_models(&base, Some(&key)).unwrap_or_default(); diff --git a/src/config.rs b/src/config.rs index 1cb0b77..906d282 100644 --- a/src/config.rs +++ b/src/config.rs @@ -235,10 +235,21 @@ impl RoutingConstraints { self.min_context = on.then_some(ROUTING_MIN_1M_CONTEXT); } + /// Raise the token floor. Higher (stricter) wins. + pub fn merge_min_context(&mut self, floor: i64) { + if floor <= 0 { + return; + } + self.min_context = Some(match self.min_context { + Some(existing) => existing.max(floor), + None => floor, + }); + } + /// Merge `[1m]` / `[500k]` on a model id into `min_context` (higher wins). pub fn apply_model_id_context_suffix(&mut self, model: &str) { if let Some(n) = parse_context_window_suffix(model) { - self.min_context = Some(self.min_context.map(|e| e.max(n)).unwrap_or(n)); + self.merge_min_context(n); } } diff --git a/src/key.rs b/src/key.rs index 214c91e..7439a8b 100644 --- a/src/key.rs +++ b/src/key.rs @@ -98,6 +98,19 @@ pub fn resolve_launch_api_key( .map(str::to_string) } +/// Keep `[1m]` / `[500k]` on virtual `anyrouter/auto` so launch can peel it +/// into `provider.min_context`. Concrete ids still drop Claude's `[1m]` tag. +fn launch_model_id(raw: &str) -> String { + let raw = raw.trim(); + if crate::config::parse_context_window_suffix(raw).is_some() && crate::spawn::is_auto_model(raw) + { + let stem = crate::config::strip_context_window_suffix(raw); + let suffix = &raw[stem.len()..]; + return format!("{}{}", crate::spawn::display_model_id(stem), suffix); + } + crate::spawn::display_model_id(raw) +} + /// Launch model for `tool`: `--model`, then the agent's bound id, then the /// profile default. Does not invent catalog ids. pub fn resolve_launch_model( @@ -107,7 +120,7 @@ pub fn resolve_launch_model( tool: &str, ) -> String { if let Some(m) = get_string_flag(flags, "model") { - return crate::spawn::display_model_id(&m); + return launch_model_id(&m); } let id = canonical_tool(tool); if let Some(m) = config @@ -116,9 +129,9 @@ pub fn resolve_launch_model( .map(str::trim) .filter(|s| !s.is_empty()) { - return crate::spawn::display_model_id(m); + return launch_model_id(m); } - crate::spawn::display_model_id(profile.default_model()) + launch_model_id(profile.default_model()) } pub fn resolve_base_url( @@ -322,6 +335,15 @@ agents: resolve_launch_model(&auto_flag, Some(&cfg), claude_profile, "claude"), "anyrouter/auto" ); + let mut auto_1m = HashMap::new(); + auto_1m.insert( + "model".into(), + FlagValue::Value("anyrouter/auto[1m]".into()), + ); + assert_eq!( + resolve_launch_model(&auto_1m, Some(&cfg), claude_profile, "claude"), + "anyrouter/auto[1m]" + ); let empty = Profile::default(); assert_eq!( resolve_launch_model(&flags, None, &empty, "claude"), diff --git a/src/spawn.rs b/src/spawn.rs index 2b7e126..427f11a 100644 --- a/src/spawn.rs +++ b/src/spawn.rs @@ -407,7 +407,10 @@ pub fn build_tool_env(input: BuildToolEnvInput<'_>) -> BTreeMap ); env.insert( "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY".into(), - if input.tool.enable_gateway_model_discovery { + // Discovery remaps unknown ids (including virtual `anyrouter/auto`) + // onto a catalog SKU such as Laguna. Keep it off for auto so the + // gateway still sees the virtual id + extra-body min_context. + if input.tool.enable_gateway_model_discovery && !is_auto_model(input.model) { "1" } else { "0" @@ -550,6 +553,66 @@ pub fn sanitize_model_id(model: &str) -> String { catalog_model_id(model) } +/// Parse trailing `[k|m]` as a token floor (`k` = thousand, `m` = million). +/// Same spelling as `anyrouter/auto[1m]` / `[500k]`. Does not strip CSI. +pub fn peel_context_window_suffixes(model: &str) -> (String, Option) { + let mut s = model.trim().to_string(); + let mut min_context: Option = None; + loop { + let Some(open) = s.rfind('[') else { + break; + }; + if !s.ends_with(']') || open + 2 >= s.len() { + break; + } + let inner = &s[open + 1..s.len() - 1]; + let Some(unit) = inner.chars().last() else { + break; + }; + let digits = &inner[..inner.len() - 1]; + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + break; + } + let n: i64 = match digits.parse() { + Ok(n) if n > 0 => n, + _ => break, + }; + let floor = match unit { + 'm' | 'M' => n.saturating_mul(1_000_000), + 'k' | 'K' => n.saturating_mul(1_000), + _ => break, + }; + min_context = Some(match min_context { + Some(existing) => existing.max(floor), + None => floor, + }); + s.truncate(open); + } + (s, min_context) +} + +/// Peel `[1m]` / `[500k]` (and `:exacto`) into routing prefs; return catalog id. +pub fn apply_model_id_routing( + model: &str, + routing: &mut crate::config::RoutingConstraints, +) -> String { + let (mut peeled, floor) = peel_context_window_suffixes(model); + if let Some(n) = floor { + routing.merge_min_context(n); + } + if let Some((base, suffix)) = peeled.rsplit_once(':') { + if suffix.eq_ignore_ascii_case(crate::config::ROUTING_SORT_EXACTO) { + routing.set_exacto(true); + let (base2, floor2) = peel_context_window_suffixes(base); + if let Some(n) = floor2 { + routing.merge_min_context(n); + } + peeled = base2; + } + } + catalog_model_id(&peeled) +} + pub fn catalog_model_id(model: &str) -> String { let mut s = String::with_capacity(model.len()); let mut chars = model.trim().chars().peekable(); @@ -1315,9 +1378,29 @@ mod tests { assert!(body.contains("\"sort\":\"exacto\""), "{body}"); assert!(body.contains("\"require_params\":[\"tools\"]"), "{body}"); assert!(body.contains("\"min_context\":1000000"), "{body}"); + assert!(body.contains("\"provider\""), "{body}"); assert_eq!(env.get("ANYROUTER_EXTRA_BODY"), Some(body)); } + #[test] + fn peel_auto_1m_and_500k_into_min_context() { + let (id, floor) = peel_context_window_suffixes("anyrouter/auto[1m]"); + assert_eq!(id, "anyrouter/auto"); + assert_eq!(floor, Some(1_000_000)); + let (id, floor) = peel_context_window_suffixes("anyrouter/auto[500k]"); + assert_eq!(id, "anyrouter/auto"); + assert_eq!(floor, Some(500_000)); + let mut routing = crate::config::RoutingConstraints::default(); + let catalog = apply_model_id_routing("anyrouter/auto[1m]:exacto", &mut routing); + assert_eq!(catalog, "anyrouter/auto"); + assert!(is_auto_model(&catalog)); + assert_eq!(routing.min_context, Some(1_000_000)); + assert!(routing.wants_exacto()); + let body = routing.extra_body_json().expect("body"); + assert!(body.contains("\"min_context\":1000000"), "{body}"); + assert!(body.contains("\"sort\":\"exacto\""), "{body}"); + } + #[test] fn claude_shadow_env_overrides_parent_anthropic_key() { // WHY: a leftover ANTHROPIC_API_KEY in the parent shell must not win. @@ -1341,6 +1424,28 @@ mod tests { env.get("ANTHROPIC_API_KEY").map(String::as_str), Some("sk-ar-v1-secret") ); + assert_eq!( + env.get("CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY") + .map(String::as_str), + Some("0"), + "auto must not be remapped by catalog discovery" + ); + let concrete = build_tool_env(BuildToolEnvInput { + tool_name: "claude", + tool: &tool, + profile: &profile(), + api_key: "sk-ar-v1-secret", + model: "poolside/laguna-s-2.1", + effort: None, + context_window: None, + model_map: None, + }); + assert_eq!( + concrete + .get("CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY") + .map(String::as_str), + Some("1") + ); } #[test] diff --git a/tests/cli.rs b/tests/cli.rs index cda4531..5e0a138 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -2301,6 +2301,51 @@ agents: let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn claude_dry_run_peels_auto_1m_into_extra_body_not_laguna() { + let dir = std::env::temp_dir().join(format!("anyr-cli-auto-1m-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("config.yaml"); + std::fs::write( + &path, + "\ +active_profile: default +profiles: + default: + api_key: sk-ar-v1-fixture-key-0001 + default_model: auto +", + ) + .unwrap(); + let out = anyr() + .args([ + "claude", + "--dry-run", + "--yes", + "--config", + path.to_str().unwrap(), + "--model", + "anyrouter/auto[1m]", + ]) + .output() + .expect("claude dry-run auto[1m]"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code().unwrap_or(1), 0, "{stdout}{stderr}"); + assert!( + stdout.contains("ANTHROPIC_MODEL=anyrouter/auto"), + "must keep virtual auto, not a concrete SKU:\n{stdout}" + ); + assert!( + !stdout.contains("ANTHROPIC_MODEL=anyrouter/auto[1m]"), + "Claude strips [1m]; send min_context instead:\n{stdout}" + ); + assert!(!stdout.to_ascii_lowercase().contains("laguna"), "{stdout}"); + assert!(stdout.contains("CLAUDE_CODE_EXTRA_BODY="), "{stdout}"); + assert!(stdout.contains("\"min_context\":1000000"), "{stdout}"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn unsigned_hud_dump_offers_launch_claude() { // WHY: right after install there is no key yet. Enter on the HUD