diff --git a/rust/README.md b/rust/README.md index 53ebfc744e..8fa9882a83 100644 --- a/rust/README.md +++ b/rust/README.md @@ -110,13 +110,30 @@ Primary artifacts: ## Model Aliases -Short names resolve to the latest model versions: - -| Alias | Resolves To | -|-------|------------| -| `opus` | `claude-opus-4-7` | -| `sonnet` | `claude-sonnet-4-6` | -| `haiku` | `claude-haiku-4-5-20251213` | +Short model aliases resolve to canonical model IDs. Because the router selects a +provider from the resolved model, an alias also determines which backend and +credentials are used — choose the alias for the provider you have an API key for. +Alias resolution is case-insensitive (`OPUS` behaves the same as `opus`). Aliases +are the bare short names listed below: a prefixed form such as `anthropic/opus` +is not an alias, and is passed through as-is rather than expanded to +`claude-opus-4-7`. + +| Alias | Resolves To | Provider | Auth env var | +|-------|-------------|----------|--------------| +| `opus` | `claude-opus-4-7` | Anthropic | `ANTHROPIC_API_KEY` | +| `sonnet` | `claude-sonnet-4-6` | Anthropic | `ANTHROPIC_API_KEY` | +| `haiku` | `claude-haiku-4-5-20251213` | Anthropic | `ANTHROPIC_API_KEY` | +| `grok`, `grok-3` | `grok-3` | xAI | `XAI_API_KEY` | +| `grok-mini`, `grok-3-mini` | `grok-3-mini` | xAI | `XAI_API_KEY` | +| `grok-2` | `grok-2` | xAI | `XAI_API_KEY` | +| `kimi` | `kimi-k2.5` | DashScope (OpenAI-compatible) | `DASHSCOPE_API_KEY` | + +Anthropic aliases track the latest published model versions. xAI models honor +`XAI_BASE_URL` and Kimi honors `DASHSCOPE_BASE_URL` for base-URL overrides. Alias +resolution is implemented in `resolve_model_alias` +([`crates/api/src/providers/mod.rs`](./crates/api/src/providers/mod.rs)); for +provider-specific request handling see +[`../docs/MODEL_COMPATIBILITY.md`](../docs/MODEL_COMPATIBILITY.md). ## CLI Flags and Commands diff --git a/rust/crates/api/src/providers/mod.rs b/rust/crates/api/src/providers/mod.rs index 2524e5520a..b571f9ee0c 100644 --- a/rust/crates/api/src/providers/mod.rs +++ b/rust/crates/api/src/providers/mod.rs @@ -902,6 +902,135 @@ mod tests { assert_eq!(resolve_model_alias("grok-2"), "grok-2"); } + /// Collects every non-empty backtick-delimited span in a markdown cell. + fn backticked(cell: &str) -> Vec<&str> { + let mut spans = Vec::new(); + let mut rest = cell; + while let Some(start) = rest.find('`') { + let after_open = &rest[start + 1..]; + let Some(end) = after_open.find('`') else { + break; + }; + spans.push(&after_open[..end]); + rest = &after_open[end + 1..]; + } + spans + } + + // Locks the `## Model Aliases` table in `rust/README.md` to the resolver. + // + // The table and the alias set must agree in *both* directions: no alias may + // resolve without being documented, and none may be documented without + // resolving. The table previously listed only the three Anthropic aliases + // while the resolver had grown xAI and Kimi entries, so an alias could work + // undocumented — exactly the drift a parity test catches and a hand-written + // `assert_eq!` per alias does not. + #[test] + fn readme_alias_table_matches_resolver() { + // `rust/crates/api/src/providers/mod.rs` -> `rust/README.md`. + const README: &str = include_str!("../../../../README.md"); + + let section = README + .split("## Model Aliases") + .nth(1) + .expect("rust/README.md must keep a `## Model Aliases` section"); + let section = section.split("\n## ").next().unwrap_or(section); + + // (alias, documented canonical id, documented auth env var) + let mut documented: Vec<(String, String, String)> = Vec::new(); + for line in section.lines() { + let line = line.trim(); + let Some(row) = line.strip_prefix('|') else { + continue; + }; + let cells: Vec<&str> = row + .trim_end_matches('|') + .split('|') + .map(str::trim) + .collect(); + if cells.len() < 4 { + continue; + } + let aliases = backticked(cells[0]); + if aliases.is_empty() { + // Header and `|---|---|` separator rows carry no aliases. + continue; + } + let canonicals = backticked(cells[1]); + assert_eq!( + canonicals.len(), + 1, + "`{line}` must document exactly one canonical model id" + ); + let auth_envs = backticked(cells[3]); + assert_eq!( + auth_envs.len(), + 1, + "`{line}` must document exactly one auth env var" + ); + for alias in aliases { + documented.push(( + alias.to_string(), + canonicals[0].to_string(), + auth_envs[0].to_string(), + )); + } + } + + assert!( + !documented.is_empty(), + "parsed no alias rows out of the `## Model Aliases` table in rust/README.md" + ); + + for (alias, canonical, auth_env) in &documented { + let resolved = resolve_model_alias(alias); + assert_eq!( + resolved.as_str(), + canonical.as_str(), + "rust/README.md documents `{alias}` -> `{canonical}`, but resolve_model_alias returns `{resolved}`" + ); + assert_eq!( + resolve_model_alias(&alias.to_ascii_uppercase()).as_str(), + resolved.as_str(), + "rust/README.md claims alias resolution is case-insensitive, but `{}` disagrees with `{alias}`", + alias.to_ascii_uppercase() + ); + let metadata = super::metadata_for_model(&resolved).unwrap_or_else(|| { + panic!("no provider metadata for documented model `{canonical}`") + }); + assert_eq!( + metadata.auth_env, + auth_env.as_str(), + "rust/README.md documents `{alias}` as needing `{auth_env}`, but `{canonical}` resolves to provider metadata with auth env `{}`", + metadata.auth_env + ); + } + + let documented_aliases: std::collections::BTreeSet<&str> = documented + .iter() + .map(|(alias, _, _)| alias.as_str()) + .collect(); + let resolver_aliases: std::collections::BTreeSet<&str> = super::MODEL_REGISTRY + .iter() + .map(|(alias, _)| *alias) + .collect(); + assert_eq!( + documented_aliases, resolver_aliases, + "rust/README.md's `## Model Aliases` table and MODEL_REGISTRY have drifted apart" + ); + } + + // The table documents bare short names only, so the provider-prefixed form + // is deliberately *not* an alias. This pins that distinction, since the + // prefix is handled later (at request-build time) and a future refactor + // could easily fold it into alias resolution. + #[test] + fn provider_prefixed_alias_is_not_an_alias() { + assert_eq!(resolve_model_alias("opus"), "claude-opus-4-7"); + assert_eq!(resolve_model_alias("anthropic/opus"), "anthropic/opus"); + assert_eq!(resolve_model_alias("xai/grok"), "xai/grok"); + } + #[test] fn detects_provider_from_model_name_first() { assert_eq!(detect_provider_kind("grok"), ProviderKind::Xai);