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    /// Whether the catalog prices this dialect's replies
179    /// ([`Quirks::catalog_priced`](crate::providers::openai::wire::Quirks::catalog_priced)).
180    fn catalog_priced(&self) -> bool {
181        self.provider.dialect.quirks.catalog_priced
182    }
183
184    /// Section 6.3 of the typed-options design, by dialect.
185    fn map_options(
186        &self,
187        request: &crate::completion::CompletionRequest,
188        fields: crate::completion::options::OptionFields<'_>,
189    ) -> crate::completion::options::OptionMap {
190        crate::providers::openai::options::responses_options(self, request, fields)
191    }
192
193    fn facts(&self) -> Option<&ModelFacts> {
194        Some(&self.facts)
195    }
196
197    fn api(&self) -> crate::message::Api {
198        crate::message::Api::from_static("openai.responses")
199    }
200
201    fn provider(&self) -> &str {
202        self.provider.dialect.name
203    }
204
205    fn model(&self) -> &str {
206        &self.model
207    }
208
209    /// The wire's own tools are declared beside the request's.
210    fn declares_tools(&self, request: &completion::CompletionRequest) -> bool {
211        !self.tools.is_empty() || crate::completion::history::declares_tools(request)
212    }
213
214    /// A call item names its id in `call_id`.
215    fn call_id_slot(&self) -> Option<&'static str> {
216        Some("/call_id")
217    }
218
219    fn blob_slots(&self) -> &'static [&'static str] {
220        &["/encrypted_content"]
221    }
222
223    /// An edited block keeps its item's `id` and `type`, a message its
224    /// `phase`, and reasoning its ciphertext, so the items after it stay
225    /// paired.
226    fn identity(&self, item: &Value) -> Map<String, Value> {
227        let keys: &[&str] = match item.str("type") {
228            Some("message") => &["type", "id", "phase"],
229            Some("reasoning") => &["type", "id", "encrypted_content"],
230            Some("function_call" | "custom_tool_call") => &["type", "id"],
231            _ => &[],
232        };
233        keys.iter()
234            .filter_map(|key| Some(((*key).to_owned(), item.get(*key)?.clone())))
235            .collect()
236    }
237
238    /// A reasoning item goes only with the item it preceded.
239    fn needs_next(&self, item: &Value) -> bool {
240        item.str("type") == Some("reasoning")
241    }
242
243    /// Responses reads images in user input and in function outputs, never
244    /// in assistant messages, and only on a model with vision input. Every
245    /// documented model calls tools except `o1-mini` and `o1-preview`.
246    fn accepts(&self, model: &str) -> crate::completion::Accepts {
247        let images = reads_images(
248            &self.facts,
249            self.provider.dialect.quirks.responses.contract,
250            model,
251        );
252        let model = model.to_ascii_lowercase();
253        crate::completion::Accepts {
254            user_images: images,
255            assistant_images: false,
256            tool_result_images: images,
257            tools: !(model.starts_with("o1-mini") || model.starts_with("o1-preview")),
258            // A reasoning item replays only as the provider made it.
259            foreign_reasoning: false,
260        }
261    }
262
263    /// Responses carries user and tool-result images as data URLs, URLs or
264    /// file ids, documents as files or text, and no audio, video or
265    /// assistant image. File ids need a dialect that resolves them, and
266    /// xAI's `input_image` takes none.
267    fn encodes(&self, _model: &str, media: crate::completion::Media<'_>) -> bool {
268        use crate::completion::{Media, Place};
269        use crate::message::{DocumentData, DocumentSourceKind as Source};
270        let quirks = &self.provider.dialect.quirks;
271        let file_ids = quirks.accepts_file_ids;
272        match media {
273            Media::Image(_, Place::Assistant) | Media::Audio(_) | Media::Video(_) => false,
274            Media::Image(image, _) => {
275                super::image_part(image).is_some()
276                    && (!matches!(image.data, Source::FileId(_))
277                        || file_ids && quirks.responses.contract != ResponsesContract::Xai)
278            }
279            Media::Document(document) => {
280                super::document_part(document).is_some()
281                    && (file_ids || !matches!(document.data, DocumentData::File(Source::FileId(_))))
282            }
283        }
284    }
285
286    /// A request naming `previous_response_id` or a `conversation`
287    /// continues state the provider stores, and one with caller-built
288    /// `input` items state the caller holds. Either holds the calls its
289    /// first results answer.
290    fn continues_stored(&self, request: &completion::CompletionRequest) -> bool {
291        ["previous_response_id", "conversation", "input"]
292            .iter()
293            .any(|key| {
294                crate::completion::options::param(self, request, key)
295                    .is_some_and(|value| !value.is_null())
296            })
297    }
298
299    /// Characters outside `[a-zA-Z0-9_-]` become `_`, and the id is cut to
300    /// 64 characters and loses its trailing `_`.
301    fn normalize_tool_call_id(
302        &self,
303        id: &str,
304        _model: &str,
305        _source: Option<&crate::message::Origin>,
306    ) -> String {
307        use crate::providers::internal::wire_ids::{legal_call_id, short_hash};
308        let legal = legal_call_id(id, 64);
309        match legal.trim_end_matches('_') {
310            // An id with nothing legal in it still names its call.
311            "" => short_hash(id),
312            trimmed => trimmed.to_owned(),
313        }
314    }
315}
316
317/// Whether `model` reads images, past a `vendor/` prefix: its catalog
318/// entry's input, or for a model the catalog does not list its vendor's
319/// documented text-only models. An unknown model reads them.
320fn reads_images(facts: &ModelFacts, contract: ResponsesContract, model: &str) -> bool {
321    let model = model.rsplit('/').next().unwrap_or_default();
322    match contract {
323        ResponsesContract::Xai => facts.reads_images_or(
324            crate::providers::xai::DIALECT.name,
325            model,
326            crate::providers::xai::reads_images,
327        ),
328        ResponsesContract::OpenAi | ResponsesContract::Codex => facts.reads_images_or(
329            crate::providers::openai::wire::OPENAI.name,
330            model,
331            crate::providers::openai::reads_images,
332        ),
333    }
334}
335
336/// The facts a Responses payload carries before normalization discards
337/// them: the verdict, the model, the response id and any error envelope.
338///
339/// The unary reply is the response object itself; a stream event wraps that
340/// object under `response` (`response.created`, `.completed`, `.failed`,
341/// `.incomplete`) or, for `error`, carries the envelope's fields itself.
342pub(crate) fn project_payload(payload: &[u8], sink: &mut ObservationSink<'_>) {
343    let Ok(payload) = serde_json::from_slice::<Value>(payload) else {
344        return;
345    };
346    // An error event carries its envelope and nothing else.
347    if payload.str("type") != Some("error") {
348        project_verdict(&payload, sink);
349    }
350    if let Some(error) = ObservedError::of(&payload) {
351        error.emit(sink);
352    }
353}
354
355/// The verdict and response id a response object, or the stream event
356/// wrapping one, reports.
357fn project_verdict(payload: &Value, sink: &mut ObservationSink<'_>) {
358    let object = payload
359        .get("response")
360        .filter(|response| response.is_object())
361        .unwrap_or(payload);
362    // `status` is the provider's verdict; `in_progress` on a stream's
363    // opening event is not one yet, so it is left out of the projection.
364    let verdict = AdapterVerdict {
365        finish_reason: object
366            .str("status")
367            .filter(|status| *status != "in_progress" && *status != "queued")
368            .map(|value| sink.scrub(value)),
369        block_reason: None,
370        detail: object
371            .at("/incomplete_details/reason")
372            .and_then(Value::as_str)
373            .map(|value| sink.scrub(value)),
374        model: object.str("model").map(|value| sink.scrub(value)),
375    };
376    let response_id = object.str("id").map(|value| sink.scrub(value));
377    sink.provider(verdict, response_id);
378}
379
380#[cfg(test)]
381mod tests;