Skip to main content

rig_core/providers/openai/responses_api/
wire.rs

1//! Responses endpoint encoding and observation for configured OpenAI dialects.
2//!
3//! ```
4//! use rig_core::providers::openai::OpenAI;
5//! let wire = OpenAI::new("key").responses("gpt-5.2");
6//! ```
7
8use crate::catalog::ModelFacts;
9use crate::completion::{self, ProviderCapabilities};
10use crate::error::EncodeError;
11use crate::json_utils::Lenient;
12use crate::observe::ObservedError;
13use crate::operation::Completion;
14use crate::providers::openai::wire::OpenAIConfig;
15pub(crate) use crate::providers::openai::wire::ResponsesContract;
16use crate::wire::{
17    AdapterVerdict, Capabilities, Descriptor, Encoded, Framing, Mode, ObservationSink, Wire,
18};
19use serde::{Deserialize, Serialize};
20use serde_json::{Map, Value};
21
22use super::streaming::ResponsesDecoder;
23use super::{ResponsesToolDefinition, SystemInstructionsPlacement};
24
25/// The Responses wire: `POST /responses`, SSE when streamed.
26#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
27pub struct Responses {
28    /// The provider this wire speaks to.
29    pub provider: OpenAIConfig,
30    /// The model to address.
31    pub model: String,
32    /// Tools added to every request from this wire.
33    pub tools: Vec<ResponsesToolDefinition>,
34    /// Whether Rig-generated tools request OpenAI's strict validation.
35    pub strict_tools: bool,
36    /// Where this wire puts Rig's system instructions. Defaults to the
37    /// dialect's placement.
38    pub system_instructions: SystemInstructionsPlacement,
39    /// The model facts the encoder reads and replies are priced by.
40    #[serde(skip)]
41    pub facts: ModelFacts,
42}
43
44impl Responses {
45    /// Create a wire with the provider's instruction placement and dialect's
46    /// strict-tool default, without additional tools.
47    pub fn new(provider: OpenAIConfig, model: impl Into<String>) -> Self {
48        Self {
49            system_instructions: provider.system_instructions_placement(),
50            strict_tools: provider.dialect.quirks.responses.strict_tools_by_default,
51            provider,
52            model: model.into(),
53            tools: Vec::new(),
54            facts: ModelFacts::default(),
55        }
56    }
57
58    /// The same wire, encoding with `facts` and pricing its replies by
59    /// them.
60    pub fn with_facts(mut self, facts: ModelFacts) -> Self {
61        self.facts = facts;
62        self
63    }
64
65    /// Sanitize function schemas for strict mode and send `strict: true`.
66    pub fn with_strict_tools(mut self) -> Self {
67        self.strict_tools = true;
68        self
69    }
70
71    /// Add a tool to every request from this wire.
72    pub fn with_tool(mut self, tool: impl Into<ResponsesToolDefinition>) -> Self {
73        self.tools.push(tool.into());
74        self
75    }
76
77    /// Add tools to every request from this wire.
78    pub fn with_tools<I, Tool>(mut self, tools: I) -> Self
79    where
80        I: IntoIterator<Item = Tool>,
81        Tool: Into<ResponsesToolDefinition>,
82    {
83        self.tools.extend(tools.into_iter().map(Into::into));
84        self
85    }
86
87    /// Put Rig's system instructions somewhere other than the dialect's
88    /// default placement.
89    pub fn with_system_instructions_placement(
90        mut self,
91        placement: SystemInstructionsPlacement,
92    ) -> Self {
93        self.system_instructions = placement;
94        self
95    }
96
97    /// Send Rig's system instructions as `system` messages in `input`, for a
98    /// backend that rejects or ignores top-level `instructions`.
99    pub fn with_system_instructions_as_messages(self) -> Self {
100        self.with_system_instructions_placement(SystemInstructionsPlacement::InputSystemMessages)
101    }
102}
103
104impl Wire for Responses {
105    type Op = Completion;
106    type Payload = crate::wire::Encoded;
107    type Frame = crate::wire::WireFrame;
108    type Event = super::streaming::ResponsesEvent;
109    type Decoder<'id> = ResponsesDecoder;
110    type Reassembler = super::streaming::document::Response;
111
112    /// The xAI contract does not compose native structured output with tools.
113    fn describe(&self) -> Descriptor<'_> {
114        Descriptor::new(self.provider.dialect.name)
115            .model(self.model.as_str())
116            .facts(&self.facts)
117            .capabilities(Capabilities::completion(
118                ProviderCapabilities::default().with_native_output_tool_composition(
119                    self.provider.dialect.quirks.responses.contract != ResponsesContract::Xai,
120                ),
121            ))
122            .replay(self)
123    }
124
125    fn encode(
126        &self,
127        request: completion::CompletionRequest,
128        mode: Mode,
129    ) -> Result<Encoded, EncodeError> {
130        let quirks = &self.provider.dialect.quirks.responses;
131        // The codex gateway only ever answers with an event stream, and
132        // names no content type on it. It is asked for one whatever the
133        // caller wanted: the reply is framed the same way either way, and
134        // the driver folds it.
135        let codex = quirks.contract == ResponsesContract::Codex;
136        let streaming = matches!(mode, Mode::Streaming) || codex;
137        let builder = self.provider.completion_headers(
138            &request,
139            http::Request::post(self.provider.uri(quirks.path, None)),
140        );
141        let mode = if streaming {
142            Mode::Streaming
143        } else {
144            Mode::Unary
145        };
146        let body = self.responses_request(&request, super::Delivery::Http(mode))?;
147        crate::providers::internal::trace_json(
148            crate::providers::internal::LogTarget::Completions,
149            "Responses completion request",
150            &body,
151        );
152        let request = builder
153            .header(http::header::CONTENT_TYPE, "application/json")
154            .body(body.into_body())?;
155
156        let framing = if streaming {
157            Framing::Sse
158        } else {
159            Framing::Whole
160        };
161        let encoded = Encoded::new(request, framing)
162            .with_request_id_header(self.provider.dialect.request_id_header)
163            .with_route(Some(self.provider.dialect.quirks.responses.path))
164            .with_projection(project_payload);
165        Ok(if codex {
166            encoded.with_relaxed_content_type()
167        } else {
168            encoded
169        })
170    }
171
172    fn decoder<'id>(&self) -> Self::Decoder<'id> {
173        ResponsesDecoder::new()
174    }
175}
176
177impl crate::completion::ReplayTarget for Responses {
178    /// Section 6.3 of the typed-options design, by dialect.
179    fn map_options(
180        &self,
181        request: &crate::completion::CompletionRequest,
182        fields: crate::completion::options::OptionFields<'_>,
183    ) -> crate::completion::options::OptionMap {
184        crate::providers::openai::options::responses_options(self, request, fields)
185    }
186
187    fn facts(&self) -> Option<&ModelFacts> {
188        Some(&self.facts)
189    }
190
191    fn api(&self) -> crate::message::Api {
192        crate::message::Api::from_static("openai.responses")
193    }
194
195    fn provider(&self) -> &str {
196        self.provider.dialect.name
197    }
198
199    fn model(&self) -> &str {
200        &self.model
201    }
202
203    /// The wire's own tools are declared beside the request's.
204    fn declares_tools(&self, request: &completion::CompletionRequest) -> bool {
205        !self.tools.is_empty() || crate::completion::history::declares_tools(request)
206    }
207
208    /// A call item names its id in `call_id`.
209    fn call_id_slot(&self) -> Option<&'static str> {
210        Some("/call_id")
211    }
212
213    /// An edited block keeps its item's `id` and `type`, a message its
214    /// `phase`, and reasoning its ciphertext, so the items after it stay
215    /// paired.
216    fn identity(&self, item: &Value) -> Map<String, Value> {
217        let keys: &[&str] = match item.str("type") {
218            Some("message") => &["type", "id", "phase"],
219            Some("reasoning") => &["type", "id", "encrypted_content"],
220            Some("function_call" | "custom_tool_call") => &["type", "id"],
221            _ => &[],
222        };
223        keys.iter()
224            .filter_map(|key| Some(((*key).to_owned(), item.get(*key)?.clone())))
225            .collect()
226    }
227
228    /// A reasoning item goes only with the item it preceded.
229    fn needs_next(&self, item: &Value) -> bool {
230        item.str("type") == Some("reasoning")
231    }
232
233    /// Responses reads images in user input and in function outputs, never
234    /// in assistant messages, and only on a model with vision input. Every
235    /// documented model calls tools except `o1-mini` and `o1-preview`.
236    fn accepts(&self, model: &str) -> crate::completion::Accepts {
237        let images = reads_images(
238            &self.facts,
239            self.provider.dialect.quirks.responses.contract,
240            model,
241        );
242        let model = model.to_ascii_lowercase();
243        crate::completion::Accepts {
244            user_images: images,
245            assistant_images: false,
246            tool_result_images: images,
247            tools: !(model.starts_with("o1-mini") || model.starts_with("o1-preview")),
248            // A reasoning item replays only as the provider made it.
249            foreign_reasoning: false,
250        }
251    }
252
253    /// Responses carries user and tool-result images as data URLs, URLs or
254    /// file ids, documents as files or text, and no audio, video or
255    /// assistant image. File ids need a dialect that resolves them, and
256    /// xAI's `input_image` takes none.
257    fn encodes(&self, _model: &str, media: crate::completion::Media<'_>) -> bool {
258        use crate::completion::{Media, Place};
259        use crate::message::{DocumentData, DocumentSourceKind as Source};
260        let quirks = &self.provider.dialect.quirks;
261        let file_ids = quirks.accepts_file_ids;
262        match media {
263            Media::Image(_, Place::Assistant) | Media::Audio(_) | Media::Video(_) => false,
264            Media::Image(image, _) => {
265                super::image_part(image).is_some()
266                    && (!matches!(image.data, Source::FileId(_))
267                        || file_ids && quirks.responses.contract != ResponsesContract::Xai)
268            }
269            Media::Document(document) => {
270                super::document_part(document).is_some()
271                    && (file_ids || !matches!(document.data, DocumentData::File(Source::FileId(_))))
272            }
273        }
274    }
275
276    /// A request naming `previous_response_id` or a `conversation`
277    /// continues state the provider stores, which holds the calls its first
278    /// results answer.
279    fn continues_stored(&self, request: &completion::CompletionRequest) -> bool {
280        ["previous_response_id", "conversation"].iter().any(|key| {
281            crate::completion::options::param(self, request, key)
282                .is_some_and(|value| !value.is_null())
283        })
284    }
285
286    /// Characters outside `[a-zA-Z0-9_-]` become `_`, and the id is cut to
287    /// 64 characters and loses its trailing `_`.
288    fn normalize_tool_call_id(
289        &self,
290        id: &str,
291        _model: &str,
292        _source: Option<&crate::message::Origin>,
293    ) -> String {
294        use crate::providers::internal::wire_ids::{legal_call_id, short_hash};
295        let legal = legal_call_id(id, 64);
296        match legal.trim_end_matches('_') {
297            // An id with nothing legal in it still names its call.
298            "" => short_hash(id),
299            trimmed => trimmed.to_owned(),
300        }
301    }
302}
303
304/// Whether `model` reads images, past a `vendor/` prefix: its catalog
305/// entry's input, or for a model the catalog does not list its vendor's
306/// documented text-only models. An unknown model reads them.
307fn reads_images(facts: &ModelFacts, contract: ResponsesContract, model: &str) -> bool {
308    let model = model.rsplit('/').next().unwrap_or_default();
309    match contract {
310        ResponsesContract::Xai => facts.reads_images_or(
311            crate::providers::xai::DIALECT.name,
312            model,
313            crate::providers::xai::reads_images,
314        ),
315        ResponsesContract::OpenAi | ResponsesContract::Codex => facts.reads_images_or(
316            crate::providers::openai::wire::OPENAI.name,
317            model,
318            crate::providers::openai::reads_images,
319        ),
320    }
321}
322
323/// The facts a Responses payload carries before normalization discards
324/// them: the verdict, the model, the response id and any error envelope.
325///
326/// The unary reply is the response object itself; a stream event wraps that
327/// object under `response` (`response.created`, `.completed`, `.failed`,
328/// `.incomplete`) or, for `error`, carries the envelope's fields itself.
329pub(crate) fn project_payload(payload: &[u8], sink: &mut ObservationSink<'_>) {
330    let Ok(payload) = serde_json::from_slice::<Value>(payload) else {
331        return;
332    };
333    // An error event carries its envelope and nothing else.
334    if payload.str("type") != Some("error") {
335        project_verdict(&payload, sink);
336    }
337    if let Some(error) = ObservedError::of(&payload) {
338        error.emit(sink);
339    }
340}
341
342/// The verdict and response id a response object, or the stream event
343/// wrapping one, reports.
344fn project_verdict(payload: &Value, sink: &mut ObservationSink<'_>) {
345    let object = payload
346        .get("response")
347        .filter(|response| response.is_object())
348        .unwrap_or(payload);
349    // `status` is the provider's verdict; `in_progress` on a stream's
350    // opening event is not one yet, so it is left out of the projection.
351    let verdict = AdapterVerdict {
352        finish_reason: object
353            .str("status")
354            .filter(|status| *status != "in_progress" && *status != "queued")
355            .map(|value| sink.scrub(value)),
356        block_reason: None,
357        detail: object
358            .at("/incomplete_details/reason")
359            .and_then(Value::as_str)
360            .map(|value| sink.scrub(value)),
361        model: object.str("model").map(|value| sink.scrub(value)),
362    };
363    let response_id = object.str("id").map(|value| sink.scrub(value));
364    sink.provider(verdict, response_id);
365}
366
367#[cfg(test)]
368mod tests;