From 835f99042bbb14cb7067a930b6d0d504bf9a123e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 11:37:33 +0000 Subject: [PATCH] feat(cli): peel [1m]/[500k] on all anyrouter virtual presets anyrouter/free[1m], byok, coding, agent, hermes, cowork, and latest keep the virtual id and send provider.min_context, same as auto. Disable Claude catalog discovery so they are not remapped to a SKU. --- src/help.rs | 1 + src/key.rs | 17 ++++++++++--- src/spawn.rs | 62 ++++++++++++++++++++++++++++++++++++++++++--- tests/cli.rs | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 144 insertions(+), 7 deletions(-) diff --git a/src/help.rs b/src/help.rs index 1554152..9fdd8d7 100644 --- a/src/help.rs +++ b/src/help.rs @@ -90,6 +90,7 @@ Start Swap the model, keep the agent $ {bin} claude --model z-ai/glm-4.7-flash $ {bin} claude --yolo --model anyrouter/auto[1m] + $ {bin} claude --model anyrouter/free[1m] $ {bin} claude --model anyrouter/auto[500k] $ {bin} claude --model auto --effort high diff --git a/src/key.rs b/src/key.rs index 7439a8b..ba96ada 100644 --- a/src/key.rs +++ b/src/key.rs @@ -98,11 +98,13 @@ 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. +/// Keep `[1m]` / `[500k]` on virtual presets (`anyrouter/auto`, `free`, `byok`, …) +/// so launch can peel them 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) + if crate::config::parse_context_window_suffix(raw).is_some() + && crate::spawn::is_virtual_preset(raw) { let stem = crate::config::strip_context_window_suffix(raw); let suffix = &raw[stem.len()..]; @@ -344,6 +346,15 @@ agents: resolve_launch_model(&auto_1m, Some(&cfg), claude_profile, "claude"), "anyrouter/auto[1m]" ); + let mut free_1m = HashMap::new(); + free_1m.insert( + "model".into(), + FlagValue::Value("anyrouter/free[1m]".into()), + ); + assert_eq!( + resolve_launch_model(&free_1m, Some(&cfg), claude_profile, "claude"), + "anyrouter/free[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 427f11a..b53dfe4 100644 --- a/src/spawn.rs +++ b/src/spawn.rs @@ -407,10 +407,10 @@ pub fn build_tool_env(input: BuildToolEnvInput<'_>) -> BTreeMap ); env.insert( "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY".into(), - // Discovery remaps unknown ids (including virtual `anyrouter/auto`) - // onto a catalog SKU such as Laguna. Keep it off for auto so the + // Discovery remaps unknown ids (including virtual `anyrouter/*`) + // onto a catalog SKU such as Laguna. Keep it off for presets so the // gateway still sees the virtual id + extra-body min_context. - if input.tool.enable_gateway_model_discovery && !is_auto_model(input.model) { + if input.tool.enable_gateway_model_discovery && !is_virtual_preset(input.model) { "1" } else { "0" @@ -422,7 +422,7 @@ pub fn build_tool_env(input: BuildToolEnvInput<'_>) -> BTreeMap // automatic model fallback, which rides the fable alias on third-party // providers — falls back to a different model. Slots set explicitly // (--haiku/--sonnet/--opus/--fable or the profile config) still win. - let pinned = (!is_auto_model(input.model)).then_some(input.model); + let pinned = (!is_virtual_preset(input.model)).then_some(input.model); let alias = |slot: &Option, default: &str| -> String { let explicit = slot.as_deref().map(str::trim).filter(|s| !s.is_empty()); match (pinned, explicit) { @@ -502,12 +502,35 @@ pub fn effort_args_for(tool_name: &str, effort: Option<&str>) -> Vec { vec![] } +/// First-party virtual presets (`anyrouter/auto`, `anyrouter/free`, …). +/// Keep in lockstep with gateway `ANYROUTER_VIRTUAL_MODELS`. +const VIRTUAL_PRESETS: &[&str] = &[ + "anyrouter/auto", + "anyrouter/free", + "anyrouter/byok", + "anyrouter/coding", + "anyrouter/agent", + "anyrouter/hermes", + "anyrouter/cowork", + "anyrouter/latest", +]; + pub fn is_auto_model(model: &str) -> bool { let value = catalog_model_id(model); let value = crate::config::strip_context_window_suffix(&value); value.is_empty() || value == "auto" || value == "anyrouter/auto" } +/// Virtual routing presets. `[1m]` / `[500k]` are min_context floors, not listing ids. +pub fn is_virtual_preset(model: &str) -> bool { + if is_auto_model(model) { + return true; + } + let id = catalog_model_id(model); + let id = crate::config::strip_context_window_suffix(&id); + VIRTUAL_PRESETS.iter().any(|p| *p == id) +} + /// Catalog id for display and config. Auto is `anyrouter/auto`. pub fn display_model_id(model: &str) -> String { let id = catalog_model_id(model); @@ -1399,6 +1422,18 @@ mod tests { let body = routing.extra_body_json().expect("body"); assert!(body.contains("\"min_context\":1000000"), "{body}"); assert!(body.contains("\"sort\":\"exacto\""), "{body}"); + for id in [ + "anyrouter/free[1m]", + "anyrouter/byok[1m]", + "anyrouter/hermes[500k]", + "anyrouter/latest[1m]", + ] { + let mut r = crate::config::RoutingConstraints::default(); + let catalog = apply_model_id_routing(id, &mut r); + assert!(is_virtual_preset(id), "{id}"); + assert!(!catalog.contains('['), "{catalog}"); + assert!(r.min_context.is_some(), "{id}"); + } } #[test] @@ -1430,6 +1465,25 @@ mod tests { Some("0"), "auto must not be remapped by catalog discovery" ); + let free = build_tool_env(BuildToolEnvInput { + tool_name: "claude", + tool: &tool, + profile: &profile(), + api_key: "sk-ar-v1-secret", + model: "anyrouter/free[1m]", + effort: None, + context_window: None, + model_map: None, + }); + assert_eq!( + free.get("ANTHROPIC_MODEL").map(String::as_str), + Some("anyrouter/free") + ); + assert_eq!( + free.get("CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY") + .map(String::as_str), + Some("0") + ); let concrete = build_tool_env(BuildToolEnvInput { tool_name: "claude", tool: &tool, diff --git a/tests/cli.rs b/tests/cli.rs index 5e0a138..b63252b 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -2346,6 +2346,77 @@ profiles: let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn claude_dry_run_peels_virtual_preset_1m_into_extra_body() { + let dir = std::env::temp_dir().join(format!("anyr-cli-virtual-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(); + for (flag, expected) in [ + ("anyrouter/free[1m]", "anyrouter/free"), + ("anyrouter/byok[1m]", "anyrouter/byok"), + ("anyrouter/hermes[500k]", "anyrouter/hermes"), + ] { + let out = anyr() + .args([ + "claude", + "--dry-run", + "--yes", + "--config", + path.to_str().unwrap(), + "--model", + flag, + ]) + .output() + .expect("claude dry-run virtual[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, + "{flag}\n{stdout}{stderr}" + ); + assert!( + stdout.contains(&format!("ANTHROPIC_MODEL={expected}")), + "{flag} must keep virtual id:\n{stdout}" + ); + assert!( + stdout.contains("CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=0"), + "{flag}\n{stdout}" + ); + assert!( + stdout.contains("CLAUDE_CODE_EXTRA_BODY="), + "{flag}\n{stdout}" + ); + if flag.contains("[1m]") { + assert!( + stdout.contains("\"min_context\":1000000"), + "{flag}\n{stdout}" + ); + } else { + assert!( + stdout.contains("\"min_context\":500000"), + "{flag}\n{stdout}" + ); + } + assert!( + !stdout.to_ascii_lowercase().contains("laguna"), + "{flag}\n{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