use std::collections::HashMap;
use std::sync::OnceLock;
use crate::config::{Config, ProviderAuth, ProviderEntry};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ProviderKind {
OpenRouter,
OpenAI,
Anthropic,
Gemini,
DeepSeek,
Glm,
Cerebras,
Ollama,
OpenCode,
Custom,
}
pub fn default_model_for(provider_name: &str) -> &'static str {
match parse_provider(provider_name) {
Some(ProviderKind::OpenAI) => "gpt-4o",
Some(ProviderKind::Anthropic) => "claude-sonnet-4-6",
Some(ProviderKind::Gemini) => "gemini-2.0-flash",
Some(ProviderKind::DeepSeek) => "deepseek-v4-pro",
Some(ProviderKind::Glm) => "glm-5.2",
Some(ProviderKind::Cerebras) => "gemma-4-31b",
Some(ProviderKind::OpenCode) => "deepseek-v4-flash",
Some(ProviderKind::Ollama) => "llama3",
_ => "deepseek/deepseek-v4-flash",
}
}
pub fn default_model_for_entry(alias: &str, entry: &ProviderEntry) -> &'static str {
default_model_for(&Config::provider_type_of(alias, entry))
}
pub fn default_model_for_alias(
alias: &str,
providers: &HashMap<String, ProviderEntry>,
) -> &'static str {
match providers
.get(alias)
.or_else(|| providers.get(&alias.to_ascii_lowercase()))
{
Some(entry) => default_model_for_entry(alias, entry),
None => default_model_for(alias),
}
}
pub fn is_openai_responses_type(provider_type: &str) -> bool {
matches!(
provider_type.trim().to_ascii_lowercase().as_str(),
"openai-responses" | "openai_responses"
)
}
pub fn parse_provider(name: &str) -> Option<ProviderKind> {
match name.to_lowercase().as_str() {
"openrouter" => Some(ProviderKind::OpenRouter),
"openai" => Some(ProviderKind::OpenAI),
"openai-responses" | "openai_responses" => Some(ProviderKind::OpenAI),
"anthropic" => Some(ProviderKind::Anthropic),
"gemini" | "google" => Some(ProviderKind::Gemini),
"deepseek" => Some(ProviderKind::DeepSeek),
"glm" | "zhipu" => Some(ProviderKind::Glm),
"cerebras" => Some(ProviderKind::Cerebras),
"opencode" => Some(ProviderKind::OpenCode),
"ollama" => Some(ProviderKind::Ollama),
"custom" => Some(ProviderKind::Custom),
_ => None,
}
}
pub fn model_family(model: &str) -> Option<ProviderKind> {
let id = model.trim().to_ascii_lowercase();
let bare = id.rsplit('/').next().unwrap_or(id.as_str());
if bare.starts_with("glm-") {
Some(ProviderKind::Glm)
} else if bare.starts_with("deepseek") {
Some(ProviderKind::DeepSeek)
} else if bare.starts_with("claude-") {
Some(ProviderKind::Anthropic)
} else if bare.starts_with("gemini-") {
Some(ProviderKind::Gemini)
} else if (bare.starts_with("gpt-") && !bare.starts_with("gpt-oss-"))
|| bare.starts_with("chatgpt")
|| bare.starts_with("codex")
|| is_openai_o_series(bare)
{
Some(ProviderKind::OpenAI)
} else {
None
}
}
fn is_openai_o_series(bare: &str) -> bool {
let mut chars = bare.chars();
matches!(chars.next(), Some('o')) && matches!(chars.next(), Some(c) if c.is_ascii_digit())
}
#[derive(Debug, PartialEq, Eq)]
pub enum ModelSwitch {
Keep,
Switch(String),
NoProviderForFamily(String),
}
pub fn resolve_model_switch(
providers: &HashMap<String, ProviderEntry>,
active: &str,
model: &str,
) -> ModelSwitch {
let active_entry = providers
.get(active)
.or_else(|| providers.get(&active.to_ascii_lowercase()));
if active_entry.and_then(|entry| entry.model.as_deref()) == Some(model) {
return ModelSwitch::Keep;
}
let active_kind = active_provider_kind(providers, active);
if active_kind.is_some_and(|kind| default_model_for(kind_label(kind)) == model) {
return ModelSwitch::Keep;
}
if let Some(alias) = providers.iter().find_map(|(alias, entry)| {
(entry.model.as_deref() == Some(model) && !alias.eq_ignore_ascii_case(active))
.then(|| alias.clone())
}) {
return ModelSwitch::Switch(alias);
}
let Some(family) = model_family(model) else {
return ModelSwitch::Keep;
};
if active_kind == Some(family) {
return ModelSwitch::Keep;
}
match configured_alias_for_kind(providers, family) {
Some(alias) => ModelSwitch::Switch(alias),
None => ModelSwitch::NoProviderForFamily(kind_label(family).to_string()),
}
}
fn active_provider_kind(
providers: &HashMap<String, ProviderEntry>,
active: &str,
) -> Option<ProviderKind> {
let type_name = providers
.get(active)
.or_else(|| providers.get(&active.to_ascii_lowercase()))
.map(|entry| Config::provider_type_of(active, entry))
.unwrap_or_else(|| active.to_ascii_lowercase());
parse_provider(&type_name)
}
fn configured_alias_for_kind(
providers: &HashMap<String, ProviderEntry>,
kind: ProviderKind,
) -> Option<String> {
let mut matches: Vec<&String> = providers
.iter()
.filter(|(alias, entry)| {
parse_provider(&Config::provider_type_of(alias, entry)) == Some(kind)
})
.map(|(alias, _)| alias)
.collect();
matches.sort();
matches.first().map(|alias| alias.to_string())
}
fn kind_label(kind: ProviderKind) -> &'static str {
match kind {
ProviderKind::OpenRouter => "openrouter",
ProviderKind::OpenAI => "openai",
ProviderKind::Anthropic => "anthropic",
ProviderKind::Gemini => "gemini",
ProviderKind::DeepSeek => "deepseek",
ProviderKind::Glm => "glm",
ProviderKind::Cerebras => "cerebras",
ProviderKind::Ollama => "ollama",
ProviderKind::OpenCode => "opencode",
ProviderKind::Custom => "custom",
}
}
pub struct ProviderInfo {
pub kind: ProviderKind,
pub base_url: Option<String>,
pub api_key_env: Option<String>,
pub auth: Option<ProviderAuth>,
pub api_key_literal: Option<String>,
pub openai_responses: bool,
}
pub fn resolve_provider_info(
name: &str,
providers: &HashMap<String, ProviderEntry>,
) -> Option<ProviderInfo> {
let lower = name.to_ascii_lowercase();
if let Some(entry) = providers.get(name).or_else(|| providers.get(&lower)) {
let ptype = Config::provider_type_of(name, entry);
let kind = parse_provider(&ptype)?;
if let Some(url) = entry.base_url.as_deref()
&& let Err(err) = validate_custom_provider(
name,
url,
entry.allow_insecure,
false,
)
{
tracing::error!(
target: "dirge::provider",
"{err}"
);
eprintln!("error: {err}");
return None;
}
let api_key_literal = match entry.resolved_api_key() {
Some(Ok(k)) => Some(k),
Some(Err(missing)) => {
tracing::error!(
target: "dirge::provider",
"provider '{name}' references env var ${{{missing}}} via api_key but it is unset",
);
eprintln!(
"error: provider '{name}' references env var ${{{missing}}} via api_key but it is unset"
);
None
}
None => None,
};
return Some(ProviderInfo {
kind,
base_url: entry.base_url.clone(),
api_key_env: entry.api_key_env.clone(),
auth: entry.auth,
api_key_literal,
openai_responses: is_openai_responses_type(&ptype),
});
}
if let Some(entry) = plugin_provider(name).or_else(|| plugin_provider(&lower)) {
let ptype = Config::provider_type_of(name, &entry);
let kind = parse_provider(&ptype)?;
if let Some(url) = entry.base_url.as_deref()
&& let Err(err) = validate_custom_provider(
name,
url,
entry.allow_insecure,
true,
)
{
tracing::error!(
target: "dirge::provider",
"{err}"
);
eprintln!("error: {err}");
return None;
}
let api_key_literal = match entry.resolved_api_key() {
Some(Ok(k)) => Some(k),
Some(Err(missing)) => {
tracing::error!(
target: "dirge::provider",
"plugin provider '{name}' references env var ${{{missing}}} via api_key but it is unset",
);
eprintln!(
"error: plugin provider '{name}' references env var ${{{missing}}} via api_key but it is unset"
);
None
}
None => None,
};
return Some(ProviderInfo {
kind,
base_url: entry.base_url,
api_key_env: entry.api_key_env,
auth: entry.auth,
api_key_literal,
openai_responses: is_openai_responses_type(&ptype),
});
}
let kind = parse_provider(name)?;
Some(ProviderInfo {
kind,
base_url: None,
api_key_env: None,
auth: None,
api_key_literal: None,
openai_responses: is_openai_responses_type(name),
})
}
const BUILTIN_PROVIDER_NAMES: &[&str] = &[
"openai",
"anthropic",
"gemini",
"google",
"deepseek",
"glm",
"zhipu",
"cerebras",
"opencode",
"ollama",
"openrouter",
"custom",
];
pub(crate) fn validate_custom_provider(
name: &str,
base_url: &str,
allow_insecure: bool,
enforce_builtin_collision: bool,
) -> Result<(), String> {
if enforce_builtin_collision {
let lower = name.to_ascii_lowercase();
if BUILTIN_PROVIDER_NAMES
.iter()
.any(|b| b.eq_ignore_ascii_case(&lower))
{
return Err(format!(
"Custom provider '{}' collides with built-in provider name. \
Choose a different name.",
name
));
}
}
if !allow_insecure && !base_url.starts_with("https://") {
return Err(format!(
"Custom provider '{}' has insecure base_url '{}'. \
Set allow_insecure: true in config.json if this is a \
local-only endpoint (e.g. ollama, vllm). All other \
http:// URLs send your data in plaintext.",
name, base_url
));
}
if allow_insecure && base_url.starts_with("http://") && !looks_like_local_host(base_url) {
eprintln!(
" ⚠️ WARNING: custom provider '{}' is using http:// over a NON-LOCAL host: {}\n Every prompt, file content, and tool result is sent in plaintext.\n This is allowed because allow_insecure: true is set in config.json,\n but you should verify this is intentional — the typical allow_insecure\n use case is loopback (127.0.0.1 / localhost) endpoints like ollama.",
name, base_url,
);
}
Ok(())
}
fn looks_like_local_host(base_url: &str) -> bool {
let scheme_len = if base_url.len() >= 7 && base_url[..7].eq_ignore_ascii_case("http://") {
7
} else {
return false;
};
let after = &base_url[scheme_len..];
let end = after.find(['/', '?', '#']).unwrap_or(after.len());
let host_and_port = &after[..end];
let host: &str = if let Some(rest) = host_and_port.strip_prefix('[')
&& let Some(end) = rest.find(']')
{
&rest[..end]
} else {
host_and_port
.rsplit_once(':')
.map(|(h, _)| h)
.unwrap_or(host_and_port)
};
let lower = host.to_ascii_lowercase();
if matches!(
lower.as_str(),
"localhost" | "ip6-localhost" | "ip6-loopback"
) {
return true;
}
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
return match ip {
std::net::IpAddr::V4(v4) => v4.is_loopback() || v4.is_private() || v4.is_link_local(),
std::net::IpAddr::V6(v6) => v6.is_loopback() || v6.is_unspecified(),
};
}
lower.ends_with(".local")
}
static PLUGIN_PROVIDERS: OnceLock<HashMap<String, ProviderEntry>> = OnceLock::new();
#[cfg_attr(not(feature = "plugin"), allow(dead_code))]
pub fn install_plugin_providers(map: HashMap<String, ProviderEntry>) -> usize {
let size = map.len();
if let Err(rejected) = PLUGIN_PROVIDERS.set(map) {
let in_effect = PLUGIN_PROVIDERS.get().map(|m| m.len()).unwrap_or(0);
tracing::warn!(
target: "dirge::provider",
attempted = rejected.len(),
in_effect,
"plugin providers already installed — ignoring re-registration (runtime hot-reload of providers is not supported)",
);
return in_effect;
}
size
}
fn plugin_provider(name: &str) -> Option<ProviderEntry> {
PLUGIN_PROVIDERS.get().and_then(|m| m.get(name).cloned())
}
fn provider_env_var(kind: ProviderKind) -> &'static str {
match kind {
ProviderKind::OpenAI => "OPENAI_API_KEY",
ProviderKind::Anthropic => "ANTHROPIC_API_KEY",
ProviderKind::Gemini => "GEMINI_API_KEY",
ProviderKind::DeepSeek => "DEEPSEEK_API_KEY",
ProviderKind::Glm => "GLM_API_KEY",
ProviderKind::Cerebras => "CEREBRAS_API_KEY",
ProviderKind::OpenCode => "OPENCODE_API_KEY",
ProviderKind::Ollama => "OLLAMA_API_KEY",
ProviderKind::OpenRouter => "OPENROUTER_API_KEY",
ProviderKind::Custom => "CUSTOM_API_KEY",
}
}
pub fn auto_detect_provider() -> Option<&'static str> {
auto_detect_provider_from(|name| std::env::var(name).ok())
}
pub(crate) const PROVIDER_AUTODETECT_ORDER: &[(&str, &str)] = &[
("DEEPSEEK_API_KEY", "deepseek"),
("OPENAI_API_KEY", "openai"),
("ANTHROPIC_API_KEY", "anthropic"),
("GEMINI_API_KEY", "gemini"),
("GLM_API_KEY", "glm"),
("ZHIPU_API_KEY", "glm"),
("OPENCODE_API_KEY", "opencode"),
("CEREBRAS_API_KEY", "cerebras"),
("OLLAMA_API_KEY", "ollama"),
("OPENROUTER_API_KEY", "openrouter"),
];
pub(crate) fn auto_detect_provider_from<F: Fn(&str) -> Option<String>>(
env: F,
) -> Option<&'static str> {
for (env_var, provider_name) in PROVIDER_AUTODETECT_ORDER {
if let Some(v) = env(env_var)
&& !v.is_empty()
{
return Some(provider_name);
}
}
None
}
pub fn auth_detect_provider() -> Option<&'static str> {
let openai = crate::auth::store::OpenAiAuthStore::default()
.load_openai()
.ok()
.flatten()
.is_some();
let anthropic = std::env::var("ANTHROPIC_OAUTH_TOKEN").is_ok_and(|v| !v.is_empty())
|| crate::provider::anthropic_oauth::credentials_file_path().exists();
auth_detect_provider_from(openai, anthropic)
}
pub(crate) fn auth_detect_provider_from(openai: bool, anthropic: bool) -> Option<&'static str> {
if openai {
Some("openai")
} else if anthropic {
Some("anthropic")
} else {
None
}
}
pub(crate) fn provider_env_var_fallbacks(kind: ProviderKind) -> &'static [&'static str] {
match kind {
ProviderKind::Glm => &["ZHIPU_API_KEY"],
ProviderKind::Anthropic => &[],
ProviderKind::Gemini => &["GOOGLE_GENERATIVE_AI_API_KEY", "GOOGLE_API_KEY"],
_ => &[],
}
}
pub(crate) fn resolve_api_key_from<F>(
kind: ProviderKind,
api_key_env_override: Option<&str>,
cli_key: Option<&str>,
env: F,
) -> anyhow::Result<String>
where
F: Fn(&str) -> Option<String>,
{
if let Some(key) = cli_key.filter(|k| !k.is_empty()) {
return Ok(key.to_string());
}
let env_var = api_key_env_override
.filter(|s| !s.is_empty())
.unwrap_or_else(|| provider_env_var(kind));
if let Some(key) = env(env_var)
&& !key.is_empty()
{
return Ok(key);
}
if api_key_env_override.is_none_or(|s| s.is_empty()) {
for fallback in provider_env_var_fallbacks(kind) {
if let Some(key) = env(fallback)
&& !key.is_empty()
{
return Ok(key);
}
}
}
if kind == ProviderKind::Ollama {
return Ok(String::new());
}
if kind == ProviderKind::Custom {
return Ok(String::new());
}
let keyless_hint = if kind == ProviderKind::OpenAI {
" For a keyless OpenAI-compatible endpoint (e.g. a local ollama/vLLM server), set `provider_type` to \"custom\" (or \"ollama\") instead of \"openai\"."
} else {
""
};
let fallbacks = provider_env_var_fallbacks(kind);
if fallbacks.is_empty() {
anyhow::bail!(
"No API key found for {kind:?}. Set the {env_var} environment variable or pass --api-key.{keyless_hint}"
)
} else {
anyhow::bail!(
"No API key found for {kind:?}. Set {env_var} (or one of: {}) or pass --api-key.{keyless_hint}",
fallbacks.join(", ")
)
}
}
#[cfg(test)]
mod model_family_tests {
use super::*;
#[test]
fn matches_known_cloud_families_by_prefix() {
assert_eq!(model_family("glm-5.2"), Some(ProviderKind::Glm));
assert_eq!(model_family("glm-4.6"), Some(ProviderKind::Glm));
assert_eq!(
model_family("deepseek-v4-pro"),
Some(ProviderKind::DeepSeek)
);
assert_eq!(model_family("claude-opus-4"), Some(ProviderKind::Anthropic));
assert_eq!(model_family("gemini-2.0-flash"), Some(ProviderKind::Gemini));
assert_eq!(model_family("gpt-5.5"), Some(ProviderKind::OpenAI));
assert_eq!(
model_family("chatgpt-4o-latest"),
Some(ProviderKind::OpenAI)
);
assert_eq!(model_family("codex-mini"), Some(ProviderKind::OpenAI));
assert_eq!(model_family("o3"), Some(ProviderKind::OpenAI));
assert_eq!(model_family("o4-mini"), Some(ProviderKind::OpenAI));
}
#[test]
fn is_case_insensitive_and_trims() {
assert_eq!(model_family(" GLM-4.6 "), Some(ProviderKind::Glm));
assert_eq!(model_family("GPT-5.5"), Some(ProviderKind::OpenAI));
}
#[test]
fn strips_openrouter_vendor_prefix() {
assert_eq!(
model_family("deepseek/deepseek-v4-flash"),
Some(ProviderKind::DeepSeek)
);
assert_eq!(
model_family("anthropic/claude-opus-4"),
Some(ProviderKind::Anthropic)
);
}
#[test]
fn local_and_ambiguous_ids_are_unclassified() {
assert_eq!(model_family("llama3"), None);
assert_eq!(model_family("vibe-thinker:latest"), None);
assert_eq!(model_family("qwen3-coder-plus"), None);
assert_eq!(model_family("my-custom-model"), None);
assert_eq!(model_family(""), None);
assert_eq!(model_family("ollama-thing"), None);
assert_eq!(model_family("opus-local"), None);
}
}
#[cfg(test)]
mod cerebras_identity_tests {
use super::*;
#[test]
fn cerebras_parses_case_insensitively_and_defaults_to_gemma_4_31b() {
for name in ["cerebras", "CEREBRAS", "CeReBrAs"] {
assert_eq!(
parse_provider(name).map(kind_label),
Some("cerebras"),
"provider name {name:?} should resolve canonically",
);
}
assert_eq!(default_model_for("cerebras"), "gemma-4-31b");
}
}
#[cfg(test)]
mod resolve_model_switch_tests {
use super::*;
fn entry(model: Option<&str>) -> ProviderEntry {
ProviderEntry {
model: model.map(str::to_string),
..Default::default()
}
}
fn typed_entry(provider_type: &str, model: Option<&str>) -> ProviderEntry {
ProviderEntry {
provider_type: Some(provider_type.to_string()),
model: model.map(str::to_string),
..Default::default()
}
}
#[test]
fn openai_responses_type_parses_as_openai_kind() {
assert_eq!(
parse_provider("openai-responses"),
Some(ProviderKind::OpenAI)
);
assert_eq!(
parse_provider("openai_responses"),
Some(ProviderKind::OpenAI)
);
assert!(is_openai_responses_type("openai-responses"));
assert!(is_openai_responses_type(" OpenAI-Responses "));
assert!(!is_openai_responses_type("openai"));
assert!(!is_openai_responses_type("anthropic"));
}
#[test]
fn resolve_provider_info_flags_openai_responses() {
let providers = HashMap::from([
(
"gpt5".to_string(),
ProviderEntry {
provider_type: Some("openai-responses".to_string()),
base_url: Some("https://proxy.invalid/v1".to_string()),
api_key: Some("sk-test".to_string()),
..Default::default()
},
),
(
"vanilla".to_string(),
ProviderEntry {
provider_type: Some("openai".to_string()),
base_url: Some("https://proxy.invalid/v1".to_string()),
api_key: Some("sk-test".to_string()),
..Default::default()
},
),
]);
let r = resolve_provider_info("gpt5", &providers).unwrap();
assert_eq!(r.kind, ProviderKind::OpenAI);
assert!(r.openai_responses, "openai-responses must set the flag");
let v = resolve_provider_info("vanilla", &providers).unwrap();
assert_eq!(v.kind, ProviderKind::OpenAI);
assert!(!v.openai_responses, "plain openai must not set the flag");
}
fn user_like_providers() -> HashMap<String, ProviderEntry> {
HashMap::from([
("deepseek".to_string(), entry(Some("deepseek-v4-pro"))),
("glm".to_string(), typed_entry("glm", Some("glm-5.2"))),
(
"ollama".to_string(),
typed_entry("openai", Some("vibe-thinker:latest")),
),
])
}
#[test]
fn exact_pin_on_other_provider_switches() {
let providers = user_like_providers();
assert_eq!(
resolve_model_switch(&providers, "deepseek", "glm-5.2"),
ModelSwitch::Switch("glm".to_string())
);
}
#[test]
fn free_form_family_id_routes_to_configured_provider() {
let providers = user_like_providers();
assert_eq!(
resolve_model_switch(&providers, "deepseek", "glm-4.6"),
ModelSwitch::Switch("glm".to_string())
);
}
#[test]
fn same_family_free_form_id_keeps_current_client() {
let providers = user_like_providers();
assert_eq!(
resolve_model_switch(&providers, "glm", "glm-4.6"),
ModelSwitch::Keep
);
assert_eq!(
resolve_model_switch(&providers, "deepseek", "deepseek-v4-flash"),
ModelSwitch::Keep
);
}
#[test]
fn unclassifiable_id_keeps_current_client() {
let providers = user_like_providers();
assert_eq!(
resolve_model_switch(&providers, "deepseek", "some-local-model"),
ModelSwitch::Keep
);
}
#[test]
fn foreign_family_without_a_provider_warns() {
let providers = user_like_providers();
assert_eq!(
resolve_model_switch(&providers, "deepseek", "claude-opus-4"),
ModelSwitch::NoProviderForFamily("anthropic".to_string())
);
}
#[test]
fn routing_honors_provider_type_alias() {
let providers = HashMap::from([
("deepseek".to_string(), entry(Some("deepseek-v4-pro"))),
(
"zhipu-proxy".to_string(),
typed_entry("glm", Some("glm-5.2")),
),
]);
assert_eq!(
resolve_model_switch(&providers, "deepseek", "glm-4.6"),
ModelSwitch::Switch("zhipu-proxy".to_string())
);
}
#[test]
fn switch_target_is_deterministic_across_duplicate_kinds() {
let providers = HashMap::from([
("deepseek".to_string(), entry(Some("deepseek-v4-pro"))),
("glm-b".to_string(), typed_entry("glm", Some("glm-5.2"))),
("glm-a".to_string(), typed_entry("glm", Some("glm-5.2"))),
]);
assert_eq!(
resolve_model_switch(&providers, "deepseek", "glm-4.6"),
ModelSwitch::Switch("glm-a".to_string())
);
}
#[test]
fn cerebras_builtin_default_model_keeps_the_active_client() {
assert_eq!(default_model_for("cerebras"), "gemma-4-31b");
assert_eq!(
resolve_model_switch(&HashMap::new(), "cerebras", "gemma-4-31b"),
ModelSwitch::Keep,
);
}
#[test]
fn cerebras_zero_config_gpt_oss_keeps_the_active_client() {
assert_eq!(
resolve_model_switch(&HashMap::new(), "cerebras", "gpt-oss-120b"),
ModelSwitch::Keep,
);
}
#[test]
fn cerebras_unpinned_alias_gpt_oss_keeps_the_active_client() {
let providers =
HashMap::from([("fast-cerebras".to_string(), typed_entry("cerebras", None))]);
assert_eq!(
resolve_model_switch(&providers, "fast-cerebras", "gpt-oss-120b"),
ModelSwitch::Keep,
);
}
#[test]
fn gpt_oss_is_host_dependent_away_from_cerebras() {
for model in ["gpt-oss-120b", "gpt-oss-20b"] {
assert_eq!(
model_family(model),
None,
"{model} is open-weight and does not imply an OpenAI API endpoint",
);
}
assert_eq!(
resolve_model_switch(&HashMap::new(), "deepseek", "gpt-oss-120b"),
ModelSwitch::Keep,
);
}
#[test]
fn cerebras_configured_active_model_wins_before_family_inference() {
let providers = HashMap::from([(
"cerebras".to_string(),
typed_entry("cerebras", Some("gpt-oss-120b")),
)]);
assert_eq!(
resolve_model_switch(&providers, "cerebras", "gpt-oss-120b"),
ModelSwitch::Keep,
);
}
#[test]
fn cerebras_active_pin_wins_when_another_provider_pins_the_same_model() {
let providers = HashMap::from([
(
"cerebras".to_string(),
typed_entry("cerebras", Some("gpt-oss-120b")),
),
(
"openai".to_string(),
typed_entry("openai", Some("gpt-oss-120b")),
),
]);
assert_eq!(
resolve_model_switch(&providers, "cerebras", "gpt-oss-120b"),
ModelSwitch::Keep,
);
}
#[test]
fn opencode_configured_active_gateway_model_also_keeps_its_client() {
let providers = HashMap::from([(
"opencode".to_string(),
typed_entry("opencode", Some("gpt-oss-120b")),
)]);
assert_eq!(
resolve_model_switch(&providers, "opencode", "gpt-oss-120b"),
ModelSwitch::Keep,
);
}
}
#[cfg(test)]
mod ro8g_tests {
use super::*;
#[test]
fn anthropic_oauth_token_is_not_an_api_key_fallback() {
assert!(
!provider_env_var_fallbacks(ProviderKind::Anthropic).contains(&"ANTHROPIC_OAUTH_TOKEN"),
"ANTHROPIC_OAUTH_TOKEN must not be treated as an API key"
);
}
}