use std::collections::BTreeMap;
use std::sync::Arc;
use rpi_ai::providers::anthropic::models::anthropic_models;
use rpi_ai::providers::anthropic::AnthropicProvider;
use rpi_ai::{Model, Provider, ThinkingLevel};
use crate::args::parse_thinking_level;
use crate::config::{self, Credential, DEFAULT_PROVIDER_ID};
use crate::settings;
pub const DEFAULT_MODEL_ID: &str = "claude-sonnet-5";
pub const DEFAULT_THINKING_LEVEL: ThinkingLevel = ThinkingLevel::Medium;
#[derive(Clone)]
pub struct ResolvedModel {
pub provider: Arc<dyn Provider>,
pub model: Model,
pub thinking_level: ThinkingLevel,
pub theme: Option<String>,
}
impl std::fmt::Debug for ResolvedModel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ResolvedModel")
.field("provider", &self.provider.id())
.field("model", &self.model.id)
.field("thinking_level", &self.thinking_level)
.field("theme", &self.theme)
.finish()
}
}
pub const ANTHROPIC_API_KEY_ENV: &str = "ANTHROPIC_API_KEY";
pub const ANTHROPIC_AUTH_TOKEN_ENV: &str = "ANTHROPIC_AUTH_TOKEN";
pub const ANTHROPIC_BASE_URL_ENV: &str = "ANTHROPIC_BASE_URL";
pub const NO_API_KEY_HINT: &str =
"ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN env, --api-key, or `rpi auth login` (writes ~/.rpi/auth.json)";
#[derive(Debug, thiserror::Error)]
pub enum ResolveError {
#[error("Unknown provider \"{0}\". v1 supports: anthropic")]
UnknownProvider(String),
#[error("No model matches \"{pattern}\". Available: {available}")]
NoMatch { pattern: String, available: String },
#[error("Invalid thinking level \"{0}\" in model pattern. Valid: {1}")]
InvalidThinkingLevel(String, String),
#[error("No API key. Set one of: {hint}")]
NoApiKey { hint: &'static str },
#[error("Could not read config: {0}")]
Config(#[from] config::ConfigError),
}
pub fn resolve(
cli_provider: Option<&str>,
cli_model: Option<&str>,
cli_thinking: Option<ThinkingLevel>,
cli_api_key: Option<&str>,
cli_base_url: Option<&str>,
) -> Result<ResolvedModel, ResolveError> {
if let Some(req) = cli_provider {
if !req.eq_ignore_ascii_case("anthropic") {
return Err(ResolveError::UnknownProvider(req.to_string()));
}
}
let mut provider_key: Option<String> = None;
let mut auth_headers: BTreeMap<String, String> = BTreeMap::new();
let mut bearer_from_models_json = false;
let models_cfg = config::load_models_config()?;
if let Some(k) = cli_api_key.filter(|s| !s.is_empty()) {
provider_key = Some(k.to_string());
}
if provider_key.is_none() {
if let Ok(store) = config::read_auth() {
if let Some(Credential::ApiKey { key: Some(k), env }) = store.get(DEFAULT_PROVIDER_ID) {
if let Some(resolved) = config::resolve_config_value(k, env.as_ref()) {
if !resolved.is_empty() {
provider_key = Some(resolved);
}
}
}
}
}
if provider_key.is_none() && auth_headers.is_empty() {
if let Some(tok) = models_json_bearer_token(&models_cfg) {
auth_headers.insert("authorization".to_string(), format!("Bearer {tok}"));
bearer_from_models_json = true;
}
}
if provider_key.is_none() && auth_headers.is_empty() {
if let Ok(tok) = std::env::var(ANTHROPIC_AUTH_TOKEN_ENV) {
if !tok.is_empty() {
auth_headers.insert("authorization".to_string(), format!("Bearer {tok}"));
}
}
}
if provider_key.is_none() && auth_headers.is_empty() {
if let Ok(k) = std::env::var(ANTHROPIC_API_KEY_ENV) {
if !k.is_empty() {
provider_key = Some(k);
}
}
}
if provider_key.is_none() && auth_headers.is_empty() {
return Err(ResolveError::NoApiKey { hint: NO_API_KEY_HINT });
}
let base_url_override = cli_base_url
.map(|s| s.to_string())
.or_else(|| {
std::env::var(ANTHROPIC_BASE_URL_ENV)
.ok()
.filter(|s| !s.is_empty())
});
let settings = settings::load_settings().unwrap_or_default();
let mut catalog = anthropic_models();
merge_user_catalog(&mut catalog, &models_cfg);
if let Some(base) = &base_url_override {
for m in catalog.iter_mut() {
m.base_url = base.clone();
}
}
if !auth_headers.is_empty() && !bearer_from_models_json {
for m in catalog.iter_mut() {
let headers = m.headers.get_or_insert_with(BTreeMap::new);
for (k, v) in &auth_headers {
headers.insert(k.clone(), v.clone());
}
}
} else if !auth_headers.is_empty() {
let override_active = base_url_override.is_some();
for m in catalog.iter_mut() {
let is_gateway =
override_active || m.base_url != config::ANTHROPIC_DEFAULT_BASE_URL;
if is_gateway {
let headers = m.headers.get_or_insert_with(BTreeMap::new);
for (k, v) in &auth_headers {
headers.insert(k.clone(), v.clone());
}
}
}
}
let available = catalog
.iter()
.map(|m| m.id.clone())
.collect::<Vec<_>>()
.join(", ");
let (model, thinking_level) = match cli_model {
Some(raw) => {
let (pattern, pattern_thinking) = split_model_pattern(raw);
let thinking_level = cli_thinking
.or(pattern_thinking)
.unwrap_or(DEFAULT_THINKING_LEVEL);
let model = match find_model(&pattern, &catalog) {
Some(m) => m,
None => {
return Err(ResolveError::NoMatch {
pattern: pattern.clone(),
available,
});
}
};
(model, thinking_level)
}
None => {
let settings_thinking = settings
.default_thinking_level
.as_deref()
.and_then(parse_thinking_level);
if settings.default_provider.as_deref().map_or(true, |p| {
p.eq_ignore_ascii_case("anthropic")
}) {
if let Some(id) = settings.default_model.as_deref() {
let found = catalog
.iter()
.find(|m| m.id.eq_ignore_ascii_case(id))
.filter(|m| model_is_authed(m, provider_key.as_deref()))
.cloned();
if let Some(m) = found {
let thinking_level = cli_thinking
.or(settings_thinking)
.unwrap_or(DEFAULT_THINKING_LEVEL);
return Ok(ResolvedModel {
provider: Arc::new(AnthropicProvider::with_models(
provider_key,
reqwest::Client::new(),
catalog,
)),
model: m,
thinking_level,
theme: settings.theme.clone(),
});
}
}
}
let thinking_level = cli_thinking.unwrap_or(DEFAULT_THINKING_LEVEL);
let model = pick_default_model(&catalog, provider_key.as_deref());
(model, thinking_level)
}
};
let provider: Arc<dyn Provider> = Arc::new(AnthropicProvider::with_models(
provider_key,
reqwest::Client::new(),
catalog,
));
Ok(ResolvedModel { provider, model, thinking_level, theme: settings.theme.clone() })
}
pub fn available_catalog(resolved: &ResolvedModel) -> Vec<Model> {
resolved.provider.models().to_vec()
}
fn merge_user_catalog(catalog: &mut Vec<Model>, cfg: &config::ModelsConfig) {
for (provider_id, provider_cfg) in &cfg.providers {
let Some(models) = config::provider_to_models(provider_id, provider_cfg) else {
continue;
};
for m in models {
if let Some(existing) = catalog.iter_mut().find(|c| c.id.eq_ignore_ascii_case(&m.id)) {
*existing = m;
} else {
catalog.push(m);
}
}
}
}
fn models_json_bearer_token(cfg: &config::ModelsConfig) -> Option<String> {
for (_provider_id, provider_cfg) in &cfg.providers {
if !config::provider_is_anthropic_compatible(provider_cfg) {
continue;
}
if provider_cfg.auth_header.unwrap_or(false) {
if let Some(raw) = provider_cfg.api_key.as_deref().filter(|s| !s.is_empty()) {
if let Some(resolved) = config::resolve_config_value(raw, None) {
if !resolved.is_empty() {
return Some(resolved);
}
}
}
}
}
None
}
fn split_model_pattern(value: &str) -> (String, Option<ThinkingLevel>) {
let trimmed = value
.strip_prefix("anthropic/")
.or_else(|| value.strip_prefix("Anthropic/"))
.or_else(|| {
if let Some(idx) = value.find('/') {
Some(&value[idx + 1..])
} else {
None
}
})
.unwrap_or(value);
if let Some(idx) = trimmed.rfind(':') {
let (head, tail) = trimmed.split_at(idx);
let suffix = &tail[1..]; if let Some(level) = parse_thinking_level(suffix) {
return (head.to_string(), Some(level));
}
}
(trimmed.to_string(), None)
}
fn find_model(pattern: &str, catalog: &[Model]) -> Option<Model> {
catalog
.iter()
.find(|m| m.id.eq_ignore_ascii_case(pattern))
.cloned()
}
fn model_is_authed(m: &Model, provider_key: Option<&str>) -> bool {
model_has_header_auth(m) || provider_key.is_some()
}
fn model_has_header_auth(m: &Model) -> bool {
let Some(h) = &m.headers else { return false };
const NAMES: &[&str] = &["authorization", "x-api-key", "cf-aig-authorization"];
h.keys()
.any(|k| NAMES.contains(&k.to_ascii_lowercase().as_str()))
}
fn pick_default_model(catalog: &[Model], provider_key: Option<&str>) -> Model {
if let Some(m) = catalog
.iter()
.find(|m| m.id.eq_ignore_ascii_case(DEFAULT_MODEL_ID))
.filter(|m| model_is_authed(m, provider_key))
{
return m.clone();
}
if let Some(m) = catalog
.iter()
.find(|m| model_is_authed(m, provider_key))
{
return m.clone();
}
catalog
.iter()
.find(|m| m.id.eq_ignore_ascii_case(DEFAULT_MODEL_ID))
.or_else(|| catalog.first())
.expect("catalog is never empty (built-in anthropic_models)")
.clone()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::args::{parse_thinking_level, VALID_THINKING_LEVELS};
use crate::config::test_support::env_lock;
struct TestEnv {
_guard: std::sync::MutexGuard<'static, ()>,
prev_key: Option<std::ffi::OsString>,
prev_tok: Option<std::ffi::OsString>,
prev_base: Option<std::ffi::OsString>,
prev_dir: Option<std::ffi::OsString>,
_tmp: tempfile::TempDir,
}
impl TestEnv {
fn new() -> Self {
let guard = env_lock().lock().unwrap();
let prev_key = std::env::var_os(ANTHROPIC_API_KEY_ENV);
let prev_tok = std::env::var_os(ANTHROPIC_AUTH_TOKEN_ENV);
let prev_base = std::env::var_os(ANTHROPIC_BASE_URL_ENV);
let prev_dir = std::env::var_os(config::CONFIG_DIR_ENV);
std::env::remove_var(ANTHROPIC_API_KEY_ENV);
std::env::remove_var(ANTHROPIC_AUTH_TOKEN_ENV);
std::env::remove_var(ANTHROPIC_BASE_URL_ENV);
let tmp = tempfile::TempDir::new().unwrap();
std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
Self {
_guard: guard,
prev_key,
prev_tok,
prev_base,
prev_dir,
_tmp: tmp,
}
}
}
impl Drop for TestEnv {
fn drop(&mut self) {
restore(ANTHROPIC_API_KEY_ENV, self.prev_key.take());
restore(ANTHROPIC_AUTH_TOKEN_ENV, self.prev_tok.take());
restore(ANTHROPIC_BASE_URL_ENV, self.prev_base.take());
restore(config::CONFIG_DIR_ENV, self.prev_dir.take());
}
}
fn restore(name: &str, prev: Option<std::ffi::OsString>) {
match prev {
Some(v) => std::env::set_var(name, v),
None => std::env::remove_var(name),
}
}
fn resolve_with_key(
provider: Option<&str>,
model: Option<&str>,
thinking: Option<ThinkingLevel>,
) -> Result<ResolvedModel, ResolveError> {
let _env = TestEnv::new();
std::env::set_var(ANTHROPIC_API_KEY_ENV, "test-key");
resolve(provider, model, thinking, None, None)
}
#[test]
fn default_model_is_sonnet_5() {
let r = resolve_with_key(None, None, None).unwrap();
assert_eq!(r.model.id, DEFAULT_MODEL_ID);
assert_eq!(r.thinking_level, DEFAULT_THINKING_LEVEL);
assert_eq!(r.provider.id(), "anthropic");
}
#[test]
fn settings_default_model_wins_when_authed() {
let _env = TestEnv::new();
std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
let path = config::settings_path().unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
r#"{"defaultProvider":"anthropic","defaultModel":"claude-haiku-4-5","defaultThinkingLevel":"high"}"#,
)
.unwrap();
let r = resolve(None, None, None, None, None).unwrap();
assert_eq!(r.model.id, "claude-haiku-4-5");
assert_eq!(r.thinking_level, ThinkingLevel::High);
std::fs::write(&path, r#"{"defaultModel":"claude-does-not-exist"}"#).unwrap();
let r = resolve(None, None, None, None, None).unwrap();
assert_eq!(r.model.id, DEFAULT_MODEL_ID);
}
#[test]
fn explicit_id_match() {
let r = resolve_with_key(None, Some("claude-haiku-4-5"), None).unwrap();
assert_eq!(r.model.id, "claude-haiku-4-5");
}
#[test]
fn case_insensitive_id() {
let r = resolve_with_key(None, Some("CLAUDE-OPUS-5"), None).unwrap();
assert_eq!(r.model.id, "claude-opus-5");
}
#[test]
fn provider_prefix_stripped() {
let r = resolve_with_key(None, Some("anthropic/claude-sonnet-5"), None).unwrap();
assert_eq!(r.model.id, "claude-sonnet-5");
}
#[test]
fn custom_provider_prefix_stripped() {
let _env = TestEnv::new();
std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
std::fs::write(
config::models_path().unwrap(),
r#"{ "providers": { "gateway": { "baseUrl": "https://gw", "models": [{"id":"custom-claude"}] } } }"#,
)
.unwrap();
let r = resolve(None, Some("gateway/custom-claude"), None, None, None).unwrap();
assert_eq!(r.model.id, "custom-claude");
}
#[test]
fn thinking_suffix_in_model() {
let r = resolve_with_key(None, Some("claude-sonnet-5:high"), None).unwrap();
assert_eq!(r.model.id, "claude-sonnet-5");
assert_eq!(r.thinking_level, ThinkingLevel::High);
}
#[test]
fn thinking_flag_overrides_suffix() {
let r =
resolve_with_key(None, Some("claude-sonnet-5:high"), Some(ThinkingLevel::Low)).unwrap();
assert_eq!(r.thinking_level, ThinkingLevel::Low);
}
#[test]
fn explicit_provider_anthropic_ok() {
let r = resolve_with_key(Some("anthropic"), Some("claude-sonnet-5"), None).unwrap();
assert_eq!(r.model.id, "claude-sonnet-5");
}
#[test]
fn unknown_provider_rejected() {
let err = resolve_with_key(Some("openai"), None, None).unwrap_err();
assert!(matches!(err, ResolveError::UnknownProvider(_)));
}
#[test]
fn no_match_lists_available() {
let err = resolve_with_key(None, Some("claude-does-not-exist"), None).unwrap_err();
match err {
ResolveError::NoMatch { pattern, available } => {
assert_eq!(pattern, "claude-does-not-exist");
assert!(available.contains("claude-sonnet-5"));
}
other => panic!("expected NoMatch, got {other:?}"),
}
}
#[test]
fn colon_not_a_thinking_level_kept_in_id() {
let err = resolve_with_key(None, Some("claude-sonnet-5:foo"), None).unwrap_err();
assert!(matches!(err, ResolveError::NoMatch { .. }));
}
#[test]
fn parse_thinking_level_roundtrip() {
assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::Xhigh));
assert_eq!(parse_thinking_level("bogus"), None);
for lvl in VALID_THINKING_LEVELS {
assert!(parse_thinking_level(lvl).is_some(), "{lvl} should parse");
}
}
#[test]
fn no_api_key_errors_with_hint() {
let _env = TestEnv::new();
let err = resolve(None, None, None, None, None).unwrap_err();
match err {
ResolveError::NoApiKey { hint } => {
assert!(hint.contains("ANTHROPIC_API_KEY"));
assert!(hint.contains("auth login"));
}
other => panic!("expected NoApiKey, got {other:?}"),
}
}
#[test]
fn stored_credential_satisfies_auth() {
let _env = TestEnv::new();
config::upsert_credential(
DEFAULT_PROVIDER_ID,
Credential::ApiKey { key: Some("stored-key".into()), env: None },
)
.unwrap();
let r = resolve(None, None, None, None, None).unwrap();
assert_eq!(r.model.id, DEFAULT_MODEL_ID);
assert!(
r.model.headers.as_ref().and_then(|h| h.get("authorization")).is_none(),
"x-api-key path should not synthesize a Bearer header"
);
}
#[test]
fn auth_token_routes_via_bearer_header() {
let _env = TestEnv::new();
std::env::set_var(ANTHROPIC_AUTH_TOKEN_ENV, "tok-123");
let r = resolve(None, None, None, None, None).unwrap();
let headers = r.model.headers.as_ref().expect("bearer header on model");
assert_eq!(headers.get("authorization").map(|s| s.as_str()), Some("Bearer tok-123"));
assert_eq!(r.model.id, DEFAULT_MODEL_ID);
}
#[test]
fn api_key_flag_beats_env_and_stored() {
let _env = TestEnv::new();
std::env::set_var(ANTHROPIC_API_KEY_ENV, "env-key");
config::upsert_credential(
DEFAULT_PROVIDER_ID,
Credential::ApiKey { key: Some("stored-key".into()), env: None },
)
.unwrap();
let r = resolve(None, None, None, Some("flag-key"), None).unwrap();
assert!(
r.model.headers.as_ref().and_then(|h| h.get("authorization")).is_none(),
"--api-key should take the x-api-key path, not Bearer"
);
}
#[test]
fn base_url_override_applies_to_model() {
let _env = TestEnv::new();
std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
let r = resolve(None, None, None, None, Some("https://gw.example.com")).unwrap();
assert_eq!(r.model.base_url, "https://gw.example.com");
}
#[test]
fn base_url_env_is_fallback_for_flag() {
let _env = TestEnv::new();
std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
std::env::set_var(ANTHROPIC_BASE_URL_ENV, "https://env-gw.example.com");
let r = resolve(None, None, None, None, None).unwrap();
assert_eq!(r.model.base_url, "https://env-gw.example.com");
}
#[test]
fn models_json_adds_custom_model() {
let _env = TestEnv::new();
std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"gateway": {
"baseUrl": "https://gw.example.com",
"authHeader": true,
"apiKey": "gw-secret",
"models": [
{ "id": "custom-claude", "name": "Custom" }
]
}
}
}"#,
)
.unwrap();
let r = resolve(None, Some("custom-claude"), None, None, None).unwrap();
assert_eq!(r.model.id, "custom-claude");
assert_eq!(r.model.base_url, "https://gw.example.com");
assert_eq!(r.model.provider, DEFAULT_PROVIDER_ID);
let headers = r.model.headers.as_ref().expect("headers merged");
assert_eq!(headers.get("authorization").map(|s| s.as_str()), Some("Bearer gw-secret"));
}
#[test]
fn models_json_auth_header_satisfies_auth_without_env() {
let _env = TestEnv::new();
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"gateway": {
"baseUrl": "https://gw.example.com",
"api": "anthropic-messages",
"authHeader": true,
"apiKey": "gw-secret",
"models": [
{ "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
]
}
}
}"#,
)
.unwrap();
let r = resolve(None, Some("custom-claude"), None, None, None).unwrap();
assert_eq!(r.model.id, "custom-claude");
assert_eq!(r.model.base_url, "https://gw.example.com");
let headers = r.model.headers.as_ref().expect("bearer folded onto model");
assert_eq!(headers.get("authorization").map(|s| s.as_str()), Some("Bearer gw-secret"));
}
#[test]
fn default_prefers_gateway_when_only_gateway_configured() {
let _env = TestEnv::new();
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"gateway": {
"baseUrl": "https://gw.example.com",
"api": "anthropic-messages",
"authHeader": true,
"apiKey": "gw-secret",
"models": [
{ "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
]
}
}
}"#,
)
.unwrap();
let r = resolve(None, None, None, None, None).unwrap();
assert_eq!(r.model.id, "custom-claude");
assert_eq!(r.model.base_url, "https://gw.example.com");
let headers = r.model.headers.as_ref().expect("bearer on gateway model");
assert_eq!(
headers.get("authorization").map(|s| s.as_str()),
Some("Bearer gw-secret")
);
}
#[test]
fn api_key_flag_beats_models_json_bearer() {
let _env = TestEnv::new();
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"gateway": {
"baseUrl": "https://gw.example.com",
"authHeader": true,
"apiKey": "gw-secret",
"models": [ { "id": "custom-claude" } ]
}
}
}"#,
)
.unwrap();
let r = resolve(None, Some("custom-claude"), None, Some("flag-key"), None).unwrap();
assert!(
r.model.headers.as_ref().and_then(|h| h.get("authorization")).is_none(),
"--api-key should win over the models.json gateway bearer"
);
}
}