use super::{
ANTHROPIC_PROVIDER, AnthropicProvider, CodexRefreshedAuth, OPENAI_CODEX_PROVIDER,
OpenAiCodexProvider, OpenAiCompatibleProvider, Provider, ProviderSelection,
ReqwestHttpTransport,
};
use crate::{
config::{CustomProviderConfig, EffectiveConfig, ProviderCredential, Settings},
fast::FastWorkload,
};
use anyhow::Result;
use std::{path::Path, sync::Arc};
pub(crate) fn supported_custom_provider<'a>(
config: &'a EffectiveConfig,
selection: &ProviderSelection,
) -> Result<Option<&'a CustomProviderConfig>> {
crate::config::reject_retired_provider_selection(&selection.provider, &selection.model)?;
let selected_custom_provider = config.custom_providers.get(&selection.provider);
if selection.provider.as_str() != OPENAI_CODEX_PROVIDER
&& selection.provider.as_str() != ANTHROPIC_PROVIDER
&& selected_custom_provider.is_none()
{
anyhow::bail!(
"unsupported provider '{}'; supported providers: '{}', '{}' and configured custom providers",
selection.provider,
OPENAI_CODEX_PROVIDER,
ANTHROPIC_PROVIDER
);
}
Ok(selected_custom_provider)
}
fn custom_provider_from_config<T>(
paths: &crate::config::McPaths,
provider_id: &str,
model: &str,
api_key: Option<String>,
custom: &CustomProviderConfig,
transport: T,
fast: crate::fast::FastRequestState,
) -> OpenAiCompatibleProvider<T> {
OpenAiCompatibleProvider::custom(
provider_id.to_string(),
model.to_string(),
api_key,
custom.base_url.clone(),
custom.use_responses_endpoint,
transport,
)
.with_request_headers(custom.request_headers.clone())
.with_text_verbosity_support(custom.supports_text_verbosity)
.with_reasoning_protocol(
custom.reasoning_protocol,
crate::model_catalog::cached_model_max_output_tokens(paths, provider_id, model),
)
.with_service_tier(fast.service_tier().map(str::to_string))
}
#[cfg(test)]
pub(crate) fn provider_from_selection_with_settings(
config: &EffectiveConfig,
selection: &ProviderSelection,
cwd: &Path,
settings: &Settings,
) -> Result<Arc<dyn Provider>> {
provider_from_selection_with_settings_for_workload(
config,
selection,
cwd,
settings,
FastWorkload::Primary,
)
}
pub(crate) fn provider_from_selection_with_settings_for_workload(
config: &EffectiveConfig,
selection: &ProviderSelection,
cwd: &Path,
settings: &Settings,
workload: FastWorkload,
) -> Result<Arc<dyn Provider>> {
provider_from_selection_with_settings_for_workload_with_auth(
config,
selection,
cwd,
settings,
workload,
|config| config.resolve_provider_auth_for_runtime(),
)
}
pub(crate) fn provider_from_selection_with_settings_for_workload_with_resolved_auth(
config: &EffectiveConfig,
selection: &ProviderSelection,
cwd: &Path,
settings: &Settings,
workload: FastWorkload,
auth: ProviderCredential,
) -> Result<Arc<dyn Provider>> {
provider_from_selection_with_settings_for_workload_with_auth(
config,
selection,
cwd,
settings,
workload,
move |_| Ok(auth),
)
}
#[cfg(test)]
pub(crate) fn provider_from_selection_with_settings_with_codex_exchange(
config: &EffectiveConfig,
selection: &ProviderSelection,
cwd: &Path,
settings: &Settings,
exchange: impl FnOnce(&str) -> anyhow::Result<crate::config::NormalizedToken>,
) -> Result<Arc<dyn Provider>> {
provider_from_selection_with_settings_for_workload_with_auth(
config,
selection,
cwd,
settings,
FastWorkload::Primary,
move |config| config.resolve_provider_auth_for_runtime_with_exchange(exchange),
)
}
fn provider_from_selection_with_settings_for_workload_with_auth(
config: &EffectiveConfig,
selection: &ProviderSelection,
_cwd: &Path,
settings: &Settings,
workload: FastWorkload,
resolve_auth: impl FnOnce(&EffectiveConfig) -> Result<ProviderCredential>,
) -> Result<Arc<dyn Provider>> {
let selected_custom_provider = supported_custom_provider(config, selection)?;
let fast = crate::fast::resolve_fast_capability(
settings,
&selection.provider,
&selection.model,
&config.paths,
selected_custom_provider,
workload,
);
let auth = resolve_auth(config)?;
match (selection.provider.as_str(), auth) {
(OPENAI_CODEX_PROVIDER, ProviderCredential::OAuth { access, account_id }) => {
let paths = config.paths.clone();
Ok(Arc::new(
OpenAiCodexProvider::new(
selection.model.clone(),
access,
account_id,
ReqwestHttpTransport,
)
.with_service_tier(fast.service_tier().map(str::to_string))
.with_auth_refresh(move |cancellation| {
cancellation.check()?;
let ProviderCredential::OAuth { access, account_id } =
crate::config::force_refresh_codex_credential_from_store(&paths)?
else {
unreachable!("openai-codex forced refresh returns OAuth credentials")
};
cancellation.check()?;
Ok(CodexRefreshedAuth {
access_token: access,
account_id,
})
}),
))
}
(OPENAI_CODEX_PROVIDER, ProviderCredential::ApiKey { .. }) => anyhow::bail!(
"provider 'openai-codex' requires provider-keyed OAuth auth; refusing to send API-key/runtime token to Codex transport"
),
(OPENAI_CODEX_PROVIDER, ProviderCredential::NoAuth) => anyhow::bail!(
"provider 'openai-codex' requires provider-keyed OAuth auth; refusing no-auth configuration"
),
(ANTHROPIC_PROVIDER, ProviderCredential::ApiKey { key }) => {
let max_output_tokens = crate::model_catalog::cached_model_max_output_tokens(
&config.paths,
&selection.provider,
&selection.model,
);
let thinking_level = crate::thinking::resolve_thinking_level(
&crate::thinking::available_thinking_levels(
&selection.provider,
&selection.model,
crate::model_catalog::cached_model_thinking_metadata(
&config.paths,
&selection.provider,
&selection.model,
)
.as_ref(),
crate::thinking::ThinkingCapabilityScope::BuiltIn,
),
config.thinking_level,
);
Ok(Arc::new(
AnthropicProvider::new(selection.model.clone(), key, ReqwestHttpTransport)
.with_cache_ttl(settings.anthropic_cache_ttl)
.with_max_output_tokens(max_output_tokens)
.with_thinking_level(thinking_level),
))
}
(ANTHROPIC_PROVIDER, ProviderCredential::OAuth { .. }) => {
anyhow::bail!("provider 'anthropic' requires Anthropic API-key auth, not OAuth token")
}
(ANTHROPIC_PROVIDER, ProviderCredential::NoAuth) => anyhow::bail!(
"provider 'anthropic' requires Anthropic API-key auth; refusing no-auth configuration"
),
(provider_id, ProviderCredential::ApiKey { key }) => {
let Some(custom) = selected_custom_provider else {
unreachable!("provider support checked above")
};
Ok(Arc::new(custom_provider_from_config(
&config.paths,
provider_id,
&selection.model,
Some(key),
custom,
ReqwestHttpTransport,
fast.clone(),
)))
}
(provider_id, ProviderCredential::NoAuth) => {
let Some(custom) = selected_custom_provider else {
unreachable!("provider support checked above")
};
Ok(Arc::new(custom_provider_from_config(
&config.paths,
provider_id,
&selection.model,
None,
custom,
ReqwestHttpTransport,
fast,
)))
}
(provider_id, ProviderCredential::OAuth { .. }) => anyhow::bail!(
"provider '{provider_id}' requires custom-provider auth mode, not OAuth token"
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{config::McPaths, model_catalog::ModelCatalogEntry};
use std::collections::BTreeMap;
#[derive(Debug, Clone)]
struct NoopTransport;
fn base_config(paths: McPaths, provider: &str, model: &str) -> EffectiveConfig {
EffectiveConfig {
provider: Some(provider.to_string()),
model: Some(model.to_string()),
no_color: false,
file_autocomplete_respects_gitignore: true,
custom_providers: BTreeMap::new(),
thinking_level: crate::thinking::ThinkingLevel::Default,
auth: Some(ProviderCredential::OAuth {
access: "test-token".to_string(),
account_id: Some("acct".to_string()),
}),
paths,
}
}
#[test]
fn unsupported_provider_validation_preserves_error_text() {
let temp = tempfile::TempDir::new().unwrap();
let config = base_config(
McPaths::from_root(temp.path().join("mc")),
"unsupported",
"model-a",
);
let selection = ProviderSelection {
provider: "unsupported".to_string(),
model: "model-a".to_string(),
};
let error = supported_custom_provider(&config, &selection)
.unwrap_err()
.to_string();
assert_eq!(
error,
"unsupported provider 'unsupported'; supported providers: 'openai-codex', 'anthropic' and configured custom providers"
);
}
#[test]
fn retired_provider_cannot_dispatch_matching_custom_config() {
let temp = tempfile::TempDir::new().unwrap();
let mut config = base_config(
McPaths::from_root(temp.path().join("mc")),
"claude-code",
"claude-test",
);
config.auth = Some(ProviderCredential::NoAuth);
config.custom_providers.insert(
"claude-code".to_string(),
crate::config::make_custom_provider_config(
"Retired Claude Code",
"https://provider.example/v1",
"",
)
.unwrap(),
);
let selection = ProviderSelection {
provider: "claude-code".to_string(),
model: "claude-test".to_string(),
};
let error = match provider_from_selection_with_settings(
&config,
&selection,
temp.path(),
&Settings::default(),
) {
Ok(_) => panic!("retired provider dispatched through custom config"),
Err(error) => error.to_string(),
};
assert!(
error.contains("stale provider selection 'claude-code/claude-test'"),
"{error}"
);
assert!(error.contains("anthropic/claude-test"), "{error}");
assert!(error.contains("ANTHROPIC_API_KEY"), "{error}");
assert!(error.contains("Claude Code OAuth/subscription"), "{error}");
}
#[test]
fn custom_provider_from_config_uses_selected_endpoint_mode() {
let chat = CustomProviderConfig {
label: "Chat Provider".to_string(),
base_url: "https://chat.example/v1".to_string(),
fast_mode: None,
api_key_env_var: None,
models_dev_provider: None,
use_responses_endpoint: false,
supports_text_verbosity: false,
reasoning_protocol: crate::config::CustomReasoningProtocol::default(),
extra_models: Vec::new(),
request_headers: Default::default(),
};
let responses = CustomProviderConfig {
label: "Responses Provider".to_string(),
base_url: "https://responses.example/v1".to_string(),
fast_mode: None,
api_key_env_var: None,
models_dev_provider: None,
use_responses_endpoint: true,
supports_text_verbosity: false,
reasoning_protocol: crate::config::CustomReasoningProtocol::default(),
extra_models: Vec::new(),
request_headers: Default::default(),
};
let request = crate::providers::ProviderRequest::new(
"model-a",
vec![crate::providers::ChatMessage::user("hello")],
);
let chat_http = custom_provider_from_config(
&crate::config::McPaths::from_root(std::env::temp_dir()),
"chat-provider",
"model-a",
None,
&chat,
NoopTransport,
crate::fast::FastRequestState::Standard,
)
.build_http_request(&request);
let responses_http = custom_provider_from_config(
&crate::config::McPaths::from_root(std::env::temp_dir()),
"responses-provider",
"model-a",
None,
&responses,
NoopTransport,
crate::fast::FastRequestState::Standard,
)
.build_http_request(&request);
assert_eq!(chat_http.url, "https://chat.example/v1/chat/completions");
assert_eq!(responses_http.url, "https://responses.example/v1/responses");
assert!(chat_http.body.get("messages").is_some());
assert!(responses_http.body.get("input").is_some());
}
#[test]
fn custom_provider_from_config_uses_selected_reasoning_protocol() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let mut entry = ModelCatalogEntry::new("anthropic-custom", "model-a");
entry.max_output_tokens = Some(32_768);
let custom = CustomProviderConfig {
label: "Anthropic-like".to_string(),
base_url: "https://provider.example/v1".to_string(),
fast_mode: None,
api_key_env_var: None,
models_dev_provider: None,
use_responses_endpoint: false,
supports_text_verbosity: false,
reasoning_protocol: crate::config::CustomReasoningProtocol::AnthropicLike,
extra_models: Vec::new(),
request_headers: Default::default(),
};
crate::config::write_settings(
&paths,
&crate::config::Settings {
custom_providers: BTreeMap::from([(
"anthropic-custom".to_string(),
custom.clone(),
)]),
..Default::default()
},
)
.unwrap();
crate::model_catalog::write_catalog_cache_for_configured_provider(
&paths,
"anthropic-custom",
&[entry],
)
.unwrap();
let request = crate::providers::ProviderRequest::new(
"model-a",
vec![crate::providers::ChatMessage::user("hello")],
)
.with_thinking_level(crate::thinking::ThinkingLevel::High)
.with_default_reasoning_summary(true);
for use_responses_endpoint in [false, true] {
let custom = CustomProviderConfig {
use_responses_endpoint,
..custom.clone()
};
let body = custom_provider_from_config(
&paths,
"anthropic-custom",
"model-a",
None,
&custom,
NoopTransport,
crate::fast::FastRequestState::Standard,
)
.build_http_request(&request)
.body;
assert_eq!(
body["thinking"],
serde_json::json!({
"type": "enabled",
"budget_tokens": 16_384,
})
);
assert_eq!(body["max_tokens"], 32_768);
assert!(body.get("reasoning_effort").is_none());
assert!(body.get("reasoning").is_none());
}
}
#[test]
fn provider_from_selection_uses_supplied_fast_mode_snapshot() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let mut entry = ModelCatalogEntry::new_codex("gpt-5.5");
entry.service_tiers = Some(vec![crate::model_catalog::ModelCatalogServiceTier {
id: "priority".to_string(),
name: "fast".to_string(),
description: None,
}]);
crate::model_catalog::write_catalog_cache(&paths, OPENAI_CODEX_PROVIDER, &[entry]).unwrap();
crate::config::set_fast_mode(&paths, false).unwrap();
let config = base_config(paths.clone(), OPENAI_CODEX_PROVIDER, "gpt-5.5");
let selection = ProviderSelection {
provider: OPENAI_CODEX_PROVIDER.to_string(),
model: "gpt-5.5".to_string(),
};
let auth = config.auth.clone().expect("test config has OAuth auth");
let mut settings = Settings::default();
settings.fast.enabled = true;
let provider = provider_from_selection_with_settings_for_workload_with_resolved_auth(
&config,
&selection,
temp.path(),
&settings,
FastWorkload::Primary,
auth.clone(),
)
.unwrap();
assert_eq!(provider.service_tier_for_test(), Some("priority"));
crate::config::set_fast_mode(&paths, true).unwrap();
settings.fast.enabled = false;
let provider = provider_from_selection_with_settings_for_workload_with_resolved_auth(
&config,
&selection,
temp.path(),
&settings,
FastWorkload::Primary,
auth.clone(),
)
.unwrap();
assert_eq!(provider.service_tier_for_test(), None);
let unsupported = ModelCatalogEntry::new_codex("gpt-5.3");
crate::model_catalog::write_catalog_cache(&paths, OPENAI_CODEX_PROVIDER, &[unsupported])
.unwrap();
let unsupported_selection = ProviderSelection {
provider: OPENAI_CODEX_PROVIDER.to_string(),
model: "gpt-5.3".to_string(),
};
settings.fast.enabled = true;
let provider = provider_from_selection_with_settings_for_workload_with_resolved_auth(
&config,
&unsupported_selection,
temp.path(),
&settings,
FastWorkload::Primary,
auth,
)
.unwrap();
assert_eq!(provider.service_tier_for_test(), None);
}
#[test]
fn provider_from_selection_constructs_anthropic_provider() {
let temp = tempfile::TempDir::new().unwrap();
let config = EffectiveConfig {
provider: Some(ANTHROPIC_PROVIDER.to_string()),
model: Some(crate::providers::DEFAULT_ANTHROPIC_MODEL.to_string()),
no_color: false,
file_autocomplete_respects_gitignore: true,
custom_providers: BTreeMap::new(),
thinking_level: crate::thinking::ThinkingLevel::Default,
auth: Some(ProviderCredential::ApiKey {
key: "anthropic-key".to_string(),
}),
paths: McPaths::from_root(temp.path().join("mc")),
};
let mut auth = crate::config::Auth::default();
auth.providers.insert(
ANTHROPIC_PROVIDER.to_string(),
crate::config::AuthProviderRecord::ApiKey {
key: "anthropic-key".to_string(),
},
);
crate::config::write_auth(&config.paths, &auth).unwrap();
let selection = ProviderSelection::from_config(&config).unwrap();
let _provider = provider_from_selection_with_settings(
&config,
&selection,
temp.path(),
&Settings::default(),
)
.unwrap();
}
}