1pub mod responses;
10
11use std::time::Duration;
12
13use crate::http::HttpFailure;
14use crate::provider::ProviderError;
15
16pub const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com";
18
19pub const DEFAULT_OPENAI_MODEL: &str = "gpt-5-mini";
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum OpenAiBackend {
26 Native,
28 OpenRouter,
30 Proxy,
32}
33
34impl OpenAiBackend {
35 #[must_use]
37 pub fn detect(base_url: &str) -> Self {
38 let host = host_of(base_url);
39 if host == "openrouter.ai" || host.ends_with(".openrouter.ai") {
40 OpenAiBackend::OpenRouter
41 } else if host == "api.openai.com" {
42 OpenAiBackend::Native
43 } else {
44 OpenAiBackend::Proxy
45 }
46 }
47}
48
49fn host_of(url: &str) -> &str {
51 let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
52 let end = after_scheme
53 .find(['/', ':', '?', '#'])
54 .unwrap_or(after_scheme.len());
55 &after_scheme[..end]
56}
57
58#[derive(Debug, Clone)]
60pub struct OpenAiModelConfig {
61 pub model: String,
63 pub base_url: String,
65 pub backend: OpenAiBackend,
67 pub bearer: String,
69 pub max_tokens_cap: Option<u32>,
71 pub reasoning_summary: Option<String>,
74 pub prompt_cache_key: Option<String>,
77 pub custom_tools_supported: bool,
82 pub system_placement: SystemPlacement,
85 pub extra_headers: Vec<(String, String)>,
87 pub provider_prefs: Option<serde_json::Value>,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
94pub enum SystemPlacement {
95 #[default]
98 Instructions,
99 InputMessage,
101}
102
103impl OpenAiModelConfig {
104 #[must_use]
106 pub fn new(
107 model: impl Into<String>,
108 base_url: impl Into<String>,
109 bearer: impl Into<String>,
110 ) -> Self {
111 let base_url: String = base_url.into();
112 let base_url = base_url.trim_end_matches('/').to_string();
113 let backend = OpenAiBackend::detect(&base_url);
114 Self {
115 model: model.into(),
116 base_url,
117 backend,
118 bearer: bearer.into(),
119 max_tokens_cap: None,
120 reasoning_summary: Some("auto".to_string()),
121 prompt_cache_key: None,
122 custom_tools_supported: true,
123 system_placement: SystemPlacement::default(),
124 extra_headers: Vec::new(),
125 provider_prefs: None,
126 }
127 }
128
129 pub fn from_env() -> Result<Self, ProviderError> {
137 let key = std::env::var("LOCODE_API_KEY")
138 .ok()
139 .filter(|k| !k.is_empty())
140 .ok_or_else(|| ProviderError::Auth("LOCODE_API_KEY is not set".to_string()))?;
141 let base_url = std::env::var("LOCODE_BASE_URL")
142 .unwrap_or_else(|_| DEFAULT_OPENAI_BASE_URL.to_string());
143 let model = if OpenAiBackend::detect(&base_url) == OpenAiBackend::OpenRouter {
147 format!("openai/{DEFAULT_OPENAI_MODEL}")
148 } else {
149 DEFAULT_OPENAI_MODEL.to_string()
150 };
151 Ok(Self::new(model, base_url, key))
152 }
153
154 #[must_use]
165 pub fn effective_provider_prefs(&self) -> Option<serde_json::Value> {
166 if self.backend != OpenAiBackend::OpenRouter {
167 return None;
168 }
169 Some(self.provider_prefs.clone().unwrap_or_else(|| {
170 serde_json::json!({
171 "allow_fallbacks": false,
172 })
173 }))
174 }
175}
176
177#[derive(Debug, Clone, Default, serde::Deserialize)]
182pub struct OpenAiErrorBody {
183 #[serde(default)]
185 pub error: OpenAiErrorDetail,
186}
187
188#[derive(Debug, Clone, Default, serde::Deserialize)]
190pub struct OpenAiErrorDetail {
191 #[serde(default)]
193 pub code: Option<serde_json::Value>,
194 #[serde(rename = "type", default)]
196 pub r#type: Option<String>,
197 #[serde(default)]
199 pub message: String,
200}
201
202impl OpenAiErrorDetail {
203 #[must_use]
205 pub fn code_str(&self) -> Option<&str> {
206 self.code.as_ref().and_then(|c| c.as_str())
207 }
208}
209
210#[must_use]
216pub fn classify(status: u16, retry_after: Option<Duration>, body: &OpenAiErrorBody) -> HttpFailure {
217 let detail = &body.error;
218 let message = detail.message.as_str();
219 let lower = message.to_ascii_lowercase();
220 let code = detail.code_str().unwrap_or_default();
221 let type_slug = detail.r#type.as_deref().unwrap_or_default();
222
223 let error = if status == 401 || status == 403 {
224 ProviderError::Auth(format!("http {status}: {message}"))
225 } else if code == "insufficient_quota"
226 || type_slug == "insufficient_quota"
227 || lower.contains("exceeded your current quota")
228 || status == 402
229 {
230 ProviderError::Quota
231 } else if status == 429 {
232 ProviderError::RateLimited { retry_after }
233 } else if status == 413
234 || code == "context_length_exceeded"
235 || lower.contains("context window")
236 || lower.contains("maximum context length")
237 {
238 ProviderError::ContextOverflow
239 } else {
240 ProviderError::Api {
241 status,
242 message: message.to_string(),
243 }
244 };
245
246 HttpFailure {
247 error,
248 force_terminal: false,
250 retry_after,
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 fn body(json: &str) -> OpenAiErrorBody {
259 serde_json::from_str(json).unwrap()
260 }
261
262 #[test]
263 fn backend_detection_and_default_prefs() {
264 assert_eq!(
265 OpenAiBackend::detect("https://api.openai.com"),
266 OpenAiBackend::Native
267 );
268 assert_eq!(
269 OpenAiBackend::detect("https://openrouter.ai/api"),
270 OpenAiBackend::OpenRouter
271 );
272 assert_eq!(
273 OpenAiBackend::detect("http://localhost:9999"),
274 OpenAiBackend::Proxy
275 );
276
277 let native = OpenAiModelConfig::new("gpt-5-mini", "https://api.openai.com", "k");
278 assert!(native.effective_provider_prefs().is_none());
279 let or = OpenAiModelConfig::new("openai/gpt-5-mini", "https://openrouter.ai/api/", "k");
280 assert_eq!(or.base_url, "https://openrouter.ai/api");
281 let prefs = or.effective_provider_prefs().expect("prefs");
282 assert_eq!(prefs["allow_fallbacks"], false);
283 assert!(
284 prefs.get("require_parameters").is_none(),
285 "404s /v1/responses when tools are present (live finding)"
286 );
287 }
288
289 #[test]
290 fn review_defaults_hold() {
291 let cfg = OpenAiModelConfig::new("m", DEFAULT_OPENAI_BASE_URL, "k");
292 assert_eq!(cfg.reasoning_summary.as_deref(), Some("auto"), "A.5 Q2");
293 assert!(
294 cfg.custom_tools_supported,
295 "A.5 Q5: manual flag, default on"
296 );
297 assert_eq!(cfg.prompt_cache_key, None, "facade injects the session id");
298 }
299
300 #[test]
301 fn quota_beats_rate_limit_on_429() {
302 let f = classify(
304 429,
305 None,
306 &body(
307 r#"{"error":{"message":"You exceeded your current quota","type":"insufficient_quota","code":"insufficient_quota"}}"#,
308 ),
309 );
310 assert!(matches!(f.error, ProviderError::Quota), "the family trap");
311 assert!(!f.error.retryable());
312
313 let f = classify(
315 429,
316 Some(Duration::from_secs(3)),
317 &body(r#"{"error":{"message":"Rate limit reached","code":"rate_limit_exceeded"}}"#),
318 );
319 assert!(matches!(f.error, ProviderError::RateLimited { .. }));
320
321 let f = classify(
323 402,
324 None,
325 &body(r#"{"error":{"code":402,"message":"Insufficient credits"}}"#),
326 );
327 assert!(matches!(f.error, ProviderError::Quota));
328 }
329
330 #[test]
331 fn context_and_auth_and_5xx() {
332 let f = classify(
333 400,
334 None,
335 &body(
336 r#"{"error":{"message":"This model's maximum context length is 400000 tokens","code":"context_length_exceeded"}}"#,
337 ),
338 );
339 assert!(matches!(f.error, ProviderError::ContextOverflow));
340
341 let f = classify(401, None, &body(r#"{"error":{"message":"bad key"}}"#));
342 assert!(matches!(f.error, ProviderError::Auth(_)));
343
344 let f = classify(503, None, &body(r#"{"error":{"message":"overloaded"}}"#));
345 assert!(f.error.retryable());
346 assert!(!f.force_terminal, "no x-should-retry in this family");
347 }
348}