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
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "0.1.14"
".": "0.1.15"
}
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ This file is maintained automatically by [release-please](https://github.com/goo

GitHub Releases use this file as the release notes (full history through that tag).

## [0.1.15](https://github.com/anyrouter-dev/cli/compare/v0.1.14...v0.1.15) (2026-09-16)


### Bug Fixes

* **cli:** keep Claude HUD floors on virtual presets end-to-end (ANTHROPIC_MODEL, extra-body min_context, compact window, no catalog remap)
* **cli:** bare `anyr update` keeps the config channel; only `--beta`/`--stable` persist a switch
* **cli:** skip a broken latest GitHub release (checksum / missing asset) and install the next good build on that channel


## [0.1.14](https://github.com/anyrouter-dev/cli/compare/v0.1.13...v0.1.14) (2026-09-15)


Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "anyr-cli"
version = "0.1.14" # x-release-please-version
version = "0.1.15" # x-release-please-version
edition = "2021"
description = "AnyRouter CLI — native binary"
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@anyr/cli",
"version": "0.1.14",
"version": "0.1.15",
"description": "AnyRouter CLI — native binary",
"license": "MIT",
"homepage": "https://anyrouter.dev/cli",
Expand Down
78 changes: 57 additions & 21 deletions src/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,39 @@ pub fn select_latest_release(releases: &[Release], channel: Channel) -> Result<R
})
}

fn channel_matches(rel: &Release, channel: Channel) -> bool {
match channel {
Channel::Stable => !rel.prerelease,
Channel::Beta => rel.prerelease,
}
}

/// Newest-first releases on `channel` that look installable.
/// Prefers rows that list `asset`; otherwise any non-empty asset list.
/// Empty-asset stables such as v0.1.11 are omitted so we do not 404 `/latest`.
pub fn channel_update_candidates(
releases: &[Release],
channel: Channel,
asset: &str,
) -> Vec<Release> {
let mut named: Vec<(Version, Release)> = releases
.iter()
.filter(|rel| channel_matches(rel, channel))
.filter(|rel| rel.assets.iter().any(|a| a.name == asset))
.filter_map(|rel| parse_version(&rel.tag_name).map(|v| (v, rel.clone())))
.collect();
if named.is_empty() {
named = releases
.iter()
.filter(|rel| channel_matches(rel, channel))
.filter(|rel| !rel.assets.is_empty())
.filter_map(|rel| parse_version(&rel.tag_name).map(|v| (v, rel.clone())))
.collect();
}
named.sort_by(|a, b| b.0.cmp(&a.0));
named.into_iter().map(|(_, r)| r).collect()
}

/// Latest release on `channel` that has binaries.
/// Prefers a release that lists `asset`; otherwise any non-empty asset list.
/// Empty-asset stables such as v0.1.11 are skipped so we do not 404 `/latest`.
Expand All @@ -289,29 +322,16 @@ pub fn select_latest_release_with_asset(
channel: Channel,
asset: &str,
) -> Result<Release, String> {
let named: Vec<Release> = releases
.iter()
.filter(|rel| rel.assets.iter().any(|a| a.name == asset))
.cloned()
.collect();
if let Ok(rel) = select_latest_release(&named, channel) {
return Ok(rel);
}
let nonempty: Vec<Release> = releases
.iter()
.filter(|rel| !rel.assets.is_empty())
.cloned()
.collect();
match select_latest_release(&nonempty, channel) {
Ok(rel) => Ok(rel),
Err(_) => match channel {
Channel::Stable => Err(format!(
channel_update_candidates(releases, channel, asset)
.into_iter()
.next()
.ok_or_else(|| match channel {
Channel::Stable => format!(
"No stable GitHub release has {asset} (latest non-prerelease may be empty). \
Try `anyr update --beta`."
)),
Channel::Beta => Err(format!("No beta (prerelease) has {asset}.")),
},
}
),
Channel::Beta => format!("No beta (prerelease) has {asset}."),
})
}

fn href_end(s: &str) -> usize {
Expand Down Expand Up @@ -625,6 +645,22 @@ mod tests {
{"tag_name":"v0.1.12-beta.98","prerelease":true,"assets":[{"name":"anyr-linux-x86_64","browser_download_url":"https://github.com/anyrouter-dev/cli/releases/download/v0.1.12-beta.98/anyr-linux-x86_64"}]}
]"#;

#[test]
fn channel_update_candidates_newest_first_skips_empty() {
let json = r#"[
{"tag_name":"v0.1.14","prerelease":false,"assets":[{"name":"anyr-linux-x86_64","browser_download_url":"https://example/0.1.14"}]},
{"tag_name":"v0.1.13","prerelease":false,"assets":[{"name":"anyr-linux-x86_64","browser_download_url":"https://example/0.1.13"}]},
{"tag_name":"v0.1.12","prerelease":false,"assets":[]},
{"tag_name":"v0.1.15-beta.1","prerelease":true,"assets":[{"name":"anyr-linux-x86_64","browser_download_url":"https://example/beta"}]}
]"#;
let rels = parse_releases(json).unwrap();
let tags: Vec<_> = channel_update_candidates(&rels, Channel::Stable, "anyr-linux-x86_64")
.into_iter()
.map(|r| r.tag_name)
.collect();
assert_eq!(tags, vec!["v0.1.14".to_string(), "v0.1.13".to_string()]);
}

#[test]
fn select_latest_with_asset_skips_empty_stable() {
let rels = parse_releases(EMPTY_STABLE).unwrap();
Expand Down
32 changes: 15 additions & 17 deletions src/cmd/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ use crate::key::{
};
use crate::parse::{get_string_flag, ParsedArgs};
use crate::spawn::{
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,
apply_model_id_routing, apply_routing_env, build_tool_env, catalog_context_window,
catalog_model_id, default_profile_for_env, display_model_id, effort_args_for, env_command_path,
is_auto_model, is_virtual_preset, model_args_for, normalize_effort, prepare_pi_wrapper,
provider_args_for, render_dry_run, resolve_tool, spawn_child, BuildToolEnvInput,
};
use crate::term;

Expand All @@ -33,28 +33,26 @@ pub(crate) fn resolve_session_model(
env: &BTreeMap<String, String>,
) -> ResolvedModel {
let requested = catalog_model_id(requested);
if !catalog_lookup_enabled(env) {
let id = if is_auto_model(&requested) {
display_model_id(&requested)
} else {
requested
};
if is_virtual_preset(&id) || !catalog_lookup_enabled(env) {
// Virtual anyrouter/* stays the preset + floor suffix. Do not remap
// onto a catalog SKU or inherit auto's 200k listing window.
return ResolvedModel {
id: requested,
id,
context_window: None,
};
}
let Ok(models) = fetch_models(base, key) else {
return ResolvedModel {
id: requested,
id,
context_window: None,
};
};
// Keep `auto` / `anyrouter/auto` as-is so the gateway applies the full
// preset failover chain on the first turn — pinning a single concrete model
// here would defeat anyrouter/auto's cross-model failover (north star:
// "fewer models that work"). The live `/models` lookup still contributes the
// context-window probe used for Claude's 1M-context suffix decision below.
let id = requested;
let context_window = models
.iter()
.find(|m| is_auto_model(&m.id))
.and_then(|m| m.context_length);
let context_window = catalog_context_window(&id, &models);
ResolvedModel { id, context_window }
}

Expand Down
7 changes: 6 additions & 1 deletion src/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,9 @@ Usage:
{bin} upgrade [--check] [--beta|--stable] [--channel stable|beta] [--dry-run]
{bin} update [--beta|--stable] (alias)

Bare `{bin} update` / `{bin} update --check` keep the config channel.
Only `--beta` or `--stable` switch the channel and persist it.

Switch channel and update:
{bin} update --beta follow GitHub prereleases (persist + install)
{bin} update --stable follow latest non-prerelease (persist + install)
Expand Down Expand Up @@ -463,8 +466,10 @@ While installing, a spinner ticks with the from → to versions and channel:

--check reports current vs latest without installing.
--fixture <path> / ANYR_RELEASES_JSON skips the network (tests / dry-run).
--channel stable|beta overrides the config file for this run only.
--channel stable|beta overrides the config file for this run only (does not persist).
--beta / --stable write channel: into the config, then install that channel.
If the newest build fails checksum, is missing, or is corrupt, the next
good release on the same channel is installed instead of aborting.
";

const RELAY: &str = "\
Expand Down
114 changes: 89 additions & 25 deletions src/spawn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -403,16 +403,14 @@ pub fn build_tool_env(input: BuildToolEnvInput<'_>) -> BTreeMap<String, String>
);
}
if input.tool_name == "claude" {
env.insert(
"ANTHROPIC_MODEL".into(),
model_id_for_tool("claude", input.model, input.min_context),
);
let anthropic_model = model_id_for_tool("claude", input.model, input.min_context);
env.insert("ANTHROPIC_MODEL".into(), anthropic_model);
env.insert(
"CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY".into(),
// 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_virtual_preset(input.model) {
if claude_gateway_discovery_enabled(input.tool, input.model) {
"1"
} else {
"0"
Expand Down Expand Up @@ -449,15 +447,7 @@ pub fn build_tool_env(input: BuildToolEnvInput<'_>) -> BTreeMap<String, String>
alias(&input.profile.claude_fable, input.profile.claude_fable()),
);
env.insert("CLAUDE_CODE_SUBAGENT_MODEL".into(), haiku);
let floor = peel_context_window_suffixes(input.model)
.1
.or(input.min_context);
let wants_compact = if is_virtual_preset(input.model) {
floor.is_some_and(|n| n >= MIN_1M_CONTEXT)
} else {
claude_wants_1m(input.context_window)
};
if wants_compact {
if claude_wants_auto_compact(input.model, input.min_context, input.context_window) {
env.insert("CLAUDE_CODE_AUTO_COMPACT_WINDOW".into(), "1000000".into());
}
// Label each picker entry with its role; otherwise four identical IDs
Expand Down Expand Up @@ -559,13 +549,55 @@ pub fn session_model_label(model: &str) -> String {
pub fn claude_wants_1m(context_window: Option<i64>) -> bool {
match context_window {
Some(n) => n >= MIN_1M_CONTEXT,
// Unknown: we no longer append `[1m]` for Claude (third-party catalog
// ids 404 on the suffix), but this predicate still drives the
// `CLAUDE_CODE_AUTO_COMPACT_WINDOW` env var in `build_tool_env`.
// Unknown concrete window: still enable compact so a 1M session is not
// truncated at Claude's 200k default. Virtual presets use the floor.
None => true,
}
}

fn merged_floor(model: &str, min_context: Option<i64>) -> Option<i64> {
match (peel_context_window_suffixes(model).1, min_context) {
(Some(a), Some(b)) => Some(a.max(b)),
(a, b) => a.or(b),
}
}

/// Compact when the routing floor is ≥ 1M. Concrete ids without a floor still
/// use catalog `context_length` (unknown counts as yes).
pub fn claude_wants_auto_compact(
model: &str,
min_context: Option<i64>,
context_window: Option<i64>,
) -> bool {
if merged_floor(model, min_context).is_some_and(|n| n >= MIN_1M_CONTEXT) {
return true;
}
if is_virtual_preset(model) {
return false;
}
claude_wants_1m(context_window)
}

pub fn claude_gateway_discovery_enabled(tool: &ToolConfig, model: &str) -> bool {
tool.enable_gateway_model_discovery && !is_virtual_preset(model)
}

/// Catalog `context_length` for a concrete id. Virtual `anyrouter/*` must not
/// inherit auto's 200k listing — that would paint the HUD `[200k]`.
pub fn catalog_context_window(
requested: &str,
models: &[crate::http::CatalogModel],
) -> Option<i64> {
let id = catalog_model_id(requested);
if is_virtual_preset(&id) {
return None;
}
models
.iter()
.find(|m| catalog_model_id(&m.id) == id)
.and_then(|m| m.context_length)
}

/// Spell a token floor as `[1m]` / `[500k]` (same as `--model` suffixes).
pub fn context_floor_suffix(n: i64) -> Option<String> {
if n <= 0 {
Expand All @@ -580,11 +612,9 @@ pub fn context_floor_suffix(n: i64) -> Option<String> {
None
}

/// Agent-specific model id.
///
/// Claude + virtual presets (`anyrouter/auto`, `free`, …) keep `[1m]` / `[500k]`
/// on `ANTHROPIC_MODEL` so Claude Code's HUD does not invent `[200k]` from its
/// default window. Concrete catalog ids still drop the suffix (they 404).
/// Peel `[Nm]`/`[Nk]` → catalog id. Claude virtual presets re-attach the floor
/// so the HUD shows `[1m]`/`[500k]` instead of catalog 200k. Concrete ids never
/// get an invented suffix (those SKUs 404).
pub fn model_id_for_tool(tool_name: &str, model: &str, min_context: Option<i64>) -> String {
let (peeled, peeled_floor) = peel_context_window_suffixes(model);
let id = catalog_model_id(&peeled);
Expand All @@ -594,11 +624,10 @@ pub fn model_id_for_tool(tool_name: &str, model: &str, min_context: Option<i64>)
id
};
if tool_name == "claude" && is_virtual_preset(&catalog) {
let floor = match (peeled_floor, min_context) {
if let Some(n) = match (peeled_floor, min_context) {
(Some(a), Some(b)) => Some(a.max(b)),
(a, b) => a.or(b),
};
if let Some(n) = floor {
} {
if let Some(sfx) = context_floor_suffix(n) {
return format!("{catalog}{sfx}");
}
Expand Down Expand Up @@ -1547,6 +1576,41 @@ mod tests {
.map(String::as_str),
Some("1000000")
);
assert!(!claude_gateway_discovery_enabled(
&tool,
"anyrouter/auto[1m]"
));
let auto_row = crate::http::CatalogModel {
id: "anyrouter/auto".into(),
name: None,
owned_by: None,
context_length: Some(200_000),
};
let ox = crate::http::CatalogModel {
id: "stealth/ox-alpha".into(),
name: None,
owned_by: None,
context_length: Some(1_000_000),
};
assert_eq!(
catalog_context_window("anyrouter/auto[1m]", &[auto_row.clone(), ox.clone()]),
None,
"virtual preset must not inherit catalog 200k"
);
assert_eq!(
catalog_context_window("stealth/ox-alpha", &[auto_row, ox]),
Some(1_000_000)
);
assert!(claude_wants_auto_compact(
"anyrouter/auto",
Some(1_000_000),
Some(200_000)
));
assert!(!claude_wants_auto_compact(
"anyrouter/auto[500k]",
None,
Some(200_000)
));

let half = build_tool_env(BuildToolEnvInput {
tool_name: "claude",
Expand Down
Loading
Loading