Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 13 additions & 23 deletions src/cmd/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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())?;
Expand All @@ -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();
Expand Down
13 changes: 12 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
28 changes: 25 additions & 3 deletions src/key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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"),
Expand Down
107 changes: 106 additions & 1 deletion src/spawn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,10 @@ pub fn build_tool_env(input: BuildToolEnvInput<'_>) -> BTreeMap<String, String>
);
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"
Expand Down Expand Up @@ -550,6 +553,66 @@ pub fn sanitize_model_id(model: &str) -> String {
catalog_model_id(model)
}

/// Parse trailing `[<n>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<i64>) {
let mut s = model.trim().to_string();
let mut min_context: Option<i64> = 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();
Expand Down Expand Up @@ -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.
Expand All @@ -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]
Expand Down
45 changes: 45 additions & 0 deletions tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading