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::providers::openai_completions::OpenAiCompletionsProvider;
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 has_provider_key: bool,
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("has_provider_key", &self.has_provider_key)
.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 OPENAI_API_KEY_ENV: &str = "OPENAI_API_KEY";
pub const NO_API_KEY_HINT: &str =
"models.json apiKey, OPENAI_API_KEY / ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN env, --api-key, or `rpi auth login`";
#[derive(Debug, thiserror::Error)]
pub enum ResolveError {
#[error("Unknown provider \"{0}\". Supported: anthropic, openai-completions, or a models.json provider id")]
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> {
let mut provider_key: Option<String> = None;
let mut auth_headers: BTreeMap<String, String> = BTreeMap::new();
let mut auth_from_models_json = false;
let models_cfg = config::load_models_config()?;
if let Some(requested) = cli_provider {
if !provider_is_known(requested, &models_cfg) {
return Err(ResolveError::UnknownProvider(requested.to_string()));
}
}
let openai_provider_key = cli_api_key
.filter(|key| !key.is_empty())
.map(str::to_string)
.or_else(|| {
std::env::var(OPENAI_API_KEY_ENV)
.ok()
.filter(|key| !key.is_empty())
});
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);
}
}
}
}
}
let models_json_auth = models_json_provider_auth(&models_cfg);
if provider_key.is_none() && auth_headers.is_empty() && !models_json_auth.is_empty() {
auth_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);
}
}
}
let has_configured_model_auth = models_cfg
.providers
.iter()
.filter_map(|(id, cfg)| config::provider_to_models(id, cfg))
.flatten()
.any(|model| model_has_header_auth(&model));
if provider_key.is_none()
&& openai_provider_key.is_none()
&& auth_headers.is_empty()
&& models_json_auth.is_empty()
&& !has_configured_model_auth
{
return Err(ResolveError::NoApiKey {
hint: NO_API_KEY_HINT,
});
}
let cli_base_url_override = cli_base_url.map(str::to_string);
let anthropic_base_url_override = std::env::var(ANTHROPIC_BASE_URL_ENV)
.ok()
.filter(|value| !value.is_empty());
let settings = settings::load_settings().unwrap_or_default();
let mut catalog = anthropic_models();
merge_user_catalog(&mut catalog, &models_cfg);
for model in &mut catalog {
if let Some(base) = &cli_base_url_override {
model.base_url = base.clone();
} else if matches!(model.api, rpi_ai::Api::AnthropicMessages) {
if let Some(base) = &anthropic_base_url_override {
model.base_url = base.clone();
}
}
}
if let Some(requested) = cli_provider {
catalog.retain(|model| provider_matches(model, requested, &models_cfg));
}
if !auth_headers.is_empty() && !auth_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_from_models_json {
let override_active =
cli_base_url_override.is_some() || anthropic_base_url_override.is_some();
for m in catalog.iter_mut() {
if !matches!(m.api, rpi_ai::Api::AnthropicMessages) {
continue;
}
let is_gateway = override_active || m.base_url != config::ANTHROPIC_DEFAULT_BASE_URL;
if !is_gateway {
continue;
}
let provider_auth = if override_active {
models_json_auth.values().next()
} else {
models_json_auth.get(&m.base_url)
};
let Some(provider_auth) = provider_auth else {
continue;
};
let headers = m.headers.get_or_insert_with(BTreeMap::new);
for (k, v) in provider_auth {
headers.insert(k.clone(), v.clone());
}
}
}
let available = catalog
.iter()
.map(|m| m.id.clone())
.collect::<Vec<_>>()
.join(", ");
if catalog.is_empty() {
return Err(ResolveError::NoMatch {
pattern: cli_provider.unwrap_or("default").to_string(),
available,
});
}
let (model, thinking_level) = match cli_model {
Some(raw) => {
let (pattern_provider, pattern, pattern_thinking) = split_model_pattern(raw);
if let Some(provider) = pattern_provider.as_deref() {
if !provider_is_known(provider, &models_cfg) {
return Err(ResolveError::UnknownProvider(provider.to_string()));
}
}
let thinking_level = cli_thinking
.or(pattern_thinking)
.unwrap_or(DEFAULT_THINKING_LEVEL);
let model =
match find_model(&pattern, pattern_provider.as_deref(), &catalog, &models_cfg) {
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);
let saved_provider = settings
.default_provider
.as_deref()
.filter(|provider| provider_is_known(provider, &models_cfg));
let saved = settings.default_model.as_deref().and_then(|id| {
if settings.default_provider.is_some() && saved_provider.is_none() {
return None;
}
find_model(id, saved_provider, &catalog, &models_cfg).filter(|m| {
model_is_authed_for_resolution(
m,
provider_key.is_some(),
openai_provider_key.is_some(),
)
})
});
if let Some(model) = saved {
let thinking_level = cli_thinking
.or(settings_thinking)
.unwrap_or(DEFAULT_THINKING_LEVEL);
(model, thinking_level)
} else {
let thinking_level = cli_thinking.unwrap_or(DEFAULT_THINKING_LEVEL);
let model = pick_default_model(
&catalog,
provider_key.is_some(),
openai_provider_key.is_some(),
);
(model, thinking_level)
}
}
};
let selected_api = model.api.clone();
let selected_provider = model.provider.clone();
let provider_models: Vec<Model> = catalog
.into_iter()
.filter(|candidate| {
candidate.api == selected_api
&& (matches!(selected_api, rpi_ai::Api::AnthropicMessages)
|| candidate.provider == selected_provider)
})
.collect();
let (provider, has_provider_key): (Arc<dyn Provider>, bool) = match selected_api {
rpi_ai::Api::AnthropicMessages => {
let has_key = provider_key.is_some();
(
Arc::new(AnthropicProvider::with_models(
provider_key,
reqwest::Client::new(),
provider_models,
)),
has_key,
)
}
rpi_ai::Api::OpenaiCompletions => {
let has_key = openai_provider_key.is_some();
(
Arc::new(OpenAiCompletionsProvider::with_models(
selected_provider,
openai_provider_key,
reqwest::Client::new(),
provider_models,
)),
has_key,
)
}
_ => unreachable!("unsupported APIs are filtered while loading models.json"),
};
Ok(ResolvedModel {
provider,
model,
thinking_level,
has_provider_key,
theme: settings.theme.clone(),
})
}
pub fn available_catalog(resolved: &ResolvedModel) -> Vec<Model> {
resolved
.provider
.models()
.iter()
.filter(|m| model_is_authed(m, resolved.has_provider_key))
.cloned()
.collect()
}
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(|candidate| {
candidate.api == m.api
&& candidate.provider.eq_ignore_ascii_case(&m.provider)
&& candidate.id.eq_ignore_ascii_case(&m.id)
}) {
*existing = m;
} else {
catalog.push(m);
}
}
}
}
fn models_json_provider_auth(
cfg: &config::ModelsConfig,
) -> BTreeMap<String, BTreeMap<String, String>> {
let mut out: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
for (_provider_id, provider_cfg) in &cfg.providers {
if !config::provider_is_anthropic_compatible(provider_cfg) {
continue;
}
let Some(raw) = provider_cfg.api_key.as_deref().filter(|s| !s.is_empty()) else {
continue;
};
let Some(resolved) = config::resolve_config_value(raw, None) else {
continue;
};
if resolved.is_empty() {
continue;
}
let base = provider_cfg
.base_url
.clone()
.unwrap_or_else(config::default_anthropic_base_url);
let mut headers = BTreeMap::new();
if provider_cfg.auth_header.unwrap_or(false) {
headers.insert("authorization".to_string(), format!("Bearer {resolved}"));
} else {
headers.insert("x-api-key".to_string(), resolved);
}
out.entry(base).or_insert(headers);
}
out
}
fn split_model_pattern(value: &str) -> (Option<String>, String, Option<ThinkingLevel>) {
let (without_thinking, thinking) = if let Some(idx) = value.rfind(':') {
let (head, tail) = value.split_at(idx);
let suffix = &tail[1..]; if let Some(level) = parse_thinking_level(suffix) {
(head, Some(level))
} else {
(value, None)
}
} else {
(value, None)
};
match without_thinking.split_once('/') {
Some((provider, model)) if !provider.is_empty() && !model.is_empty() => {
(Some(provider.to_string()), model.to_string(), thinking)
}
_ => (None, without_thinking.to_string(), thinking),
}
}
fn find_model(
pattern: &str,
provider: Option<&str>,
catalog: &[Model],
cfg: &config::ModelsConfig,
) -> Option<Model> {
catalog
.iter()
.find(|model| {
model.id.eq_ignore_ascii_case(pattern)
&& provider.is_none_or(|requested| provider_matches(model, requested, cfg))
})
.cloned()
}
fn provider_is_known(requested: &str, cfg: &config::ModelsConfig) -> bool {
requested.eq_ignore_ascii_case("anthropic")
|| requested.eq_ignore_ascii_case("openai")
|| requested.eq_ignore_ascii_case("openai-completions")
|| cfg
.providers
.keys()
.any(|id| id.eq_ignore_ascii_case(requested))
}
fn provider_matches(model: &Model, requested: &str, cfg: &config::ModelsConfig) -> bool {
if requested.eq_ignore_ascii_case("anthropic") {
return matches!(model.api, rpi_ai::Api::AnthropicMessages);
}
if requested.eq_ignore_ascii_case("openai")
|| requested.eq_ignore_ascii_case("openai-completions")
{
return matches!(model.api, rpi_ai::Api::OpenaiCompletions);
}
if model.provider.eq_ignore_ascii_case(requested) {
return true;
}
cfg.providers
.iter()
.find(|(id, _)| id.eq_ignore_ascii_case(requested))
.map(|(_, provider)| {
config::provider_is_anthropic_compatible(provider)
&& matches!(model.api, rpi_ai::Api::AnthropicMessages)
&& provider
.models
.iter()
.any(|configured| configured.id.eq_ignore_ascii_case(&model.id))
})
.unwrap_or(false)
}
fn model_is_authed(m: &Model, has_provider_key: bool) -> bool {
model_has_header_auth(m) || has_provider_key
}
fn model_is_authed_for_resolution(
model: &Model,
has_anthropic_key: bool,
has_openai_key: bool,
) -> bool {
model_has_header_auth(model)
|| match model.api {
rpi_ai::Api::AnthropicMessages => has_anthropic_key,
rpi_ai::Api::OpenaiCompletions => has_openai_key,
_ => false,
}
}
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], has_anthropic_key: bool, has_openai_key: bool) -> Model {
if let Some(m) = catalog
.iter()
.find(|m| m.id.eq_ignore_ascii_case(DEFAULT_MODEL_ID))
.filter(|m| model_is_authed_for_resolution(m, has_anthropic_key, has_openai_key))
{
return m.clone();
}
if let Some(m) = catalog
.iter()
.find(|m| model_is_authed_for_resolution(m, has_anthropic_key, has_openai_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_openai_key: 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_openai_key = std::env::var_os(OPENAI_API_KEY_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);
std::env::remove_var(OPENAI_API_KEY_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_openai_key,
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(OPENAI_API_KEY_ENV, self.prev_openai_key.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("unsupported-provider"), 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 openai_completions_models_json_is_a_complete_provider_config() {
let _env = TestEnv::new();
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"routeryo": {
"baseUrl": "https://api.routeryo.com",
"api": "openai-completions",
"apiKey": "router-secret",
"models": [
{
"id": "gpt-5.6-sol",
"name": "GPT 5.6",
"reasoning": true,
"contextWindow": 200000,
"maxTokens": 32768
}
]
}
}
}"#,
)
.unwrap();
let resolved = resolve(None, None, None, None, None).unwrap();
assert_eq!(resolved.model.id, "gpt-5.6-sol");
assert_eq!(resolved.model.api, rpi_ai::Api::OpenaiCompletions);
assert_eq!(resolved.model.provider, "routeryo");
assert_eq!(resolved.provider.id(), "routeryo");
assert!(!resolved.has_provider_key);
assert_eq!(
resolved
.model
.headers
.as_ref()
.and_then(|headers| headers.get("authorization"))
.map(String::as_str),
Some("Bearer router-secret")
);
let explicit = resolve(
Some("routeryo"),
Some("routeryo/gpt-5.6-sol"),
None,
None,
None,
)
.unwrap();
assert_eq!(explicit.provider.id(), "routeryo");
assert_eq!(explicit.model.id, "gpt-5.6-sol");
}
#[test]
fn openai_model_prefix_disambiguates_providers_with_the_same_model_id() {
let _env = TestEnv::new();
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"alpha": {
"api": "openai-completions",
"baseUrl": "https://alpha.example.com",
"apiKey": "alpha-secret",
"models": [{"id":"shared-model"}]
},
"beta": {
"api": "openai-completions",
"baseUrl": "https://beta.example.com",
"apiKey": "beta-secret",
"models": [{"id":"shared-model"}]
}
}
}"#,
)
.unwrap();
let alpha = resolve(None, Some("alpha/shared-model"), None, None, None).unwrap();
assert_eq!(alpha.provider.id(), "alpha");
assert_eq!(alpha.model.base_url, "https://alpha.example.com");
let beta = resolve(None, Some("beta/shared-model"), None, None, None).unwrap();
assert_eq!(beta.provider.id(), "beta");
assert_eq!(beta.model.base_url, "https://beta.example.com");
}
#[test]
fn openai_model_prefix_rejects_unknown_provider() {
let _env = TestEnv::new();
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"routeryo": {
"api": "openai-completions",
"apiKey": "secret",
"models": [{"id":"gpt-test"}]
}
}
}"#,
)
.unwrap();
let error = resolve(None, Some("misspelled/gpt-test"), None, None, None).unwrap_err();
assert!(
matches!(error, ResolveError::UnknownProvider(provider) if provider == "misspelled")
);
}
#[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"
);
}
#[test]
fn models_json_bare_apikey_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",
"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("x-api-key folded onto model");
assert_eq!(
headers.get("x-api-key").map(|s| s.as_str()),
Some("gw-secret")
);
assert!(
headers.get("authorization").is_none(),
"bare apiKey must NOT synthesize a Bearer (that is the authHeader path)"
);
}
#[test]
fn default_prefers_gateway_when_only_bare_apikey_configured() {
let _env = TestEnv::new();
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"gateway": {
"baseUrl": "https://gw.example.com",
"api": "anthropic-messages",
"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("x-api-key on gateway model");
assert_eq!(
headers.get("x-api-key").map(|s| s.as_str()),
Some("gw-secret")
);
}
#[test]
fn models_json_bare_apikey_env_template_resolves() {
let _env = TestEnv::new();
std::env::set_var("RPI_TEST_GATEWAY_KEY", "env-resolved-secret");
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"gateway": {
"baseUrl": "https://gw.example.com",
"api": "anthropic-messages",
"apiKey": "$RPI_TEST_GATEWAY_KEY",
"models": [
{ "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
]
}
}
}"#,
)
.unwrap();
let r = resolve(None, Some("custom-claude"), None, None, None).unwrap();
let headers = r.model.headers.as_ref().expect("x-api-key folded");
assert_eq!(
headers.get("x-api-key").map(|s| s.as_str()),
Some("env-resolved-secret")
);
std::env::remove_var("RPI_TEST_GATEWAY_KEY");
}
#[test]
fn auth_header_provider_and_bare_apikey_provider_each_fold_their_own() {
let _env = TestEnv::new();
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"bearer-gw": {
"baseUrl": "https://bearer.example.com",
"api": "anthropic-messages",
"authHeader": true,
"apiKey": "bearer-secret",
"models": [ { "id": "bearer-model" } ]
},
"xkey-gw": {
"baseUrl": "https://xkey.example.com",
"api": "anthropic-messages",
"apiKey": "xkey-secret",
"models": [ { "id": "xkey-model" } ]
}
}
}"#,
)
.unwrap();
let r = resolve(None, None, None, None, None).unwrap();
assert_eq!(r.model.id, "bearer-model");
let r = resolve(None, Some("bearer-model"), None, None, None).unwrap();
let h = r.model.headers.as_ref().expect("bearer folded");
assert_eq!(
h.get("authorization").map(|s| s.as_str()),
Some("Bearer bearer-secret")
);
assert!(
h.get("x-api-key").is_none(),
"authHeader path must not synthesize x-api-key"
);
let r2 = resolve(None, Some("xkey-model"), None, None, None).unwrap();
let h2 = r2.model.headers.as_ref().expect("x-api-key folded");
assert_eq!(h2.get("x-api-key").map(|s| s.as_str()), Some("xkey-secret"));
assert!(
h2.get("authorization").is_none(),
"xkey-gw has no authHeader"
);
let catalog = available_catalog(&r);
let ids: Vec<&str> = catalog.iter().map(|m| m.id.as_str()).collect();
assert_eq!(ids, vec!["bearer-model", "xkey-model"]);
}
#[test]
fn settings_default_model_honored_for_models_json_provider() {
let _env = TestEnv::new();
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"beta-gw": {
"baseUrl": "https://beta.example.com",
"api": "anthropic-messages",
"apiKey": "beta-secret",
"models": [ { "id": "beta-model" } ]
},
"alpha-gw": {
"baseUrl": "https://alpha.example.com",
"api": "anthropic-messages",
"apiKey": "alpha-secret",
"models": [ { "id": "alpha-model" } ]
}
}
}"#,
)
.unwrap();
std::fs::write(
config::settings_path().unwrap(),
r#"{"defaultProvider":"beta-gw","defaultModel":"beta-model"}"#,
)
.unwrap();
let r = resolve(None, None, None, None, None).unwrap();
assert_eq!(r.model.id, "beta-model");
std::fs::write(
config::settings_path().unwrap(),
r#"{"defaultProvider":"not-a-provider","defaultModel":"beta-model"}"#,
)
.unwrap();
let r = resolve(None, None, None, None, None).unwrap();
assert_eq!(r.model.id, "alpha-model");
}
#[test]
fn available_catalog_filters_to_authed_models_in_gateway_only_setup() {
let _env = TestEnv::new();
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"gateway": {
"baseUrl": "https://gw.example.com",
"api": "anthropic-messages",
"apiKey": "gw-secret",
"models": [
{ "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
]
}
}
}"#,
)
.unwrap();
let r = resolve(None, None, None, None, None).unwrap();
assert!(!r.has_provider_key);
let catalog = available_catalog(&r);
let ids: Vec<&str> = catalog.iter().map(|m| m.id.as_str()).collect();
assert_eq!(
ids,
vec!["custom-claude"],
"selector must only list authed models"
);
assert!(r.provider.models().len() > catalog.len());
}
#[test]
fn available_catalog_keeps_all_models_on_provider_key_path() {
let _env = TestEnv::new();
std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
let r = resolve(None, None, None, None, None).unwrap();
assert!(r.has_provider_key);
let catalog = available_catalog(&r);
assert_eq!(catalog.len(), r.provider.models().len());
assert!(catalog.iter().any(|m| m.id == DEFAULT_MODEL_ID));
}
}