pub mod responses;
use std::time::Duration;
use crate::http::HttpFailure;
use crate::provider::ProviderError;
pub const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com";
pub const DEFAULT_OPENAI_MODEL: &str = "gpt-5-mini";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OpenAiBackend {
Native,
OpenRouter,
Proxy,
}
impl OpenAiBackend {
#[must_use]
pub fn detect(base_url: &str) -> Self {
let host = host_of(base_url);
if host == "openrouter.ai" || host.ends_with(".openrouter.ai") {
OpenAiBackend::OpenRouter
} else if host == "api.openai.com" {
OpenAiBackend::Native
} else {
OpenAiBackend::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)]
pub struct OpenAiModelConfig {
pub model: String,
pub base_url: String,
pub backend: OpenAiBackend,
pub bearer: String,
pub max_tokens_cap: u32,
pub reasoning_summary: Option<String>,
pub prompt_cache_key: Option<String>,
pub custom_tools_supported: bool,
pub system_placement: SystemPlacement,
pub extra_headers: Vec<(String, String)>,
pub provider_prefs: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SystemPlacement {
#[default]
Instructions,
InputMessage,
}
impl OpenAiModelConfig {
#[must_use]
pub fn new(
model: impl Into<String>,
base_url: impl Into<String>,
bearer: impl Into<String>,
) -> Self {
let base_url: String = base_url.into();
let base_url = base_url.trim_end_matches('/').to_string();
let backend = OpenAiBackend::detect(&base_url);
Self {
model: model.into(),
base_url,
backend,
bearer: bearer.into(),
max_tokens_cap: 32_000,
reasoning_summary: Some("auto".to_string()),
prompt_cache_key: None,
custom_tools_supported: true,
system_placement: SystemPlacement::default(),
extra_headers: Vec::new(),
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_OPENAI_BASE_URL.to_string());
let model = std::env::var("LOCODE_MODEL").unwrap_or_else(|_| {
if OpenAiBackend::detect(&base_url) == OpenAiBackend::OpenRouter {
format!("openai/{DEFAULT_OPENAI_MODEL}")
} else {
DEFAULT_OPENAI_MODEL.to_string()
}
});
Ok(Self::new(model, base_url, key))
}
#[must_use]
pub fn effective_provider_prefs(&self) -> Option<serde_json::Value> {
if self.backend != OpenAiBackend::OpenRouter {
return None;
}
Some(self.provider_prefs.clone().unwrap_or_else(|| {
serde_json::json!({
"allow_fallbacks": false,
})
}))
}
}
#[derive(Debug, Clone, Default, serde::Deserialize)]
pub struct OpenAiErrorBody {
#[serde(default)]
pub error: OpenAiErrorDetail,
}
#[derive(Debug, Clone, Default, serde::Deserialize)]
pub struct OpenAiErrorDetail {
#[serde(default)]
pub code: Option<serde_json::Value>,
#[serde(rename = "type", default)]
pub r#type: Option<String>,
#[serde(default)]
pub message: String,
}
impl OpenAiErrorDetail {
#[must_use]
pub fn code_str(&self) -> Option<&str> {
self.code.as_ref().and_then(|c| c.as_str())
}
}
#[must_use]
pub fn classify(status: u16, retry_after: Option<Duration>, body: &OpenAiErrorBody) -> HttpFailure {
let detail = &body.error;
let message = detail.message.as_str();
let lower = message.to_ascii_lowercase();
let code = detail.code_str().unwrap_or_default();
let type_slug = detail.r#type.as_deref().unwrap_or_default();
let error = if status == 401 || status == 403 {
ProviderError::Auth(format!("http {status}: {message}"))
} else if code == "insufficient_quota"
|| type_slug == "insufficient_quota"
|| lower.contains("exceeded your current quota")
|| status == 402
{
ProviderError::Quota
} else if status == 429 {
ProviderError::RateLimited { retry_after }
} else if status == 413
|| code == "context_length_exceeded"
|| lower.contains("context window")
|| lower.contains("maximum context length")
{
ProviderError::ContextOverflow
} else {
ProviderError::Api {
status,
message: message.to_string(),
}
};
HttpFailure {
error,
force_terminal: false,
retry_after,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn body(json: &str) -> OpenAiErrorBody {
serde_json::from_str(json).unwrap()
}
#[test]
fn backend_detection_and_default_prefs() {
assert_eq!(
OpenAiBackend::detect("https://api.openai.com"),
OpenAiBackend::Native
);
assert_eq!(
OpenAiBackend::detect("https://openrouter.ai/api"),
OpenAiBackend::OpenRouter
);
assert_eq!(
OpenAiBackend::detect("http://localhost:9999"),
OpenAiBackend::Proxy
);
let native = OpenAiModelConfig::new("gpt-5-mini", "https://api.openai.com", "k");
assert!(native.effective_provider_prefs().is_none());
let or = OpenAiModelConfig::new("openai/gpt-5-mini", "https://openrouter.ai/api/", "k");
assert_eq!(or.base_url, "https://openrouter.ai/api");
let prefs = or.effective_provider_prefs().expect("prefs");
assert_eq!(prefs["allow_fallbacks"], false);
assert!(
prefs.get("require_parameters").is_none(),
"404s /v1/responses when tools are present (live finding)"
);
}
#[test]
fn review_defaults_hold() {
let cfg = OpenAiModelConfig::new("m", DEFAULT_OPENAI_BASE_URL, "k");
assert_eq!(cfg.reasoning_summary.as_deref(), Some("auto"), "A.5 Q2");
assert!(
cfg.custom_tools_supported,
"A.5 Q5: manual flag, default on"
);
assert_eq!(cfg.prompt_cache_key, None, "facade injects the session id");
}
#[test]
fn quota_beats_rate_limit_on_429() {
let f = classify(
429,
None,
&body(
r#"{"error":{"message":"You exceeded your current quota","type":"insufficient_quota","code":"insufficient_quota"}}"#,
),
);
assert!(matches!(f.error, ProviderError::Quota), "the family trap");
assert!(!f.error.retryable());
let f = classify(
429,
Some(Duration::from_secs(3)),
&body(r#"{"error":{"message":"Rate limit reached","code":"rate_limit_exceeded"}}"#),
);
assert!(matches!(f.error, ProviderError::RateLimited { .. }));
let f = classify(
402,
None,
&body(r#"{"error":{"code":402,"message":"Insufficient credits"}}"#),
);
assert!(matches!(f.error, ProviderError::Quota));
}
#[test]
fn context_and_auth_and_5xx() {
let f = classify(
400,
None,
&body(
r#"{"error":{"message":"This model's maximum context length is 400000 tokens","code":"context_length_exceeded"}}"#,
),
);
assert!(matches!(f.error, ProviderError::ContextOverflow));
let f = classify(401, None, &body(r#"{"error":{"message":"bad key"}}"#));
assert!(matches!(f.error, ProviderError::Auth(_)));
let f = classify(503, None, &body(r#"{"error":{"message":"overloaded"}}"#));
assert!(f.error.retryable());
assert!(!f.force_terminal, "no x-should-retry in this family");
}
}