Skip to main content

locode_provider/openai/responses/
mod.rs

1//! The OpenAI Responses wire — the second live `Provider` (Task 18).
2//!
3//! `api_schema() = "openai-responses"`, `POST {base_url}/v1/responses`,
4//! non-streaming, always-Bearer, **stateless always** (`store:false`, no
5//! `previous_response_id`). Drives both OpenAI models (function + custom/
6//! grammar tools, encrypted-reasoning replay) and xAI grok models (function
7//! tools + encrypted reasoning; `custom_tools_supported=false` degrades
8//! freeform specs). Design: `tasks/plans/task-18-openai-responses-wire.md`.
9
10pub mod build;
11pub mod parse;
12pub mod wire;
13
14use std::sync::Arc;
15
16use async_trait::async_trait;
17
18pub use build::{build_request, freeform_fallback_parameters, freeform_tool_names};
19pub use parse::response_to_completion;
20
21use crate::completion::Completion;
22use crate::http::{self, HttpFailure, RetryPolicy};
23use crate::openai::{OpenAiModelConfig, classify};
24use crate::provider::{Provider, ProviderError};
25use crate::repair::repair_pairing;
26use crate::request::ConversationRequest;
27
28/// The live OpenAI Responses `Provider` (non-streaming, stateless).
29pub struct OpenAiResponsesProvider {
30    http: reqwest::Client,
31    config: OpenAiModelConfig,
32    retry: RetryPolicy,
33}
34
35impl OpenAiResponsesProvider {
36    /// Build a provider from a resolved [`OpenAiModelConfig`].
37    ///
38    /// # Errors
39    /// [`ProviderError::Transport`] when the HTTP client cannot be constructed.
40    pub fn new(config: OpenAiModelConfig) -> Result<Self, ProviderError> {
41        Ok(Self {
42            http: http::build_http_client()?,
43            config,
44            retry: RetryPolicy::default(),
45        })
46    }
47
48    /// Build from the environment (`LOCODE_API_KEY` / `LOCODE_BASE_URL` /
49    /// `LOCODE_MODEL`).
50    ///
51    /// # Errors
52    /// [`ProviderError::Auth`] when the key is missing;
53    /// [`ProviderError::Transport`] when the client cannot be constructed.
54    pub fn from_env() -> Result<Self, ProviderError> {
55        Self::new(OpenAiModelConfig::from_env()?)
56    }
57
58    /// Override the transport retry policy.
59    #[must_use]
60    pub fn with_retry_policy(mut self, retry: RetryPolicy) -> Self {
61        self.retry = retry;
62        self
63    }
64
65    /// The active config (read-only view).
66    #[must_use]
67    pub fn config(&self) -> &OpenAiModelConfig {
68        &self.config
69    }
70
71    /// Mutable config access (the facade sets `prompt_cache_key` to the
72    /// session id, plan §A.5 Q4).
73    pub fn config_mut(&mut self) -> &mut OpenAiModelConfig {
74        &mut self.config
75    }
76
77    async fn send_once(
78        &self,
79        request: &wire::ResponsesRequest,
80        freeform_names: &std::collections::HashSet<String>,
81    ) -> Result<Completion, HttpFailure> {
82        let url = format!("{}/v1/responses", self.config.base_url);
83        let mut builder = self
84            .http
85            .post(&url)
86            .bearer_auth(&self.config.bearer)
87            .json(request);
88        for (name, value) in &self.config.extra_headers {
89            builder = builder.header(name, value);
90        }
91        let response = builder
92            .send()
93            .await
94            .map_err(|e| HttpFailure::transport(e.to_string()))?;
95
96        let status = response.status();
97        let retry_after = response
98            .headers()
99            .get(reqwest::header::RETRY_AFTER)
100            .and_then(|v| v.to_str().ok())
101            .and_then(http::parse_retry_after);
102
103        if status.is_success() {
104            let parsed: wire::ResponsesResponse = response
105                .json()
106                .await
107                .map_err(|e| HttpFailure::decode(format!("response body: {e}")))?;
108            return response_to_completion(parsed, freeform_names).map_err(|error| HttpFailure {
109                // A `failed` response's rate-limit/server-error codes ARE
110                // retryable — let the shared loop see them as such.
111                force_terminal: false,
112                retry_after,
113                error,
114            });
115        }
116
117        let text = response.text().await.unwrap_or_default();
118        let body: crate::openai::OpenAiErrorBody = match serde_json::from_str(&text) {
119            Ok(body) => body,
120            Err(_) => crate::openai::OpenAiErrorBody {
121                error: crate::openai::OpenAiErrorDetail {
122                    code: None,
123                    r#type: None,
124                    message: text,
125                },
126            },
127        };
128        Err(classify(status.as_u16(), retry_after, &body))
129    }
130}
131
132#[async_trait]
133impl Provider for OpenAiResponsesProvider {
134    #[allow(clippy::unnecessary_literal_bound)] // signature is the trait's
135    fn api_schema(&self) -> &str {
136        "openai-responses"
137    }
138
139    async fn complete(&self, request: &ConversationRequest) -> Result<Completion, ProviderError> {
140        // 1. Defensive transcript repair on a clone (ADR-0004 — same pass as
141        //    the anthropic wire; the engine runs the canonical one).
142        let mut repaired = request.clone();
143        let _ = repair_pairing(&mut repaired.messages);
144
145        // 2. Build (stateless, include, reasoning, tools + degradation).
146        let wire_request = build_request(&repaired, &self.config);
147        let freeform_names = freeform_tool_names(&repaired.tools);
148
149        // 3. Send with the shared transport retry.
150        let freeform_ref = &freeform_names;
151        http::run_with_retry(&self.retry, |_attempt| {
152            self.send_once(&wire_request, freeform_ref)
153        })
154        .await
155    }
156}
157
158/// The provider wrapped in [`Arc`] for the engine's `Arc<dyn Provider>` slot.
159#[must_use]
160pub fn into_provider(provider: OpenAiResponsesProvider) -> Arc<dyn Provider> {
161    Arc::new(provider)
162}