use crate::provider::ProviderError;
pub const DEFAULT_MODEL: &str = "claude-sonnet-5";
pub const DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
pub const ANTHROPIC_VERSION: &str = "2023-06-01";
pub const INTERLEAVED_THINKING_BETA: &str = "interleaved-thinking-2025-05-14";
pub const EFFORT_BETA: &str = "effort-2025-11-24";
pub const DEFAULT_MAX_TOKENS_CAP: u32 = 8000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApiBackend {
Native,
OpenRouter,
Proxy,
}
impl ApiBackend {
#[must_use]
pub fn detect(base_url: &str) -> Self {
let host = host_of(base_url);
if host == "openrouter.ai" || host.ends_with(".openrouter.ai") {
ApiBackend::OpenRouter
} else if host == "api.anthropic.com" {
ApiBackend::Native
} else {
ApiBackend::Proxy
}
}
}
fn host_of(url: &str) -> &str {
let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
let end = after_scheme
.find(['/', ':', '?', '#'])
.unwrap_or(after_scheme.len());
&after_scheme[..end]
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthScheme {
ApiKey(String),
Bearer(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DeveloperRendering {
#[default]
SystemReminder,
MidConversationSystemBeta,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ReasoningEncoding {
#[default]
Budget,
EffortAdaptive,
}
#[derive(Debug, Clone)]
pub struct ModelConfig {
pub model: String,
pub base_url: String,
pub api_backend: ApiBackend,
pub auth: AuthScheme,
pub anthropic_version: String,
pub betas: Vec<String>,
pub extra_headers: Vec<(String, String)>,
pub max_tokens_cap: u32,
pub developer_rendering: DeveloperRendering,
pub reasoning_encoding: ReasoningEncoding,
pub provider_prefs: Option<serde_json::Value>,
}
impl ModelConfig {
#[must_use]
pub fn new(
model: impl Into<String>,
base_url: impl Into<String>,
key: impl Into<String>,
) -> Self {
let base_url: String = base_url.into();
let base_url = base_url.trim_end_matches('/').to_string();
let api_backend = ApiBackend::detect(&base_url);
let key = key.into();
let auth = match api_backend {
ApiBackend::Native => AuthScheme::ApiKey(key),
ApiBackend::OpenRouter | ApiBackend::Proxy => AuthScheme::Bearer(key),
};
Self {
model: model.into(),
base_url,
api_backend,
auth,
anthropic_version: ANTHROPIC_VERSION.to_string(),
betas: vec![INTERLEAVED_THINKING_BETA.to_string()],
extra_headers: Vec::new(),
max_tokens_cap: DEFAULT_MAX_TOKENS_CAP,
developer_rendering: DeveloperRendering::default(),
reasoning_encoding: ReasoningEncoding::default(),
provider_prefs: None,
}
}
pub fn from_env() -> Result<Self, ProviderError> {
let key = std::env::var("LOCODE_API_KEY")
.ok()
.filter(|k| !k.is_empty())
.ok_or_else(|| ProviderError::Auth("LOCODE_API_KEY is not set".to_string()))?;
let base_url =
std::env::var("LOCODE_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string());
let model = std::env::var("LOCODE_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string());
Ok(Self::new(model, base_url, key))
}
#[must_use]
pub fn interleaved_thinking(&self) -> bool {
self.betas.iter().any(|b| b == INTERLEAVED_THINKING_BETA)
}
#[must_use]
pub fn effective_provider_prefs(&self) -> Option<serde_json::Value> {
if self.api_backend != ApiBackend::OpenRouter {
return None;
}
Some(self.provider_prefs.clone().unwrap_or_else(|| {
serde_json::json!({
"ignore": ["amazon-bedrock"],
"allow_fallbacks": false,
"require_parameters": true,
})
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backend_detection() {
assert_eq!(
ApiBackend::detect("https://api.anthropic.com"),
ApiBackend::Native
);
assert_eq!(
ApiBackend::detect("https://openrouter.ai/api"),
ApiBackend::OpenRouter
);
assert_eq!(
ApiBackend::detect("https://gateway.openrouter.ai/api"),
ApiBackend::OpenRouter
);
assert_eq!(
ApiBackend::detect("https://my-proxy.example.com:8080/v1"),
ApiBackend::Proxy
);
assert_eq!(
ApiBackend::detect("http://localhost:8080"),
ApiBackend::Proxy
);
}
#[test]
fn auth_scheme_follows_backend() {
let native = ModelConfig::new("m", "https://api.anthropic.com", "k1");
assert_eq!(native.auth, AuthScheme::ApiKey("k1".to_string()));
assert_eq!(native.api_backend, ApiBackend::Native);
let or = ModelConfig::new("m", "https://openrouter.ai/api/", "k2");
assert_eq!(or.auth, AuthScheme::Bearer("k2".to_string()));
assert_eq!(or.api_backend, ApiBackend::OpenRouter);
assert_eq!(
or.base_url, "https://openrouter.ai/api",
"trailing slash trimmed"
);
}
#[test]
fn interleaved_thinking_default_on_and_removable() {
let mut cfg = ModelConfig::new("m", DEFAULT_BASE_URL, "k");
assert!(cfg.interleaved_thinking(), "beta on by default (plan §9.3)");
cfg.betas.clear();
assert!(!cfg.interleaved_thinking());
}
#[test]
fn provider_prefs_only_for_openrouter() {
let native = ModelConfig::new("m", DEFAULT_BASE_URL, "k");
assert!(native.effective_provider_prefs().is_none());
let or = ModelConfig::new("m", "https://openrouter.ai/api", "k");
let prefs = or.effective_provider_prefs().expect("default trio");
assert_eq!(prefs["require_parameters"], true);
assert_eq!(prefs["allow_fallbacks"], false);
assert_eq!(prefs["ignore"][0], "amazon-bedrock", "Vertex stays allowed");
let mut custom = ModelConfig::new("m", "https://openrouter.ai/api", "k");
custom.provider_prefs = Some(serde_json::json!({"only": ["anthropic"]}));
assert_eq!(
custom.effective_provider_prefs().expect("custom")["only"][0],
"anthropic"
);
}
}