Skip to main content

locode_provider/openai/
mod.rs

1//! The OpenAI wire family (Task 18): shared config record, backend detection,
2//! and error classification for the Responses wire (and the deferred Chat
3//! Completions wire, Task 17).
4//!
5//! Design: `tasks/plans/task-18-openai-responses-wire.md` (+ addenda A.1–A.6).
6//! Auth is **always Bearer** for this family; the backend variants select
7//! quirks only (OpenRouter `provider` prefs injection).
8
9pub mod responses;
10
11use std::time::Duration;
12
13use crate::http::HttpFailure;
14use crate::provider::ProviderError;
15
16/// The default first-party endpoint.
17pub const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com";
18
19/// The default model (plan §A.5 Q1): the convenience default, mirroring the
20/// Anthropic wire. Namespaced per backend at resolution time.
21pub const DEFAULT_OPENAI_MODEL: &str = "gpt-5-mini";
22
23/// Endpoint family — quirks only; auth is always Bearer here.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum OpenAiBackend {
26    /// First-party `api.openai.com`.
27    Native,
28    /// OpenRouter's `/api/v1/responses` (beta; stateless-only).
29    OpenRouter,
30    /// Any other OpenAI-compatible gateway.
31    Proxy,
32}
33
34impl OpenAiBackend {
35    /// Detect the backend from a base URL (pinnable by setting the field).
36    #[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
49/// Extract the host portion of a URL without a URL crate.
50fn 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/// The per-model config record for the OpenAI wire family (plan §3.2 + §A.5).
59#[derive(Debug, Clone)]
60pub struct OpenAiModelConfig {
61    /// The model id (`gpt-…` native; `openai/…`/`x-ai/…` via OpenRouter).
62    pub model: String,
63    /// The endpoint base URL (the wire appends its request path).
64    pub base_url: String,
65    /// The endpoint family — quirks only (auth is always Bearer).
66    pub backend: OpenAiBackend,
67    /// The bearer credential.
68    pub bearer: String,
69    /// Clamp for `max_output_tokens` (which includes reasoning tokens).
70    pub max_tokens_cap: Option<u32>,
71    /// `reasoning.summary` request value. Default `Some("auto")` (plan §A.5
72    /// Q2: trace readability); `None` omits the field for score-only runs.
73    pub reasoning_summary: Option<String>,
74    /// `prompt_cache_key` — the facade passes the session id (plan §A.5 Q4;
75    /// codex's behavior; probe P6 confirmed harmless for xAI models).
76    pub prompt_cache_key: Option<String>,
77    /// Whether the target model/provider accepts `type:"custom"` tools. Manual
78    /// flag (plan §A.5 Q5 — no model-id sniffing): xAI 422s them (probe P3);
79    /// `false` degrades freeform specs to the `{"input": string}` function
80    /// framing on both the tools array and the call/replay items.
81    pub custom_tools_supported: bool,
82    /// Where `Role::System` goes: codex's top-level `instructions` (default)
83    /// or grok's in-stream `role:"system"` message.
84    pub system_placement: SystemPlacement,
85    /// Extra headers appended to every request.
86    pub extra_headers: Vec<(String, String)>,
87    /// OpenRouter routing preferences; `None` → the default pair
88    /// (`allow_fallbacks:false, require_parameters:true`) on OpenRouter.
89    pub provider_prefs: Option<serde_json::Value>,
90}
91
92/// Where the hoisted System prompt is placed on the Responses wire.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
94pub enum SystemPlacement {
95    /// Top-level `instructions` (codex's shape; probes P4/P5 confirm it works
96    /// through OpenRouter for OpenAI and xAI models). The default.
97    #[default]
98    Instructions,
99    /// A `role:"system"` input message (grok's shape) — the escape hatch.
100    InputMessage,
101}
102
103impl OpenAiModelConfig {
104    /// Build a config, detecting the backend from `base_url`.
105    #[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    /// Resolve from the environment: `LOCODE_API_KEY` (required),
130    /// `LOCODE_BASE_URL` (default native). The model defaults to
131    /// [`DEFAULT_OPENAI_MODEL`] (namespaced `openai/…` on OpenRouter) — select
132    /// it via `--model`/settings (ADR-0024 §1.4), not env.
133    ///
134    /// # Errors
135    /// [`ProviderError::Auth`] when `LOCODE_API_KEY` is unset or empty.
136    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        // Model selection is settings/flag territory (ADR-0024 §1.4; the
144        // LOCODE_MODEL env override was removed 2026-07-24). Keep the default
145        // pair coherent per backend: OpenRouter slugs are vendor-namespaced.
146        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    /// The OpenRouter `provider` preferences to inject, if any: the configured
155    /// value, or `{allow_fallbacks:false}` on the OpenRouter backend.
156    ///
157    /// **`require_parameters` is deliberately absent** (live finding,
158    /// 2026-07-19): on the beta `/v1/responses` endpoint, `require_parameters:
159    /// true` combined with a `tools` array 404s with "No endpoints found that
160    /// can handle the requested parameters" — OpenRouter's parameter registry
161    /// does not count `tools` as supported there. The silent-param-drop
162    /// protection it provided on the Messages endpoint is covered here by the
163    /// live smokes instead.
164    #[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/// An OpenAI-family error body: OpenAI sends
178/// `{"error":{"message","type","param","code"}}` (`code` a string); OpenRouter
179/// sends `{"error":{"code": <number>, "message", "metadata"}}`. One shape
180/// tolerating both.
181#[derive(Debug, Clone, Default, serde::Deserialize)]
182pub struct OpenAiErrorBody {
183    /// The inner error object.
184    #[serde(default)]
185    pub error: OpenAiErrorDetail,
186}
187
188/// The inner error object of an [`OpenAiErrorBody`].
189#[derive(Debug, Clone, Default, serde::Deserialize)]
190pub struct OpenAiErrorDetail {
191    /// OpenAI's string code OR OpenRouter's numeric code.
192    #[serde(default)]
193    pub code: Option<serde_json::Value>,
194    /// The error type slug (OpenAI).
195    #[serde(rename = "type", default)]
196    pub r#type: Option<String>,
197    /// The human-readable message.
198    #[serde(default)]
199    pub message: String,
200}
201
202impl OpenAiErrorDetail {
203    /// The string form of `code`, if it is a string.
204    #[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/// Classify an OpenAI-family HTTP error (plan §4.7).
211///
212/// The family trap: **quota exhaustion arrives as a 429** (`insufficient_quota`
213/// / "exceeded your current quota") — blind 429-retry hammers a dead account,
214/// so quota is matched before rate limiting. OpenRouter's 402 is quota too.
215#[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        // No x-should-retry analog exists in this family.
249        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        // OpenAI string-code form.
303        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        // Plain 429 stays retryable-with-cap.
314        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        // OpenRouter numeric-code 402.
322        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}