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,
};
mod binding;
mod factories;
use binding::{ExecutionBinding, RequestAuth};
pub use factories::RuntimeFactories;
#[cfg(test)]
use crate::config::Credential;
use crate::config::{self, DEFAULT_PROVIDER_ID};
use crate::thinking::parse_thinking_level;
use crate::{RuntimeSettings, ValueSource};
pub const DEFAULT_MODEL_ID: &str = "claude-opus-4-8";
const DEFAULT_MODELS_PER_PROVIDER: &[(&str, &str)] =
&[("anthropic", DEFAULT_MODEL_ID), ("openai", "gpt-5.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>,
pub providers: Vec<Arc<dyn Provider>>,
pub compat_warnings: Vec<String>,
execution: Arc<ExecutionBinding>,
pub(crate) snapshot_catalog: Vec<Model>,
pub(crate) snapshot_auth: BTreeMap<String, crate::credentials::AuthState>,
}
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("Invalid provider HTTP/proxy configuration")]
HttpConfiguration,
#[error("No implementation registered for provider {provider:?} and API {api}")]
UnsupportedApi { provider: String, api: String },
#[error("Unknown provider \"{0}\". Supported: anthropic, openai-completions, openai-responses, a models.json provider id, or an installed provider package")]
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),
}
fn provider_http_client(
models: &[Model],
default_base_url: &str,
settings: &RuntimeSettings,
values: &dyn ValueSource,
) -> Result<reqwest::Client, ResolveError> {
let env: std::collections::HashMap<String, String> = [
"http_proxy",
"https_proxy",
"all_proxy",
"no_proxy",
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"NO_PROXY",
]
.into_iter()
.map(|name| {
(
name.into(),
values
.env(name)
.or_else(|| values.env(&name.to_ascii_uppercase()))
.unwrap_or_default(),
)
})
.collect();
let builder = reqwest::Client::builder().no_proxy();
let bypass_all = env["no_proxy"]
.split([',', ' ', '\t', '\n'])
.any(|host| host == "*");
if !bypass_all {
let mut policy = env.clone();
policy.insert("no_proxy".into(), String::new());
for target in [
"http://proxy-validation.invalid",
"https://proxy-validation.invalid",
] {
rpi_ai::http::resolve_http_proxy_url_for_target(target, Some(&policy))
.map_err(|_| ResolveError::HttpConfiguration)?;
}
}
let targets: Vec<_> = if models.is_empty() {
vec![default_base_url]
} else {
models
.iter()
.map(|model| {
if model.base_url.is_empty() {
default_base_url
} else {
model.base_url.as_str()
}
})
.collect()
};
for target in targets {
rpi_ai::http::resolve_http_proxy_url_for_target(target, Some(&env))
.map_err(|_| ResolveError::HttpConfiguration)?;
}
let mut builder = builder.proxy(reqwest::Proxy::custom(move |url| {
rpi_ai::http::resolve_http_proxy_url_for_target(url.as_str(), Some(&env))
.ok()
.flatten()
}));
let timeout = settings
.http_idle_timeout_ms
.unwrap_or(rpi_ai::http::DEFAULT_HTTP_IDLE_TIMEOUT_MS);
builder = builder.pool_idle_timeout(if timeout > 0 {
Some(std::time::Duration::from_millis(timeout as u64))
} else {
None
});
builder.build().map_err(|_| ResolveError::HttpConfiguration)
}
pub(crate) fn resolve_with_factories(
request: &crate::ModelRequest<'_>,
inputs: &crate::RuntimeConfig,
factories: &RuntimeFactories,
values: &dyn ValueSource,
) -> Result<ResolvedModel, ResolveError> {
let resolution_values = crate::values::ResolutionValues::new(values);
let values: &dyn ValueSource = &resolution_values;
let cli_provider = request.provider;
let cli_model = request.model;
let cli_thinking = request.thinking;
let cli_api_key = request.api_key;
let cli_base_url = request.base_url;
let settings = &inputs.settings;
let mut models_cfg = inputs.models.clone();
let auth_store = &inputs.credentials;
apply_manifest_defaults(&mut models_cfg, &factories.manifests);
let compat_warnings = config::model_compat_warnings(&models_cfg);
let canonical_cli_provider = cli_provider
.map(|requested| canonicalize_cli_provider(requested, &models_cfg))
.transpose()?;
let cli_provider = canonical_cli_provider.as_deref();
let anthropic_credentials =
resolve_anthropic_credentials(&models_cfg, auth_store, &factories.manifests, values);
let openai_credentials =
resolve_openai_credentials(&models_cfg, auth_store, &factories.manifests, values);
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 = values
.env(ANTHROPIC_BASE_URL_ENV)
.filter(|value| !value.is_empty());
let mut catalog = build_catalog(&models_cfg, &factories.manifests, values);
for model in &mut catalog {
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();
}
}
}
let mut full_catalog = catalog.clone();
if let Some(api) = &request.api {
catalog.retain(|model| &model.api == api);
}
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 selected.is_none() && !parsed_pattern.inferred_provider {
selected = find_cli_model(
&parsed_pattern.raw_model_id,
cli_provider,
&catalog,
&models_cfg,
&anthropic_credentials,
&openai_credentials,
)?;
if selected.is_some() {
pattern_thinking = None;
}
}
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();
if let Some(base) = &cli_base_url_override {
model.base_url = base.clone();
for candidate in &mut full_catalog {
if candidate.provider == selected_provider && candidate.api == selected_api {
candidate.base_url = base.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 selected_api != rpi_ai::Api::AnthropicMessages {
cli_api_key
.clone()
.or_else(|| openai_credential_for(&openai_credentials, &selected_provider).cloned())
} else {
None
};
let selected_is_authed = openai_credentials.contains_key(&selected_provider)
|| 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,
});
}
let (provider, execution, has_provider_key) = build_runtime(
&selected_provider,
&selected_api,
&full_catalog,
(
selected_anthropic_credential.as_ref(),
selected_openai_key.as_deref(),
),
settings,
values,
factories,
)?;
let mut providers: Vec<Arc<dyn Provider>> = vec![provider.clone()];
{
let mut seen: Vec<(String, rpi_ai::Api)> =
vec![(selected_provider.clone(), selected_api.clone())];
for candidate in &full_catalog {
let key = (candidate.provider.clone(), candidate.api.clone());
if seen.contains(&key) {
continue;
}
seen.push(key.clone());
let (anth, openai_key) = match &candidate.api {
rpi_ai::Api::AnthropicMessages => {
let auth_header = models_cfg
.providers
.get(&candidate.provider)
.and_then(|p| p.auth_header)
.unwrap_or(false);
(
credential_for_selected_anthropic_provider(
None,
&anthropic_credentials,
&candidate.provider,
auth_header,
),
None,
)
}
rpi_ai::Api::OpenaiCompletions | rpi_ai::Api::OpenaiResponses => (
None,
openai_credential_for(&openai_credentials, &candidate.provider).cloned(),
),
_ => (
None,
openai_credential_for(&openai_credentials, &candidate.provider).cloned(),
),
};
let authed = openai_credentials.contains_key(&candidate.provider)
|| model_has_header_auth(candidate)
|| match candidate.api {
rpi_ai::Api::AnthropicMessages => anth.is_some(),
rpi_ai::Api::OpenaiCompletions | rpi_ai::Api::OpenaiResponses => {
openai_key.is_some()
}
_ => false,
};
if !authed {
continue;
}
let (runtime, _, _) = build_runtime(
&candidate.provider,
&candidate.api,
&full_catalog,
(anth.as_ref(), openai_key.as_deref()),
settings,
values,
factories,
)?;
providers.push(runtime);
}
}
let providers = factories::combine_provider_runtimes(providers);
let provider = providers
.iter()
.find(|provider| provider.id() == selected_provider)
.expect("selected provider is registered")
.clone();
model = execution
.descriptors()
.iter()
.find(|candidate| {
candidate.api == model.api && candidate.id.eq_ignore_ascii_case(&model.id)
})
.expect("selected model is bound")
.clone();
let ids: std::collections::BTreeSet<_> = full_catalog
.iter()
.map(|model| model.provider.clone())
.chain(
factories
.manifests
.iter()
.map(|manifest| manifest.id.clone()),
)
.collect();
let mut snapshot_auth: BTreeMap<_, _> = ids
.into_iter()
.map(|id| {
let state = crate::credentials::state(
&id,
models_cfg.providers.get(&id),
auth_store,
factories
.manifests
.iter()
.find(|manifest| manifest.id == id),
values,
|| {
full_catalog
.iter()
.any(|model| model.provider == id && model_has_header_auth(model))
},
);
(id, state)
})
.collect();
if cli_api_key.is_some() {
snapshot_auth.insert(
model.provider.clone(),
crate::credentials::AuthState {
status: crate::credentials::AuthStatus::Ready,
source: crate::credentials::CredentialSource::Explicit,
},
);
}
let mut snapshot_catalog: Vec<_> = full_catalog.iter().map(binding::descriptor).collect();
snapshot_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()))
});
let resolved = ResolvedModel {
provider,
model,
thinking_level,
has_provider_key,
theme: settings.theme.clone(),
providers,
compat_warnings,
execution,
snapshot_catalog,
snapshot_auth,
};
debug_assert!(resolved
.execution
.descriptors()
.iter()
.all(|model| model.headers.is_none()));
Ok(resolved)
}
type BoundRuntime = (Arc<dyn Provider>, Arc<ExecutionBinding>, bool);
fn build_runtime(
provider_id: &str,
api: &rpi_ai::Api,
catalog: &[Model],
credentials: (Option<&AnthropicCredential>, Option<&str>),
settings: &RuntimeSettings,
values: &dyn ValueSource,
factories: &RuntimeFactories,
) -> Result<BoundRuntime, ResolveError> {
let (anthropic_credential, openai_key) = credentials;
let anonymous = factories
.manifests
.iter()
.any(|manifest| manifest.id == provider_id && !manifest.requires_auth);
let provider_has_auth = anthropic_credential.is_some() || openai_key.is_some() || anonymous;
let models = catalog
.iter()
.filter(|model| model.api == *api && model.provider == provider_id)
.filter(|model| provider_has_auth || model_has_header_auth(model))
.cloned()
.collect();
let auth = match anthropic_credential {
Some(AnthropicCredential::ProviderKey(key)) => RequestAuth::ApiKey(key.clone()),
Some(AnthropicCredential::Headers(headers)) => RequestAuth::Headers(headers.clone()),
None => openai_key.map_or(RequestAuth::None, |key| RequestAuth::ApiKey(key.into())),
};
let has_provider_key = matches!(auth, RequestAuth::ApiKey(_)) || anonymous;
let (provider, binding) =
factories.configure(provider_id, api, models, auth, settings, values)?;
Ok((provider, binding, has_provider_key))
}
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| seen.insert((m.api.clone(), m.provider.clone(), m.id.to_ascii_lowercase())))
.cloned()
.collect()
}
pub fn authenticated_catalog(resolved: &ResolvedModel) -> Vec<Model> {
let mut seen = std::collections::HashSet::new();
resolved
.providers
.iter()
.flat_map(|provider| provider.models())
.filter(|m| seen.insert((m.api.clone(), m.provider.clone(), m.id.to_ascii_lowercase())))
.cloned()
.collect()
}
pub fn catalog_with_diagnostics(
mut cfg: config::ModelsConfig,
manifests: &[crate::ProviderDeclaration],
values: &dyn ValueSource,
) -> (Vec<Model>, Vec<String>) {
let (models, warnings) = execution_catalog_with_diagnostics(&mut cfg, manifests, values);
(models.iter().map(binding::descriptor).collect(), warnings)
}
pub(crate) fn execution_catalog_with_diagnostics(
cfg: &mut config::ModelsConfig,
manifests: &[crate::ProviderDeclaration],
values: &dyn ValueSource,
) -> (Vec<Model>, Vec<String>) {
apply_manifest_defaults(cfg, manifests);
let warnings = config::model_compat_warnings(cfg);
let mut catalog = build_catalog(cfg, manifests, values);
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()))
});
(catalog, warnings)
}
fn apply_manifest_defaults(
models_cfg: &mut config::ModelsConfig,
manifests: &[crate::ProviderDeclaration],
) {
for manifest in manifests {
if !models_cfg.providers.contains_key(&manifest.id) {
let first = manifest.models.first();
let config = serde_json::from_value(serde_json::json!({
"api": first.map(|model| model.api.as_str()),
"baseUrl": first.map(|model| model.base_url.as_str()), "models":[]
}))
.expect("provider config defaults");
models_cfg.providers.insert(manifest.id.clone(), config);
}
if let (Some(default), Some(config)) = (
manifest.models.first(),
models_cfg.providers.get_mut(&manifest.id),
) {
config
.api
.get_or_insert_with(|| default.api.as_str().to_string());
config
.base_url
.get_or_insert_with(|| default.base_url.clone());
}
}
}
pub(crate) fn builtin_catalog() -> Vec<Model> {
let mut catalog = anthropic_models();
catalog.extend(openai_responses_models());
catalog
}
pub(crate) fn provider_has_auth_headers(
provider: &str,
cfg: &config::ModelsConfig,
manifests: &[crate::ProviderDeclaration],
values: &dyn ValueSource,
) -> bool {
let mut selected = config::ModelsConfig::default();
if let Some(config) = cfg.providers.get(provider) {
selected.providers.insert(provider.into(), config.clone());
}
let manifests: Vec<_> = manifests
.iter()
.filter(|manifest| manifest.id == provider)
.cloned()
.collect();
apply_manifest_defaults(&mut selected, &manifests);
build_catalog(&selected, &manifests, values)
.iter()
.any(|model| model.provider == provider && model_has_header_auth(model))
}
fn build_catalog(
cfg: &config::ModelsConfig,
manifests: &[crate::ProviderDeclaration],
values: &dyn ValueSource,
) -> Vec<Model> {
let mut catalog = builtin_catalog();
for manifest in manifests {
let config = cfg.providers.get(&manifest.id);
for mut model in manifest.models.clone() {
if let Some(base) = config.and_then(|config| config.base_url.as_ref()) {
model.base_url = base.clone();
}
if let Some(headers) = config.and_then(|config| config.headers.as_ref()) {
merge_auth_headers(
&mut model,
&crate::values::resolve_headers(headers, None, values),
);
}
catalog.push(model);
}
}
merge_user_catalog(&mut catalog, cfg, values);
catalog
}
fn merge_user_catalog(
catalog: &mut Vec<Model>,
cfg: &config::ModelsConfig,
values: &dyn ValueSource,
) {
for (provider_id, provider_cfg) in &cfg.providers {
let Some(models) = config::provider_to_models(provider_id, provider_cfg, values) 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>),
}
type AnthropicCredentials = BTreeMap<String, AnthropicCredential>;
enum OpenAiCredential {
Key(String),
Anonymous,
}
type OpenAiCredentials = BTreeMap<String, OpenAiCredential>;
fn resolve_anthropic_credentials(
cfg: &config::ModelsConfig,
auth_store: &config::AuthStore,
manifests: &[crate::ProviderDeclaration],
values: &dyn ValueSource,
) -> 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 resolved = crate::credentials::resolve(
&provider_id,
cfg.providers.get(&provider_id),
auth_store,
manifests.iter().find(|manifest| manifest.id == provider_id),
values,
);
let credential = resolved
.key
.map(|key| anthropic_credential_from_key(key, resolved.bearer));
if let Some(credential) = credential {
credentials.insert(provider_id, credential);
}
}
credentials
}
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,
manifests: &[crate::ProviderDeclaration],
values: &dyn ValueSource,
) -> 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.contains(provider_id)
{
provider_ids.push(provider_id.clone());
}
}
for manifest in manifests {
if !provider_ids.contains(&manifest.id) {
provider_ids.push(manifest.id.clone());
}
}
let mut credentials = BTreeMap::new();
for provider_id in provider_ids {
let provider_cfg = cfg.providers.get(&provider_id);
let manifest = manifests.iter().find(|manifest| manifest.id == provider_id);
let resolved =
crate::credentials::resolve(&provider_id, provider_cfg, auth_store, manifest, values);
if provider_cfg.is_some_and(config::provider_is_anthropic_compatible) {
if matches!(
resolved.source,
crate::credentials::CredentialSource::Anonymous
) {
credentials.insert(provider_id, OpenAiCredential::Anonymous);
}
} else if let Some(key) = resolved.key {
credentials.insert(provider_id, OpenAiCredential::Key(key));
} else if matches!(
resolved.source,
crate::credentials::CredentialSource::Anonymous
) {
credentials.insert(provider_id, OpenAiCredential::Anonymous);
}
}
credentials
}
fn openai_credential_for<'a>(
credentials: &'a OpenAiCredentials,
provider_id: &str,
) -> Option<&'a String> {
match credentials.get(provider_id) {
Some(OpenAiCredential::Key(key)) => Some(key),
_ => None,
}
}
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_for_resolution(
model: &Model,
anthropic_credentials: &AnthropicCredentials,
openai_credentials: &OpenAiCredentials,
has_cli_key: bool,
) -> bool {
openai_credentials.contains_key(&model.provider)
|| 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 {
crate::credentials::has_header_auth(m.headers.as_ref())
}
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;