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::providers::openai_responses::openai_responses_models;
use rpi_ai::providers::openai_responses::OpenAiResponsesProvider;
use rpi_ai::{
AssistantMessageEventStream, Context, Model, Provider, SimpleStreamOptions, 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-opus-4-8";
const DEFAULT_MODELS_PER_PROVIDER: &[(&str, &str)] = &[
("amazon-bedrock", "us.anthropic.claude-opus-4-6-v1"),
("ant-ling", "Ring-2.6-1T"),
("anthropic", DEFAULT_MODEL_ID),
("openai", "gpt-5.5"),
("azure-openai-responses", "gpt-5.4"),
("openai-codex", "gpt-5.5"),
("radius", "auto"),
("nvidia", "nvidia/nemotron-3-super-120b-a12b"),
("deepseek", "deepseek-v4-pro"),
("google", "gemini-3.1-pro-preview"),
("google-vertex", "gemini-3.1-pro-preview"),
("github-copilot", "gpt-5.4"),
("openrouter", "moonshotai/kimi-k2.6"),
("vercel-ai-gateway", "zai/glm-5.1"),
("xai", "grok-4.6"),
("groq", "openai/gpt-oss-120b"),
("cerebras", "gpt-oss-120b"),
("zai", "glm-5.3"),
("zai-coding-cn", "glm-5.3"),
("mistral", "devstral-medium-latest"),
("minimax", "MiniMax-M2.7"),
("minimax-cn", "MiniMax-M2.7"),
("moonshotai", "kimi-k2.6"),
("moonshotai-cn", "kimi-k2.6"),
("huggingface", "moonshotai/Kimi-K2.6"),
("fireworks", "accounts/fireworks/models/kimi-k2p6"),
("together", "moonshotai/Kimi-K2.6"),
("baseten", "zai-org/GLM-5.2"),
("opencode", "kimi-k2.6"),
("opencode-go", "kimi-k2.6"),
("kimi-coding", "kimi-for-coding"),
("cloudflare-workers-ai", "@cf/moonshotai/kimi-k2.6"),
(
"cloudflare-ai-gateway",
"workers-ai/@cf/moonshotai/kimi-k2.6",
),
("qwen-token-plan", "qwen3.7-max"),
("qwen-token-plan-cn", "qwen3.7-max"),
("qwen-token-plan-individual", "qwen3.8-max"),
("xiaomi", "mimo-v2.5-pro"),
("xiaomi-token-plan-cn", "mimo-v2.5-pro"),
("xiaomi-token-plan-ams", "mimo-v2.5-pro"),
("xiaomi-token-plan-sgp", "mimo-v2.5-pro"),
];
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()
}
}
struct NamedAnthropicProvider {
id: String,
inner: AnthropicProvider,
}
#[async_trait::async_trait]
impl Provider for NamedAnthropicProvider {
fn id(&self) -> &str {
&self.id
}
fn models(&self) -> &[Model] {
self.inner.models()
}
async fn stream_simple(
&self,
model: &Model,
ctx: &Context,
opts: &SimpleStreamOptions,
) -> AssistantMessageEventStream {
self.inner.stream_simple(model, ctx, opts).await
}
}
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, openai-responses, or a models.json provider id")]
UnknownProvider(String),
#[error(
"Provider \"{requested}\" is ambiguous: {matches}. Use the exact provider id to select one."
)]
AmbiguousProvider { requested: String, matches: String },
#[error("No model matches \"{pattern}\". Available: {available}")]
NoMatch { pattern: String, available: String },
#[error(
"Model \"{pattern}\" is ambiguous across providers: {matches}. {auth_hint} Use --provider or provider/model."
)]
AmbiguousModel {
pattern: String,
matches: String,
auth_hint: &'static str,
},
#[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 settings = settings::load_settings().unwrap_or_default();
resolve_with_settings(
cli_provider,
cli_model,
cli_thinking,
cli_api_key,
cli_base_url,
settings,
)
}
pub fn resolve_for_cwd(
cli_provider: Option<&str>,
cli_model: Option<&str>,
cli_thinking: Option<ThinkingLevel>,
cli_api_key: Option<&str>,
cli_base_url: Option<&str>,
cwd: &std::path::Path,
project_trusted: bool,
) -> Result<ResolvedModel, ResolveError> {
let settings = settings::load_effective_model_settings(cwd, project_trusted)?;
resolve_with_settings(
cli_provider,
cli_model,
cli_thinking,
cli_api_key,
cli_base_url,
settings,
)
}
fn resolve_with_settings(
cli_provider: Option<&str>,
cli_model: Option<&str>,
cli_thinking: Option<ThinkingLevel>,
cli_api_key: Option<&str>,
cli_base_url: Option<&str>,
settings: settings::Settings,
) -> Result<ResolvedModel, ResolveError> {
let models_cfg = config::load_models_config()?;
let canonical_cli_provider = cli_provider
.map(|requested| canonicalize_cli_provider(requested, &models_cfg))
.transpose()?;
let cli_provider = canonical_cli_provider.as_deref();
let auth_store = config::read_auth()?;
let anthropic_credentials = resolve_anthropic_credentials(&models_cfg, &auth_store);
let openai_credentials = resolve_openai_credentials(&models_cfg, &auth_store);
let cli_api_key = cli_api_key
.filter(|key| !key.is_empty())
.map(str::to_string);
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 mut catalog = anthropic_models();
catalog.extend(openai_responses_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)
&& model.provider == DEFAULT_PROVIDER_ID
{
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));
}
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 (mut model, thinking_level) = match cli_model {
Some(raw) => {
let parsed_pattern = split_model_pattern(raw, cli_provider, &catalog, &models_cfg)?;
if let Some(provider) = parsed_pattern.provider.as_deref() {
if !provider_is_known(provider, &models_cfg) {
return Err(ResolveError::UnknownProvider(provider.to_string()));
}
}
let mut pattern_thinking = parsed_pattern.thinking;
let mut selected = find_cli_model(
&parsed_pattern.model_id,
parsed_pattern.provider.as_deref(),
&catalog,
&models_cfg,
&anthropic_credentials,
&openai_credentials,
)?;
if parsed_pattern.inferred_provider {
if let Some(inferred) = selected.as_ref() {
if !model_is_authed_for_resolution(
inferred,
&anthropic_credentials,
&openai_credentials,
false,
) {
let authenticated_raw_matches = |candidate: &str| {
catalog
.iter()
.filter(|model| {
model.id.eq_ignore_ascii_case(candidate)
&& (model.provider != inferred.provider
|| model.id != inferred.id)
&& model_is_authed_for_resolution(
model,
&anthropic_credentials,
&openai_credentials,
false,
)
})
.collect::<Vec<_>>()
};
let mut raw_matches =
authenticated_raw_matches(&parsed_pattern.raw_model_id);
let mut matched_complete_raw_id = true;
if raw_matches.is_empty() && parsed_pattern.thinking.is_some() {
if let Some((raw_without_thinking, _)) =
parsed_pattern.raw_model_id.rsplit_once(':')
{
raw_matches = authenticated_raw_matches(raw_without_thinking);
matched_complete_raw_id = false;
}
}
if let [raw_match] = raw_matches.as_slice() {
selected = Some((*raw_match).clone());
if matched_complete_raw_id {
pattern_thinking = None;
}
}
}
} else {
selected = find_cli_model(
&parsed_pattern.raw_model_id,
None,
&catalog,
&models_cfg,
&anthropic_credentials,
&openai_credentials,
)?;
if selected.is_some() {
pattern_thinking = None;
} else if parsed_pattern.thinking.is_some() {
if let Some((raw_without_thinking, _)) =
parsed_pattern.raw_model_id.rsplit_once(':')
{
selected = find_cli_model(
raw_without_thinking,
None,
&catalog,
&models_cfg,
&anthropic_credentials,
&openai_credentials,
)?;
}
}
}
}
let model = match selected {
Some(m) => m,
None => {
return Err(ResolveError::NoMatch {
pattern: parsed_pattern.model_id,
available,
});
}
};
let thinking_level = cli_thinking
.or(pattern_thinking)
.unwrap_or(DEFAULT_THINKING_LEVEL);
(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,
&anthropic_credentials,
&openai_credentials,
cli_api_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,
&models_cfg,
&anthropic_credentials,
&openai_credentials,
cli_api_key.is_some(),
);
(model, thinking_level)
}
}
};
let selected_api = model.api.clone();
let selected_provider = model.provider.clone();
let selected_anthropic_credential = if matches!(selected_api, rpi_ai::Api::AnthropicMessages) {
let auth_header = models_cfg
.providers
.get(&selected_provider)
.and_then(|provider| provider.auth_header)
.unwrap_or(false);
credential_for_selected_anthropic_provider(
cli_api_key.as_deref(),
&anthropic_credentials,
&selected_provider,
auth_header,
)
} else {
None
};
let selected_openai_key = if matches!(
selected_api,
rpi_ai::Api::OpenaiCompletions | rpi_ai::Api::OpenaiResponses
) {
cli_api_key
.clone()
.or_else(|| openai_credential_for(&openai_credentials, &selected_provider).cloned())
} else {
None
};
let selected_is_authed = model_has_header_auth(&model)
|| match selected_api {
rpi_ai::Api::AnthropicMessages => selected_anthropic_credential.is_some(),
rpi_ai::Api::OpenaiCompletions | rpi_ai::Api::OpenaiResponses => {
selected_openai_key.is_some()
}
_ => false,
};
if !selected_is_authed {
return Err(ResolveError::NoApiKey {
hint: NO_API_KEY_HINT,
});
}
if let Some(AnthropicCredential::Headers(headers)) = &selected_anthropic_credential {
merge_auth_headers(&mut model, headers);
}
if let Some(key) = &selected_openai_key {
merge_auth_headers(
&mut model,
&BTreeMap::from([("authorization".into(), format!("Bearer {key}"))]),
);
}
let mut provider_models: Vec<Model> = catalog
.into_iter()
.filter(|candidate| {
candidate.api == selected_api && candidate.provider == selected_provider
})
.collect();
if let Some(AnthropicCredential::Headers(headers)) = &selected_anthropic_credential {
for candidate in &mut provider_models {
merge_auth_headers(candidate, headers);
}
}
if let Some(key) = &selected_openai_key {
let headers = BTreeMap::from([("authorization".into(), format!("Bearer {key}"))]);
for candidate in &mut provider_models {
merge_auth_headers(candidate, &headers);
}
}
let (provider, has_provider_key): (Arc<dyn Provider>, bool) = match selected_api {
rpi_ai::Api::AnthropicMessages => {
let provider_key = selected_anthropic_credential
.as_ref()
.and_then(AnthropicCredential::provider_key)
.map(str::to_string);
let has_key = provider_key.is_some();
let inner = if selected_provider == DEFAULT_PROVIDER_ID
&& !matches!(
selected_anthropic_credential,
Some(AnthropicCredential::Headers(_))
) {
AnthropicProvider::with_models(
provider_key,
reqwest::Client::new(),
provider_models,
)
} else {
AnthropicProvider::with_models_without_env_api_key(
provider_key,
reqwest::Client::new(),
provider_models,
)
};
(
Arc::new(NamedAnthropicProvider {
id: selected_provider,
inner,
}),
has_key,
)
}
rpi_ai::Api::OpenaiCompletions => {
let has_key = selected_openai_key.is_some();
let inner = if selected_provider == "openai" {
OpenAiCompletionsProvider::with_models(
selected_provider,
selected_openai_key,
reqwest::Client::new(),
provider_models,
)
} else {
OpenAiCompletionsProvider::with_models_without_env_api_key(
selected_provider,
selected_openai_key,
reqwest::Client::new(),
provider_models,
)
};
(Arc::new(inner), has_key)
}
rpi_ai::Api::OpenaiResponses => {
let has_key = selected_openai_key.is_some();
let inner = if selected_provider == "openai" {
OpenAiResponsesProvider::with_models(
selected_provider,
selected_openai_key,
reqwest::Client::new(),
provider_models,
)
} else {
OpenAiResponsesProvider::with_models_without_env_api_key(
selected_provider,
selected_openai_key,
reqwest::Client::new(),
provider_models,
)
};
(Arc::new(inner), 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> {
let selected_api = &resolved.model.api;
let selected_provider = &resolved.model.provider;
let mut seen = std::collections::HashSet::new();
resolved
.provider
.models()
.iter()
.filter(|m| m.api == *selected_api && m.provider == *selected_provider)
.filter(|m| model_is_authed(m, resolved.has_provider_key))
.filter(|m| seen.insert((m.api.clone(), m.provider.clone(), m.id.to_ascii_lowercase())))
.cloned()
.collect()
}
pub fn catalog_all() -> Result<Vec<Model>, config::ConfigError> {
let cfg = config::load_models_config()?;
let mut catalog = anthropic_models();
catalog.extend(openai_responses_models());
merge_user_catalog(&mut catalog, &cfg);
catalog.sort_by(|a, b| {
a.provider
.to_ascii_lowercase()
.cmp(&b.provider.to_ascii_lowercase())
.then_with(|| a.id.to_ascii_lowercase().cmp(&b.id.to_ascii_lowercase()))
});
Ok(catalog)
}
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 == m.provider
&& candidate.id.eq_ignore_ascii_case(&m.id)
}) {
*existing = m;
} else {
catalog.push(m);
}
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum AnthropicCredential {
ProviderKey(String),
Headers(BTreeMap<String, String>),
}
impl AnthropicCredential {
fn provider_key(&self) -> Option<&str> {
match self {
Self::ProviderKey(key) => Some(key),
Self::Headers(_) => None,
}
}
}
type AnthropicCredentials = BTreeMap<String, AnthropicCredential>;
type OpenAiCredentials = BTreeMap<String, String>;
fn resolve_anthropic_credentials(
cfg: &config::ModelsConfig,
auth_store: &config::AuthStore,
) -> AnthropicCredentials {
let mut provider_ids = vec![DEFAULT_PROVIDER_ID.to_string()];
for (provider_id, provider_cfg) in &cfg.providers {
if config::provider_is_anthropic_compatible(provider_cfg)
&& !provider_ids.iter().any(|known| known == provider_id)
{
provider_ids.push(provider_id.clone());
}
}
let mut credentials = BTreeMap::new();
for provider_id in provider_ids {
let provider_cfg = cfg.providers.get(&provider_id);
let auth_header = provider_cfg
.and_then(|provider| provider.auth_header)
.unwrap_or(false);
let stored_key = stored_api_key(auth_store, &provider_id);
let configured_key = provider_cfg.and_then(|provider| {
provider
.api_key
.as_deref()
.filter(|raw| !raw.is_empty())
.and_then(|raw| config::resolve_config_value(raw, None))
.filter(|key| !key.is_empty())
});
let credential = stored_key
.or(configured_key)
.map(|key| anthropic_credential_from_key(key, auth_header))
.or_else(|| {
if provider_id != DEFAULT_PROVIDER_ID {
return None;
}
std::env::var(ANTHROPIC_AUTH_TOKEN_ENV)
.ok()
.filter(|token| !token.is_empty())
.map(|token| {
AnthropicCredential::Headers(BTreeMap::from([(
"authorization".to_string(),
format!("Bearer {token}"),
)]))
})
.or_else(|| {
std::env::var(ANTHROPIC_API_KEY_ENV)
.ok()
.filter(|key| !key.is_empty())
.map(|key| anthropic_credential_from_key(key, auth_header))
})
});
if let Some(credential) = credential {
credentials.insert(provider_id, credential);
}
}
credentials
}
fn stored_api_key(auth_store: &config::AuthStore, provider_id: &str) -> Option<String> {
auth_store
.get(provider_id)
.and_then(|credential| match credential {
Credential::ApiKey {
key: Some(key),
env,
} => config::resolve_config_value(key, env.as_ref()).filter(|key| !key.is_empty()),
_ => None,
})
}
fn anthropic_credential_from_key(key: String, auth_header: bool) -> AnthropicCredential {
if auth_header {
AnthropicCredential::Headers(BTreeMap::from([(
"authorization".to_string(),
format!("Bearer {key}"),
)]))
} else {
AnthropicCredential::ProviderKey(key)
}
}
fn anthropic_credential_for<'a>(
credentials: &'a AnthropicCredentials,
provider_id: &str,
) -> Option<&'a AnthropicCredential> {
credentials.get(provider_id)
}
fn credential_for_selected_anthropic_provider(
cli_api_key: Option<&str>,
credentials: &AnthropicCredentials,
provider_id: &str,
auth_header: bool,
) -> Option<AnthropicCredential> {
cli_api_key
.filter(|key| !key.is_empty())
.map(|key| anthropic_credential_from_key(key.to_string(), auth_header))
.or_else(|| anthropic_credential_for(credentials, provider_id).cloned())
}
fn resolve_openai_credentials(
cfg: &config::ModelsConfig,
auth_store: &config::AuthStore,
) -> OpenAiCredentials {
let mut provider_ids = vec!["openai".to_string()];
for (provider_id, provider_cfg) in &cfg.providers {
if (config::provider_is_openai_completions(provider_cfg)
|| config::provider_is_openai_responses(provider_cfg))
&& !provider_ids.iter().any(|known| known == provider_id)
{
provider_ids.push(provider_id.clone());
}
}
let mut credentials = BTreeMap::new();
for provider_id in provider_ids {
let provider_cfg = cfg.providers.get(&provider_id);
let key = stored_api_key(auth_store, &provider_id)
.or_else(|| configured_openai_api_key(&provider_id, provider_cfg));
if let Some(key) = key {
credentials.insert(provider_id, key);
}
}
credentials
}
fn configured_openai_api_key(
provider_id: &str,
provider_cfg: Option<&config::ProviderConfig>,
) -> Option<String> {
if let Some(provider_cfg) = provider_cfg {
return config::openai_provider_api_key(provider_id, provider_cfg);
}
(provider_id == "openai")
.then(|| std::env::var(OPENAI_API_KEY_ENV).ok())
.flatten()
.filter(|value| !value.is_empty())
}
fn openai_credential_for<'a>(
credentials: &'a OpenAiCredentials,
provider_id: &str,
) -> Option<&'a String> {
credentials.get(provider_id)
}
fn merge_auth_headers(model: &mut Model, auth_headers: &BTreeMap<String, String>) {
let headers = model.headers.get_or_insert_with(BTreeMap::new);
for (name, value) in auth_headers {
headers.retain(|existing, _| !existing.eq_ignore_ascii_case(name));
headers.insert(name.clone(), value.clone());
}
}
struct SplitModelPattern {
provider: Option<String>,
model_id: String,
raw_model_id: String,
thinking: Option<ThinkingLevel>,
inferred_provider: bool,
}
fn split_model_pattern(
value: &str,
cli_provider: Option<&str>,
catalog: &[Model],
cfg: &config::ModelsConfig,
) -> Result<SplitModelPattern, ResolveError> {
let mut provider = cli_provider.map(str::to_string);
let mut model_id = value.to_string();
let mut inferred_provider = false;
if let Some((prefix, remainder)) = value.split_once('/') {
if !prefix.is_empty() && !remainder.is_empty() {
let matches_explicit = cli_provider
.and_then(|requested| {
canonicalize_cli_provider(prefix, cfg)
.ok()
.map(|canonical| canonical == requested)
})
.unwrap_or(false);
if matches_explicit {
model_id = remainder.to_string();
} else if cli_provider.is_none() {
match canonicalize_cli_provider(prefix, cfg) {
Ok(canonical) => {
provider = Some(canonical);
model_id = remainder.to_string();
inferred_provider = true;
}
Err(ResolveError::UnknownProvider(_)) => {}
Err(error) => return Err(error),
}
}
}
}
let has_full_exact_match = catalog.iter().any(|model| {
model.id.eq_ignore_ascii_case(&model_id)
&& provider
.as_deref()
.map_or(true, |requested| provider_matches(model, requested, cfg))
});
let thinking = if has_full_exact_match {
None
} else if let Some((head, suffix)) = model_id.rsplit_once(':') {
if let Some(level) = parse_thinking_level(suffix) {
model_id = head.to_string();
Some(level)
} else {
None
}
} else {
None
};
Ok(SplitModelPattern {
provider,
model_id,
raw_model_id: value.to_string(),
thinking,
inferred_provider,
})
}
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.map_or(true, |requested| provider_matches(model, requested, cfg))
})
.cloned()
}
fn find_cli_model(
pattern: &str,
provider: Option<&str>,
catalog: &[Model],
cfg: &config::ModelsConfig,
anthropic_credentials: &AnthropicCredentials,
openai_credentials: &OpenAiCredentials,
) -> Result<Option<Model>, ResolveError> {
let exact_matches: Vec<&Model> = catalog
.iter()
.filter(|model| {
model.id.eq_ignore_ascii_case(pattern)
&& provider.map_or(true, |requested| provider_matches(model, requested, cfg))
})
.collect();
match exact_matches.as_slice() {
[] => Ok(None),
[model] => Ok(Some((*model).clone())),
_ => {
let authenticated: Vec<&Model> = exact_matches
.iter()
.copied()
.filter(|model| {
model_is_authed_for_resolution(
model,
anthropic_credentials,
openai_credentials,
false,
)
})
.collect();
if let [model] = authenticated.as_slice() {
return Ok(Some((*model).clone()));
}
let mut matches = exact_matches
.iter()
.map(|model| format!("{}/{}", model.provider, model.id))
.collect::<Vec<_>>();
matches.sort();
let auth_hint = if authenticated.is_empty() {
"No matching provider is authenticated."
} else {
"More than one matching provider is authenticated."
};
Err(ResolveError::AmbiguousModel {
pattern: pattern.to_string(),
matches: matches.join(", "),
auth_hint,
})
}
}
}
fn provider_is_known(requested: &str, cfg: &config::ModelsConfig) -> bool {
cfg.providers.contains_key(requested)
|| requested.eq_ignore_ascii_case("anthropic")
|| requested.eq_ignore_ascii_case("openai")
|| requested.eq_ignore_ascii_case("openai-completions")
|| requested.eq_ignore_ascii_case("openai-responses")
}
fn canonicalize_cli_provider(
requested: &str,
cfg: &config::ModelsConfig,
) -> Result<String, ResolveError> {
if cfg.providers.contains_key(requested) {
return Ok(requested.to_string());
}
const BUILTIN_PROVIDER_IDS: &[&str] = &[
"anthropic",
"openai",
"openai-completions",
"openai-responses",
];
if BUILTIN_PROVIDER_IDS.contains(&requested) {
return Ok(requested.to_string());
}
let mut matches = cfg
.providers
.keys()
.filter(|provider| provider.eq_ignore_ascii_case(requested))
.cloned()
.collect::<Vec<_>>();
matches.extend(
BUILTIN_PROVIDER_IDS
.iter()
.filter(|provider| provider.eq_ignore_ascii_case(requested))
.map(|provider| (*provider).to_string()),
);
matches.sort();
matches.dedup();
match matches.as_slice() {
[provider] => return Ok(provider.clone()),
[] => {}
_ => {
return Err(ResolveError::AmbiguousProvider {
requested: requested.to_string(),
matches: matches.join(", "),
});
}
}
Err(ResolveError::UnknownProvider(requested.to_string()))
}
fn provider_matches(model: &Model, requested: &str, cfg: &config::ModelsConfig) -> bool {
if model.provider == requested {
return true;
}
if cfg.providers.contains_key(requested) {
return false;
}
if requested.eq_ignore_ascii_case("anthropic") {
return model.provider == DEFAULT_PROVIDER_ID;
}
if requested.eq_ignore_ascii_case("openai") {
return model.provider == "openai";
}
if requested.eq_ignore_ascii_case("openai-completions") {
return matches!(model.api, rpi_ai::Api::OpenaiCompletions);
}
if requested.eq_ignore_ascii_case("openai-responses") {
return matches!(model.api, rpi_ai::Api::OpenaiResponses);
}
false
}
fn model_belongs_to_default_provider(
model: &Model,
requested: &str,
_cfg: &config::ModelsConfig,
) -> bool {
model.provider == requested
}
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,
anthropic_credentials: &AnthropicCredentials,
openai_credentials: &OpenAiCredentials,
has_cli_key: bool,
) -> bool {
model_has_header_auth(model)
|| has_cli_key
|| match model.api {
rpi_ai::Api::AnthropicMessages => {
anthropic_credential_for(anthropic_credentials, &model.provider).is_some()
}
rpi_ai::Api::OpenaiCompletions | rpi_ai::Api::OpenaiResponses => {
openai_credential_for(openai_credentials, &model.provider).is_some()
}
_ => 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],
models_cfg: &config::ModelsConfig,
anthropic_credentials: &AnthropicCredentials,
openai_credentials: &OpenAiCredentials,
has_cli_key: bool,
) -> Model {
for (provider, model_id) in DEFAULT_MODELS_PER_PROVIDER {
if let Some(model) = catalog.iter().find(|model| {
model.id.eq_ignore_ascii_case(model_id)
&& model_belongs_to_default_provider(model, provider, models_cfg)
&& model_is_authed_for_resolution(
model,
anthropic_credentials,
openai_credentials,
has_cli_key,
)
}) {
return model.clone();
}
}
if let Some(m) = catalog.iter().find(|m| {
model_is_authed_for_resolution(m, anthropic_credentials, openai_credentials, has_cli_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_matches_native_anthropic_default() {
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, "official-key");
std::fs::write(
config::models_path().unwrap(),
r#"{ "providers": { "gateway": { "baseUrl": "https://gw", "apiKey": "gateway-key", "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 unknown_slash_prefix_remains_part_of_the_raw_model_id() {
let _env = TestEnv::new();
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"gateway": {
"api": "openai-completions",
"apiKey": "gateway-key",
"models": [{"id":"meta-llama/llama-3.3"}]
}
}
}"#,
)
.unwrap();
let resolved = resolve(None, Some("meta-llama/llama-3.3:high"), None, None, None).unwrap();
assert_eq!(resolved.model.provider, "gateway");
assert_eq!(resolved.model.id, "meta-llama/llama-3.3");
assert_eq!(resolved.thinking_level, ThinkingLevel::High);
}
#[test]
fn complete_model_id_wins_before_parsing_a_thinking_suffix() {
let _env = TestEnv::new();
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"gateway": {
"api": "openai-completions",
"apiKey": "gateway-key",
"models": [
{"id":"vendor/model"},
{"id":"vendor/model:high"}
]
}
}
}"#,
)
.unwrap();
let resolved = resolve(None, Some("gateway/vendor/model:high"), None, None, None).unwrap();
assert_eq!(resolved.model.provider, "gateway");
assert_eq!(resolved.model.id, "vendor/model:high");
assert_eq!(resolved.thinking_level, DEFAULT_THINKING_LEVEL);
}
#[test]
fn authenticated_inferred_provider_beats_a_matching_raw_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-key",
"models": [{"id":"target"}]
},
"gateway": {
"api": "openai-completions",
"baseUrl": "https://gateway.example.com",
"apiKey": "gateway-key",
"models": [{"id":"alpha/target"}]
}
}
}"#,
)
.unwrap();
let resolved = resolve(None, Some("alpha/target"), None, None, None).unwrap();
assert_eq!(resolved.model.provider, "alpha");
assert_eq!(resolved.model.id, "target");
assert_eq!(resolved.model.base_url, "https://alpha.example.com");
}
#[test]
fn authenticated_raw_model_beats_an_unauthenticated_inferred_provider() {
let _env = TestEnv::new();
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"alpha": {
"api": "openai-completions",
"baseUrl": "https://alpha.example.com",
"models": [{"id":"target"}]
},
"gateway": {
"api": "openai-completions",
"baseUrl": "https://gateway.example.com",
"apiKey": "gateway-key",
"models": [{"id":"alpha/target"}]
}
}
}"#,
)
.unwrap();
let resolved = resolve(None, Some("alpha/target"), None, None, None).unwrap();
assert_eq!(resolved.model.provider, "gateway");
assert_eq!(resolved.model.id, "alpha/target");
assert_eq!(resolved.model.base_url, "https://gateway.example.com");
}
#[test]
fn thinking_suffix_raw_model_beats_an_unauthenticated_inferred_provider() {
let _env = TestEnv::new();
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"alpha": {
"api": "openai-completions",
"baseUrl": "https://alpha.example.com",
"models": [{"id":"target"}]
},
"gateway": {
"api": "openai-completions",
"baseUrl": "https://gateway.example.com",
"apiKey": "gateway-key",
"models": [{"id":"alpha/target"}]
}
}
}"#,
)
.unwrap();
let resolved = resolve(None, Some("alpha/target:high"), None, None, None).unwrap();
assert_eq!(resolved.model.provider, "gateway");
assert_eq!(resolved.model.id, "alpha/target");
assert_eq!(resolved.model.base_url, "https://gateway.example.com");
assert_eq!(resolved.thinking_level, ThinkingLevel::High);
}
#[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");
let r = resolve_with_key(Some("ANTHROPIC"), Some("claude-sonnet-5"), None).unwrap();
assert_eq!(r.provider.id(), DEFAULT_PROVIDER_ID);
}
#[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 gateway_models_key_wins_over_unrelated_anthropic_auth() {
let _env = TestEnv::new();
config::upsert_credential(
DEFAULT_PROVIDER_ID,
Credential::ApiKey {
key: Some("official-key".into()),
env: None,
},
)
.unwrap();
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"gateway": {
"api": "anthropic-messages",
"baseUrl": "https://gateway.example.com",
"apiKey": "gateway-key",
"models": [{"id":"gateway-model"}]
}
}
}"#,
)
.unwrap();
let resolved = resolve(None, Some("gateway/gateway-model"), None, None, None).unwrap();
assert_eq!(resolved.provider.id(), "gateway");
assert_eq!(resolved.model.provider, "gateway");
assert!(resolved.has_provider_key);
let cfg = config::load_models_config().unwrap();
let credentials = resolve_anthropic_credentials(&cfg, &config::read_auth().unwrap());
assert_eq!(
anthropic_credential_for(&credentials, DEFAULT_PROVIDER_ID),
Some(&AnthropicCredential::ProviderKey("official-key".into()))
);
assert_eq!(
anthropic_credential_for(&credentials, "gateway"),
Some(&AnthropicCredential::ProviderKey("gateway-key".into()))
);
}
#[test]
fn gateway_auth_json_credential_is_usable() {
let _env = TestEnv::new();
config::upsert_credential(
"gateway",
Credential::ApiKey {
key: Some("stored-gateway-key".into()),
env: None,
},
)
.unwrap();
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"gateway": {
"api": "anthropic-messages",
"baseUrl": "https://gateway.example.com",
"models": [{"id":"gateway-model"}]
}
}
}"#,
)
.unwrap();
let resolved = resolve(Some("gateway"), None, None, None, None).unwrap();
assert_eq!(resolved.model.id, "gateway-model");
assert_eq!(resolved.provider.id(), "gateway");
assert!(resolved.has_provider_key);
}
#[test]
fn anthropic_auth_does_not_authenticate_an_unkeyed_gateway() {
let _env = TestEnv::new();
config::upsert_credential(
DEFAULT_PROVIDER_ID,
Credential::ApiKey {
key: Some("official-key".into()),
env: None,
},
)
.unwrap();
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"gateway": {
"api": "anthropic-messages",
"baseUrl": "https://gateway.example.com",
"models": [{"id":"gateway-model"}]
}
}
}"#,
)
.unwrap();
let error = resolve(
Some("gateway"),
Some("gateway/gateway-model"),
None,
None,
None,
)
.unwrap_err();
assert!(matches!(error, ResolveError::NoApiKey { .. }));
let default = resolve(None, None, None, None, None).unwrap();
assert_eq!(default.provider.id(), DEFAULT_PROVIDER_ID);
assert_eq!(default.model.id, DEFAULT_MODEL_ID);
}
#[test]
fn two_anthropic_gateways_keep_credentials_isolated_by_id() {
let _env = TestEnv::new();
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"alpha": {
"api": "anthropic-messages",
"baseUrl": "https://shared.example.com",
"apiKey": "alpha-key",
"models": [{"id":"shared-model"}]
},
"beta": {
"api": "anthropic-messages",
"baseUrl": "https://shared.example.com",
"apiKey": "beta-key",
"models": [{"id":"shared-model"}]
}
}
}"#,
)
.unwrap();
let cfg = config::load_models_config().unwrap();
let credentials = resolve_anthropic_credentials(&cfg, &config::read_auth().unwrap());
assert_eq!(
anthropic_credential_for(&credentials, "alpha"),
Some(&AnthropicCredential::ProviderKey("alpha-key".into()))
);
assert_eq!(
anthropic_credential_for(&credentials, "beta"),
Some(&AnthropicCredential::ProviderKey("beta-key".into()))
);
let alpha = resolve(None, Some("alpha/shared-model"), None, None, None).unwrap();
let beta = resolve(None, Some("beta/shared-model"), None, None, None).unwrap();
assert_eq!(alpha.provider.id(), "alpha");
assert_eq!(beta.provider.id(), "beta");
assert!(alpha.has_provider_key && beta.has_provider_key);
}
#[test]
fn case_distinct_provider_ids_keep_endpoints_and_credentials_isolated() {
let _env = TestEnv::new();
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"alpha": {
"api": "anthropic-messages",
"baseUrl": "https://lower.example.com",
"authHeader": true,
"models": [{"id":"shared-model"}]
},
"ALPHA": {
"api": "anthropic-messages",
"baseUrl": "https://upper.example.com",
"authHeader": true,
"models": [{"id":"shared-model"}]
}
}
}"#,
)
.unwrap();
config::upsert_credential(
"alpha",
Credential::ApiKey {
key: Some("lower-key".into()),
env: None,
},
)
.unwrap();
config::upsert_credential(
"ALPHA",
Credential::ApiKey {
key: Some("upper-key".into()),
env: None,
},
)
.unwrap();
let lower = resolve(None, Some("alpha/shared-model"), None, None, None).unwrap();
assert_eq!(lower.provider.id(), "alpha");
assert_eq!(lower.model.base_url, "https://lower.example.com");
assert_eq!(
lower
.model
.headers
.as_ref()
.and_then(|headers| headers.get("authorization"))
.map(String::as_str),
Some("Bearer lower-key")
);
let upper = resolve(None, Some("ALPHA/shared-model"), None, None, None).unwrap();
assert_eq!(upper.provider.id(), "ALPHA");
assert_eq!(upper.model.base_url, "https://upper.example.com");
assert_eq!(
upper
.model
.headers
.as_ref()
.and_then(|headers| headers.get("authorization"))
.map(String::as_str),
Some("Bearer upper-key")
);
let error = resolve(Some("Alpha"), None, None, None, None).unwrap_err();
assert!(matches!(
error,
ResolveError::AmbiguousProvider {
requested,
matches
} if requested == "Alpha" && matches == "ALPHA, alpha"
));
let prefix_error = resolve(None, Some("Alpha/shared-model"), None, None, None).unwrap_err();
assert!(matches!(
prefix_error,
ResolveError::AmbiguousProvider {
requested,
matches
} if requested == "Alpha" && matches == "ALPHA, alpha"
));
}
#[test]
fn cli_provider_case_insensitively_selects_one_canonical_custom_id() {
let _env = TestEnv::new();
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"routeryo-copy": {
"api": "openai-completions",
"baseUrl": "https://routeryo-copy.example.com",
"apiKey": "copy-key",
"models": [{"id":"copy-model"}]
}
}
}"#,
)
.unwrap();
let resolved =
resolve(Some("ROUTERYO-COPY"), Some("copy-model"), None, None, None).unwrap();
assert_eq!(resolved.model.provider, "routeryo-copy");
assert_eq!(resolved.provider.id(), "routeryo-copy");
assert_eq!(resolved.model.base_url, "https://routeryo-copy.example.com");
let prefixed = resolve(None, Some("ROUTERYO-COPY/copy-model"), None, None, None).unwrap();
assert_eq!(prefixed.model.provider, "routeryo-copy");
assert_eq!(prefixed.model.id, "copy-model");
}
#[test]
fn cli_provider_canonicalization_keeps_builtin_and_custom_case_ids_distinct() {
let _env = TestEnv::new();
std::env::set_var(OPENAI_API_KEY_ENV, "official-openai-key");
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"OpenAI": {
"api": "openai-completions",
"baseUrl": "https://custom-openai.example.com",
"apiKey": "custom-openai-key",
"models": [{"id":"custom-model"}]
}
}
}"#,
)
.unwrap();
let custom = resolve(Some("OpenAI"), Some("custom-model"), None, None, None).unwrap();
assert_eq!(custom.model.provider, "OpenAI");
assert_eq!(custom.model.base_url, "https://custom-openai.example.com");
let builtin = resolve(Some("openai"), Some("gpt-6-astra"), None, None, None).unwrap();
assert_eq!(builtin.model.provider, "openai");
let error = resolve(Some("OPENAI"), None, None, None, None).unwrap_err();
assert!(matches!(
error,
ResolveError::AmbiguousProvider {
requested,
matches
} if requested == "OPENAI" && matches == "OpenAI, openai"
));
}
#[test]
fn cli_api_key_overrides_only_the_selected_anthropic_provider() {
let _env = TestEnv::new();
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"gateway": {
"api": "anthropic-messages",
"baseUrl": "https://gateway.example.com",
"authHeader": true,
"apiKey": "configured-key",
"models": [{"id":"gateway-model"}]
}
}
}"#,
)
.unwrap();
let cfg = config::load_models_config().unwrap();
let credentials = resolve_anthropic_credentials(&cfg, &config::read_auth().unwrap());
assert_eq!(
credential_for_selected_anthropic_provider(
Some("cli-key"),
&credentials,
"gateway",
true,
),
Some(AnthropicCredential::Headers(BTreeMap::from([(
"authorization".into(),
"Bearer cli-key".into(),
)])))
);
assert_eq!(
anthropic_credential_for(&credentials, "gateway"),
Some(&AnthropicCredential::Headers(BTreeMap::from([(
"authorization".into(),
"Bearer configured-key".into(),
)])))
);
let resolved = resolve(
Some("gateway"),
Some("gateway/gateway-model"),
None,
Some("cli-key"),
None,
)
.unwrap();
assert!(!resolved.has_provider_key);
assert_eq!(
resolved
.model
.headers
.as_ref()
.and_then(|headers| headers.get("authorization"))
.map(String::as_str),
Some("Bearer cli-key")
);
}
#[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 anthropic_base_url_env_does_not_redirect_a_custom_provider() {
let _env = TestEnv::new();
std::env::set_var(ANTHROPIC_BASE_URL_ENV, "https://ambient-proxy.example.com");
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"gateway": {
"api": "anthropic-messages",
"baseUrl": "https://gateway.example.com",
"apiKey": "gateway-secret",
"models": [{"id":"gateway-model"}]
}
}
}"#,
)
.unwrap();
let r = resolve(
Some("gateway"),
Some("gateway/gateway-model"),
None,
None,
None,
)
.unwrap();
assert_eq!(r.model.base_url, "https://gateway.example.com");
assert!(r.has_provider_key);
}
#[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, "gateway");
assert_eq!(r.provider.id(), "gateway");
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_completions_provider_alias_excludes_responses_models() {
let _env = TestEnv::new();
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"chat-gateway": {
"api": "openai-completions",
"baseUrl": "https://chat.example.com/v1",
"apiKey": "chat-key",
"models": [{"id":"chat-model"}]
},
"responses-gateway": {
"api": "openai-responses",
"baseUrl": "https://responses.example.com/v1",
"apiKey": "responses-key",
"models": [{"id":"responses-model"}]
}
}
}"#,
)
.unwrap();
let resolved = resolve(Some("openai-completions"), None, None, None, None).unwrap();
assert_eq!(resolved.model.api, rpi_ai::Api::OpenaiCompletions);
assert_eq!(resolved.model.provider, "chat-gateway");
assert_eq!(resolved.model.id, "chat-model");
}
#[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");
assert_eq!(
alpha
.model
.headers
.as_ref()
.and_then(|headers| headers.get("authorization"))
.map(String::as_str),
Some("Bearer alpha-secret")
);
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");
assert_eq!(
beta.model
.headers
.as_ref()
.and_then(|headers| headers.get("authorization"))
.map(String::as_str),
Some("Bearer beta-secret")
);
}
#[test]
fn bare_duplicate_model_id_prefers_the_only_authenticated_provider() {
let _env = TestEnv::new();
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"alpha": {
"api": "openai-completions",
"baseUrl": "https://alpha.example.com",
"models": [{"id":"shared-model"}]
},
"beta": {
"api": "openai-completions",
"baseUrl": "https://beta.example.com",
"apiKey": "beta-secret",
"models": [{"id":"shared-model"}]
}
}
}"#,
)
.unwrap();
let resolved = resolve(None, Some("shared-model"), None, None, None).unwrap();
assert_eq!(resolved.model.provider, "beta");
assert_eq!(resolved.model.base_url, "https://beta.example.com");
}
#[test]
fn bare_duplicate_model_id_rejects_multiple_authenticated_providers() {
let _env = TestEnv::new();
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"alpha": {
"api": "openai-completions",
"apiKey": "alpha-secret",
"models": [{"id":"shared-model"}]
},
"beta": {
"api": "openai-completions",
"apiKey": "beta-secret",
"models": [{"id":"shared-model"}]
}
}
}"#,
)
.unwrap();
let error = resolve(None, Some("shared-model"), None, None, None).unwrap_err();
match error {
ResolveError::AmbiguousModel {
pattern,
matches,
auth_hint,
} => {
assert_eq!(pattern, "shared-model");
assert_eq!(matches, "alpha/shared-model, beta/shared-model");
assert_eq!(
auth_hint,
"More than one matching provider is authenticated."
);
}
other => panic!("expected AmbiguousModel, got {other:?}"),
}
}
#[test]
fn bare_duplicate_model_id_rejects_when_no_provider_is_authenticated() {
let _env = TestEnv::new();
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"alpha": {
"api": "openai-completions",
"models": [{"id":"shared-model"}]
},
"beta": {
"api": "openai-completions",
"models": [{"id":"shared-model"}]
}
}
}"#,
)
.unwrap();
let error = resolve(None, Some("shared-model"), None, None, None).unwrap_err();
assert!(matches!(
error,
ResolveError::AmbiguousModel {
auth_hint: "No matching provider is authenticated.",
..
}
));
}
#[test]
fn openai_auth_json_overrides_config_for_both_protocols() {
let _env = TestEnv::new();
config::upsert_credential(
"chat-gateway",
Credential::ApiKey {
key: Some("stored-chat-key".into()),
env: None,
},
)
.unwrap();
config::upsert_credential(
"responses-gateway",
Credential::ApiKey {
key: Some("stored-responses-key".into()),
env: None,
},
)
.unwrap();
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"chat-gateway": {
"api": "openai-completions",
"baseUrl": "https://chat.example.com/v1",
"apiKey": "configured-chat-key",
"models": [{"id":"chat-model"}]
},
"responses-gateway": {
"api": "openai-responses",
"baseUrl": "https://responses.example.com/v1",
"apiKey": "configured-responses-key",
"models": [{"id":"responses-model"}]
}
}
}"#,
)
.unwrap();
let chat = resolve(
Some("chat-gateway"),
Some("chat-gateway/chat-model"),
None,
None,
None,
)
.unwrap();
assert!(chat.has_provider_key);
assert_eq!(
chat.model
.headers
.as_ref()
.and_then(|headers| headers.get("authorization"))
.map(String::as_str),
Some("Bearer stored-chat-key")
);
let responses = resolve(
Some("responses-gateway"),
Some("responses-gateway/responses-model"),
None,
None,
None,
)
.unwrap();
assert!(responses.has_provider_key);
assert_eq!(
responses
.model
.headers
.as_ref()
.and_then(|headers| headers.get("authorization"))
.map(String::as_str),
Some("Bearer stored-responses-key")
);
}
#[test]
fn openai_env_does_not_authenticate_custom_providers() {
let _env = TestEnv::new();
std::env::set_var(OPENAI_API_KEY_ENV, "official-openai-key");
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"chat-gateway": {
"api": "openai-completions",
"baseUrl": "https://chat.example.com/v1",
"models": [{"id":"chat-model"}]
},
"responses-gateway": {
"api": "openai-responses",
"baseUrl": "https://responses.example.com/v1",
"models": [{"id":"responses-model"}]
},
"openai-completions": {
"api": "openai-completions",
"baseUrl": "https://alias-chat.example.com/v1",
"models": [{"id":"alias-chat-model"}]
},
"openai-responses": {
"api": "openai-responses",
"baseUrl": "https://alias-responses.example.com/v1",
"models": [{"id":"alias-responses-model"}]
},
"OpenAI": {
"api": "openai-responses",
"baseUrl": "https://case-distinct.example.com/v1",
"models": [{"id":"case-distinct-model"}]
}
}
}"#,
)
.unwrap();
for (provider, model) in [
("chat-gateway", "chat-gateway/chat-model"),
("responses-gateway", "responses-gateway/responses-model"),
("openai-completions", "openai-completions/alias-chat-model"),
("openai-responses", "openai-responses/alias-responses-model"),
("OpenAI", "OpenAI/case-distinct-model"),
] {
let error = resolve(Some(provider), Some(model), None, None, None).unwrap_err();
assert!(matches!(error, ResolveError::NoApiKey { .. }));
}
let official = resolve(None, None, None, None, None).unwrap();
assert_eq!(official.provider.id(), "openai");
assert_eq!(official.model.id, "gpt-6-astra");
assert!(official.has_provider_key);
}
#[test]
fn cli_api_key_overrides_selected_openai_provider() {
let _env = TestEnv::new();
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"chat-gateway": {
"api": "openai-completions",
"baseUrl": "https://chat.example.com/v1",
"apiKey": "configured-chat-key",
"models": [{"id":"chat-model"}]
},
"responses-gateway": {
"api": "openai-responses",
"baseUrl": "https://responses.example.com/v1",
"apiKey": "configured-responses-key",
"models": [{"id":"responses-model"}]
}
}
}"#,
)
.unwrap();
for (provider, model) in [
("chat-gateway", "chat-gateway/chat-model"),
("responses-gateway", "responses-gateway/responses-model"),
] {
let resolved =
resolve(Some(provider), Some(model), None, Some("cli-key"), None).unwrap();
assert_eq!(resolved.provider.id(), provider);
assert!(resolved.has_provider_key);
assert_eq!(
resolved
.model
.headers
.as_ref()
.and_then(|headers| headers.get("authorization"))
.map(String::as_str),
Some("Bearer cli-key")
);
}
}
#[test]
fn anthropic_model_prefix_disambiguates_providers_with_the_same_model_id() {
let _env = TestEnv::new();
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"alpha": {
"api": "anthropic-messages",
"baseUrl": "https://alpha.example.com",
"apiKey": "alpha-secret",
"models": [{"id":"shared-model"}]
},
"beta": {
"api": "anthropic-messages",
"baseUrl": "https://beta.example.com",
"authHeader": true,
"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.provider, "alpha");
assert_eq!(alpha.model.base_url, "https://alpha.example.com");
assert!(alpha.has_provider_key);
assert!(alpha.model.headers.as_ref().map_or(true, |headers| {
headers
.keys()
.all(|name| !name.eq_ignore_ascii_case("x-api-key"))
}));
let beta = resolve(None, Some("beta/shared-model"), None, None, None).unwrap();
assert_eq!(beta.provider.id(), "beta");
assert_eq!(beta.model.provider, "beta");
assert_eq!(beta.model.base_url, "https://beta.example.com");
assert_eq!(
beta.model
.headers
.as_ref()
.and_then(|headers| headers.get("authorization"))
.map(String::as_str),
Some("Bearer beta-secret")
);
}
#[test]
fn trusted_project_defaults_override_global_defaults_for_resolution() {
let _env = TestEnv::new();
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"global": {
"api": "anthropic-messages",
"apiKey": "global-secret",
"models": [{"id":"global-model"}]
},
"project": {
"api": "anthropic-messages",
"apiKey": "project-secret",
"models": [{"id":"project-model"}]
}
}
}"#,
)
.unwrap();
std::fs::write(
config::settings_path().unwrap(),
r#"{"defaultProvider":"global","defaultModel":"global-model"}"#,
)
.unwrap();
let project = tempfile::tempdir().unwrap();
std::fs::create_dir_all(project.path().join(".rpi")).unwrap();
std::fs::write(
project.path().join(".rpi/settings.json"),
r#"{"defaultProvider":"project","defaultModel":"project-model"}"#,
)
.unwrap();
let trusted = resolve_for_cwd(None, None, None, None, None, project.path(), true).unwrap();
assert_eq!(trusted.provider.id(), "project");
assert_eq!(trusted.model.id, "project-model");
let untrusted =
resolve_for_cwd(None, None, None, None, None, project.path(), false).unwrap();
assert_eq!(untrusted.provider.id(), "global");
assert_eq!(untrusted.model.id, "global-model");
}
#[test]
fn unknown_model_prefix_without_a_raw_match_returns_no_match() {
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::NoMatch { pattern, .. } if pattern == "misspelled/gpt-test"
));
}
#[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_honors_models_json_auth_header() {
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_eq!(
r.model
.headers
.as_ref()
.and_then(|h| h.get("authorization"))
.map(String::as_str),
Some("Bearer flag-key")
);
assert!(!r.has_provider_key);
}
#[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");
assert!(r.has_provider_key);
assert!(r.model.headers.as_ref().map_or(true, |headers| {
headers.keys().all(|name| {
!name.eq_ignore_ascii_case("x-api-key")
&& !name.eq_ignore_ascii_case("authorization")
})
}));
}
#[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");
assert!(r.has_provider_key);
}
#[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();
assert!(r.has_provider_key);
let cfg = config::load_models_config().unwrap();
let credentials = resolve_anthropic_credentials(&cfg, &config::read_auth().unwrap());
assert_eq!(
anthropic_credential_for(&credentials, "gateway"),
Some(&AnthropicCredential::ProviderKey(
"env-resolved-secret".into()
))
);
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();
assert!(r2.has_provider_key);
assert!(r2.model.headers.as_ref().map_or(true, |headers| {
headers.keys().all(|name| {
!name.eq_ignore_ascii_case("x-api-key")
&& !name.eq_ignore_ascii_case("authorization")
})
}));
let catalog = available_catalog(&r);
let ids: Vec<&str> = catalog.iter().map(|m| m.id.as_str()).collect();
assert_eq!(ids, vec!["bearer-model"]);
let other_catalog = available_catalog(&r2);
let other_ids: Vec<&str> = other_catalog.iter().map(|m| m.id.as_str()).collect();
assert_eq!(other_ids, vec!["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":"alpha-gw","defaultModel":"alpha-model"}"#,
)
.unwrap();
let r = resolve(None, None, None, None, None).unwrap();
assert_eq!(r.model.id, "alpha-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, "beta-model");
}
#[test]
fn models_json_fallback_preserves_provider_and_model_declaration_order() {
let _env = TestEnv::new();
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"routeryo-copy": {
"api": "openai-completions",
"baseUrl": "https://router.example.com/v1",
"apiKey": "router-key",
"models": [
{ "id": "gpt-5.6-sol" },
{ "id": "gpt-5.6-terra" }
]
},
"alpha-gw": {
"api": "openai-completions",
"baseUrl": "https://alpha.example.com/v1",
"apiKey": "alpha-key",
"models": [ { "id": "alpha-model" } ]
}
}
}"#,
)
.unwrap();
let resolved = resolve(None, None, None, None, None).unwrap();
assert_eq!(resolved.model.provider, "routeryo-copy");
assert_eq!(resolved.model.id, "gpt-5.6-sol");
}
#[test]
fn native_known_provider_default_beats_first_model_in_array() {
let _env = TestEnv::new();
std::fs::write(
config::models_path().unwrap(),
r#"{
"providers": {
"deepseek": {
"api": "openai-completions",
"baseUrl": "https://api.deepseek.com",
"apiKey": "deepseek-key",
"models": [
{ "id": "deepseek-chat" },
{ "id": "deepseek-v4-pro" }
]
}
}
}"#,
)
.unwrap();
let resolved = resolve(None, None, None, None, None).unwrap();
assert_eq!(resolved.model.provider, "deepseek");
assert_eq!(resolved.model.id, "deepseek-v4-pro");
}
#[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_eq!(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));
}
}