Skip to main content

rig_core/providers/openai/responses_api/
mod.rs

1//! Request and response types for the OpenAI Responses API.
2//! Endpoint and dialect configuration lives in [`wire`].
3//!
4//! ```no_run
5//! use rig_core::providers::openai::{self, OpenAI};
6//!
7//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
8//! let model = OpenAI::from_env()?.responses(openai::GPT_5_2);
9//! # let _ = model;
10//! # Ok(())
11//! # }
12//! ```
13
14use crate::completion::history::Replay;
15use crate::completion::options::{
16    BaseInput, CacheRetention, FinalBody, RawAt, Rewrite, request_params,
17};
18use crate::error::EncodeError;
19use crate::json_utils;
20use crate::json_utils::Lenient;
21use crate::message::{
22    AssistantContent, Document, DocumentData, DocumentMediaType, DocumentSourceKind, Message,
23    MimeType, ToolResultContent, UserContent,
24};
25use crate::providers::internal::object;
26use crate::providers::internal::wire_ids::WireIds;
27use crate::wire::Mode;
28use crate::{completion, message};
29use serde::{Deserialize, Serialize, Serializer};
30use serde_json::{Map, Value, json};
31use std::sync::Arc;
32
33pub mod streaming;
34#[cfg(feature = "websocket")]
35#[cfg_attr(docsrs, doc(cfg(feature = "websocket")))]
36pub mod websocket;
37pub mod wire;
38
39/// The `input_image` part `image` goes as: a data URL for typed base64
40/// data, its URL, or its file id. `None` for any other source, which
41/// [`wire::Responses`] tells the adapter it does not carry.
42fn image_part(image: &message::Image) -> Option<Value> {
43    let (key, source) = match &image.data {
44        DocumentSourceKind::Base64(data) => (
45            "image_url",
46            format!(
47                "data:{};base64,{data}",
48                image.media_type.as_ref()?.to_mime_type()
49            ),
50        ),
51        DocumentSourceKind::Url(url) => ("image_url", url.clone()),
52        DocumentSourceKind::FileId(file_id) => ("file_id", file_id.clone()),
53        _ => return None,
54    };
55    let mut part =
56        json!({"type": "input_image", "detail": image.detail.clone().unwrap_or_default()});
57    set(&mut part, key, Value::String(source));
58    Some(part)
59}
60
61/// The content part `document` goes as: an `input_file` for a file id, a URL
62/// or base64 PDF data, and its text for a text document. `None` for any other
63/// form; the adapter sends a text document's text instead.
64fn document_part(document: &Document) -> Option<Value> {
65    let source = match &document.data {
66        DocumentData::Text(text) => return Some(json!({"type": "input_text", "text": text})),
67        DocumentData::File(source) => source,
68    };
69    Some(match source {
70        DocumentSourceKind::FileId(file_id) => json!({"type": "input_file", "file_id": file_id}),
71        // `input_file` reads the type of a file it fetches itself.
72        DocumentSourceKind::Url(url) => json!({"type": "input_file", "file_url": url}),
73        DocumentSourceKind::Base64(data) if document.media_type == Some(DocumentMediaType::PDF) => {
74            object! {
75                "type": "input_file",
76                "file_data": format!("data:application/pdf;base64,{data}"),
77                "filename": "document.pdf",
78            }
79        }
80        _ => return None,
81    })
82}
83
84/// `value` with `key` set to `field`, when `value` is an object.
85fn set(value: &mut Value, key: &str, field: Value) {
86    if let Some(fields) = value.as_object_mut() {
87        fields.insert(key.to_owned(), field);
88    }
89}
90
91/// The refusal for a part a request was not prepared to carry.
92fn unsendable(part: &str) -> EncodeError {
93    EncodeError::request(format!(
94        "the Responses API cannot carry this {part}; prepare the request first"
95    ))
96}
97
98/// A tool result's `output`: one text as a string, anything else as its
99/// ordered parts.
100fn result_output(content: &[ToolResultContent]) -> Result<Value, EncodeError> {
101    let mut parts = content
102        .iter()
103        .map(|part| match part {
104            ToolResultContent::Text(text) => Ok(json!({"type": "input_text", "text": text.text})),
105            ToolResultContent::Json { value } => {
106                Ok(json!({"type": "input_text", "text": value.to_string()}))
107            }
108            ToolResultContent::Image(image) => {
109                image_part(image).ok_or_else(|| unsendable("tool-result image"))
110            }
111        })
112        .collect::<Result<Vec<_>, _>>()?;
113    Ok(match parts.as_mut_slice() {
114        [part] if part.str("type") == Some("input_text") => {
115            part.get_mut("text").map(Value::take).unwrap_or_default()
116        }
117        _ => Value::Array(parts),
118    })
119}
120
121/// The custom tools of a request: a call to one is a `custom_tool_call`,
122/// answered by a `custom_tool_call_output`, decided by name. A result
123/// answering a call the request sends takes that call's kind.
124struct Custom {
125    /// The custom tools the request declares.
126    tools: std::collections::HashSet<String>,
127    /// Whether each call id the request sends is a custom call.
128    calls: std::collections::HashMap<String, bool>,
129}
130
131/// The input items of `history` for `target`, which addresses `model`. A
132/// turn the target model produced keeps its provider items (the adapter
133/// cleared the rest): a current item verbatim with its call id spelled, an
134/// edited block rebuilt under its item's identity, and any other block
135/// rebuilt as another model's turn is: text as
136/// a completed output message under a synthetic id, and a call with no
137/// item id. Reasoning only its item can carry goes only with its identity.
138/// A `stateless` request resolves no stored item, so on an endpoint that
139/// returns ciphertext, reasoning without it is left out and the block after
140/// it is rebuilt with no item id, as another model's turn is. Elsewhere a
141/// reasoning item carries its own text and goes as any other item.
142fn input(
143    history: &[Arc<Message>],
144    target: &wire::Responses,
145    model: &str,
146    custom: &mut Custom,
147    stateless: bool,
148) -> Result<(Vec<Value>, Layout), EncodeError> {
149    let ids = WireIds::for_target(history, target, model);
150    let mut items = Vec::new();
151    let mut layout = Layout::default();
152    for (position, message) in history.iter().enumerate() {
153        if layout.after.is_empty() && !matches!(&**message, Message::System { .. }) {
154            layout.after.push(items.len());
155        }
156        let mut parts = Vec::new();
157        match &**message {
158            Message::System { content } => items.push(json!({
159                "type": "message",
160                "role": target.provider.dialect.quirks.responses.system_role,
161                "content": [{"type": "input_text", "text": content}],
162            })),
163            Message::User { content } => {
164                for part in content {
165                    parts.push(items.len());
166                    let part = match part {
167                        // Blank text says nothing, and some backends reject it.
168                        UserContent::Text(text) if text.text.trim().is_empty() => continue,
169                        UserContent::Text(text) => json!({"type": "input_text", "text": text.text}),
170                        // A function output has no error field: a failed
171                        // result says so in its text.
172                        UserContent::ToolResult(result) => {
173                            let call_id = ids.spell(&result.call);
174                            let output = result_output(&result.content)?;
175                            items.push(
176                                if custom.calls.get(&call_id).copied().unwrap_or_else(|| {
177                                    custom.tools.contains(result.name.as_str())
178                                }) {
179                                    object! {"type": "custom_tool_call_output", "call_id": call_id, "output": output}
180                                } else {
181                                    object! {"type": "function_call_output", "call_id": call_id, "output": output, "status": "completed"}
182                                },
183                            );
184                            continue;
185                        }
186                        UserContent::Image(image) => {
187                            image_part(image).ok_or_else(|| unsendable("image"))?
188                        }
189                        UserContent::Document(document) => {
190                            document_part(document).ok_or_else(|| unsendable("document"))?
191                        }
192                        UserContent::Audio(_) => return Err(unsendable("audio")),
193                        UserContent::Video(_) => return Err(unsendable("video")),
194                    };
195                    items.push(object! {"type": "message", "role": "user", "content": vec![part]});
196                }
197            }
198            Message::Assistant(turn) => {
199                let mut texts = 0usize;
200                let mut unpaired = false;
201                for block in &turn.content {
202                    parts.push(items.len());
203                    let mut replay = block.replay(target, &ids);
204                    if unpaired && !matches!(block, AssistantContent::Reasoning(_)) {
205                        unpaired = false;
206                        replay = Replay::Rebuild;
207                    }
208                    let ciphertext = |item: &Map<String, Value>| {
209                        item.get("encrypted_content")
210                            .and_then(Value::as_str)
211                            .is_some_and(|cipher| !cipher.is_empty())
212                    };
213                    let unresolvable = stateless
214                        && target
215                            .provider
216                            .dialect
217                            .quirks
218                            .responses
219                            .reasoning_ciphertext
220                        && matches!(block, AssistantContent::Reasoning(_))
221                        && match &replay {
222                            Replay::Item(item) => !item.as_object().is_some_and(ciphertext),
223                            Replay::Identity(identity) => !ciphertext(identity),
224                            Replay::Rebuild => false,
225                        };
226                    if unresolvable {
227                        unpaired = true;
228                        continue;
229                    }
230                    let identity = match replay {
231                        Replay::Item(item) => {
232                            if let Some(call_id) = item.str("call_id") {
233                                let kind = item.str("type");
234                                if matches!(kind, Some("custom_tool_call" | "function_call")) {
235                                    custom.calls.insert(
236                                        call_id.to_owned(),
237                                        kind == Some("custom_tool_call"),
238                                    );
239                                }
240                            }
241                            items.push(item.into_owned());
242                            continue;
243                        }
244                        Replay::Identity(identity) => identity,
245                        Replay::Rebuild => Map::new(),
246                    };
247                    let id = |prefix: &str| {
248                        identity
249                            .get("id")
250                            .and_then(Value::as_str)
251                            .filter(|id| !id.is_empty() && id.starts_with(prefix) && id.len() <= 64)
252                            .map(str::to_owned)
253                    };
254                    let item = match block {
255                        AssistantContent::Text(text) => {
256                            let synthetic = match texts {
257                                0 => format!("msg_rig_{position}"),
258                                n => format!("msg_rig_{position}_{n}"),
259                            };
260                            texts += 1;
261                            let mut item = json!({
262                                "type": "message",
263                                "role": "assistant",
264                                "content": [{"type": "output_text", "text": text.text, "annotations": []}],
265                                "status": "completed",
266                                "id": id("").unwrap_or(synthetic),
267                            });
268                            if let Some(phase) =
269                                identity.get("phase").filter(|phase| phase.is_string())
270                            {
271                                set(&mut item, "phase", phase.clone());
272                            }
273                            items.push(item);
274                            continue;
275                        }
276                        AssistantContent::ToolCall(call) => {
277                            let call_id = ids.spell(&call.id);
278                            let name = call.function.name.as_str();
279                            let kind = identity.get("type").and_then(Value::as_str);
280                            let arguments = || {
281                                serde_json::to_string(&call.function.arguments).unwrap_or_default()
282                            };
283                            let (kind, key, payload, prefix) = if kind == Some("custom_tool_call")
284                                || (kind.is_none() && custom.tools.contains(name))
285                            {
286                                custom.calls.insert(call_id.clone(), true);
287                                let input =
288                                    call.function.arguments.get("input").and_then(Value::as_str);
289                                let input = input.map_or_else(arguments, str::to_owned);
290                                ("custom_tool_call", "input", input, "ctc_")
291                            } else {
292                                custom.calls.insert(call_id.clone(), false);
293                                ("function_call", "arguments", arguments(), "fc_")
294                            };
295                            let mut item = json!({"type": kind, "call_id": call_id, "name": name});
296                            set(&mut item, key, Value::String(payload));
297                            if let Some(id) = id(prefix) {
298                                set(&mut item, "id", json!(id));
299                            }
300                            item
301                        }
302                        // An edited reasoning block keeps its item's id and
303                        // ciphertext, so the items after it stay paired.
304                        AssistantContent::Reasoning(reasoning) => {
305                            let Some(rs) = id("") else {
306                                continue;
307                            };
308                            let summary: Vec<Value> = (!reasoning.text.is_empty())
309                                .then(|| json!({"type": "summary_text", "text": reasoning.text}))
310                                .into_iter()
311                                .collect();
312                            let mut item =
313                                object! {"type": "reasoning", "id": rs, "summary": summary};
314                            if let Some(ciphertext) = identity.get("encrypted_content") {
315                                set(&mut item, "encrypted_content", ciphertext.clone());
316                            }
317                            item
318                        }
319                        AssistantContent::Opaque(opaque) if opaque.replay => opaque.item.clone(),
320                        AssistantContent::Opaque(_) => continue,
321                        AssistantContent::Image(_) => return Err(unsendable("assistant image")),
322                    };
323                    items.push(item);
324                }
325            }
326        }
327        if !matches!(&**message, Message::System { .. }) {
328            layout.after.push(items.len());
329            layout.parts.push(parts);
330        }
331    }
332    if layout.after.is_empty() {
333        layout.after.push(items.len());
334    }
335    Ok((items, layout))
336}
337
338/// Where the request's user and assistant messages (system messages not
339/// counted) fall among the input items, for its cache points.
340#[derive(Default)]
341struct Layout {
342    /// The item count after the first `n` messages, at `n`. At 0, the
343    /// count before the first message.
344    after: Vec<usize>,
345    /// For each message, the item count before each of its content items.
346    parts: Vec<Vec<usize>>,
347}
348
349impl Layout {
350    /// The item count the prefix `point` ends after, if the request has one.
351    fn boundary(&self, point: completion::CachePoint) -> Option<usize> {
352        use completion::CachePoint;
353        match point {
354            CachePoint::Tools => None,
355            CachePoint::System => self.after.first().copied(),
356            CachePoint::Messages(turns) => self.after.get(turns).copied(),
357            CachePoint::Within { message, items: 0 } => self.after.get(message).copied(),
358            CachePoint::Within { message, items } => self
359                .parts
360                .get(message)
361                .and_then(|parts| parts.get(items))
362                .or_else(|| self.after.get(message.checked_add(1)?))
363                .copied(),
364        }
365    }
366}
367
368/// The JSON of a tool choice.
369fn tool_choice(choice: message::ToolChoice) -> Result<Value, EncodeError> {
370    Ok(match choice {
371        message::ToolChoice::Auto => json!("auto"),
372        message::ToolChoice::None => json!("none"),
373        message::ToolChoice::Required => json!("required"),
374        message::ToolChoice::Specific { function_names } => match function_names.as_slice() {
375            [] => {
376                return Err(EncodeError::request(
377                    "ToolChoice::Specific requires at least one function name",
378                ));
379            }
380            [name] => json!({"type": "function", "name": name}),
381            names => json!({
382                "type": "allowed_tools",
383                "mode": "required",
384                "tools": names.iter().map(|name| json!({"type": "function", "name": name})).collect::<Vec<_>>(),
385            }),
386        },
387    })
388}
389
390/// Ask for the reasoning ciphertext, without which reasoning replays only
391/// from stored state.
392pub(crate) fn include_ciphertext(body: &mut Map<String, Value>) {
393    const CIPHERTEXT: &str = "reasoning.encrypted_content";
394    let mut include = body
395        .get("include")
396        .and_then(Value::as_array)
397        .cloned()
398        .unwrap_or_default();
399    if !include.iter().any(|item| item == CIPHERTEXT) {
400        include.push(json!(CIPHERTEXT));
401    }
402    body.insert("include".to_owned(), Value::Array(include));
403}
404
405/// Where a Responses body goes: the HTTP endpoint in a mode, or a WebSocket
406/// session's `response.create` event.
407#[derive(Clone, Copy, Debug, PartialEq, Eq)]
408pub(crate) enum Delivery {
409    /// `POST /responses`, streamed or not.
410    Http(Mode),
411    /// A WebSocket session, which ignores `stream` and `background`.
412    WebSocket,
413}
414
415impl wire::Responses {
416    /// The Responses request body this wire sends: its encoding of the
417    /// request, then the mapped options, then the provider options, then
418    /// `additional_params`, merged by [`request_params`], then the post-merge
419    /// rewrites. The WebSocket session calls it too, so both transports send
420    /// one body.
421    pub(crate) fn responses_request(
422        &self,
423        request: &completion::CompletionRequest,
424        delivery: Delivery,
425    ) -> Result<FinalBody, EncodeError> {
426        let codex =
427            self.provider.dialect.quirks.responses.contract == wire::ResponsesContract::Codex;
428        let mut rewrites = Vec::new();
429        match delivery {
430            Delivery::Http(Mode::Streaming) => rewrites.push(Rewrite::Stream(true)),
431            Delivery::Http(Mode::Unary) | Delivery::WebSocket => rewrites.push(Rewrite::NoStream),
432        }
433        if delivery == Delivery::WebSocket {
434            rewrites.push(Rewrite::NoBackground);
435        }
436        if codex {
437            rewrites.push(Rewrite::CodexStore);
438        }
439        // Reasoning replays without stored state only with its ciphertext.
440        if self.provider.dialect.quirks.responses.reasoning_ciphertext {
441            rewrites.push(Rewrite::ReasoningCiphertext(codex));
442        }
443        // The fields that stay the same turn to turn first, then the
444        // conversation, then the output cap, which one call may change: a
445        // request that appended to the conversation starts with the bytes
446        // of the one before, as the provider reads its prompt.
447        rewrites.push(Rewrite::MoveLast("input"));
448        rewrites.push(Rewrite::MoveLast("max_output_tokens"));
449        let body = request_params(
450            self,
451            request,
452            |layers| self.base(request, codex, layers),
453            RawAt::Top,
454            &rewrites,
455        )?;
456        crate::providers::openai::options::check_body(
457            self,
458            &self.facts,
459            request,
460            body,
461            crate::providers::openai::options::Endpoint::Responses,
462        )
463    }
464
465    /// This wire's own encoding of `request`: model, input, instructions,
466    /// tools and the typed fields the dialect takes. The Codex backend takes
467    /// no `max_output_tokens`, `temperature` or structured output, so it
468    /// gets none of them.
469    fn base(
470        &self,
471        request: &completion::CompletionRequest,
472        codex: bool,
473        layers: &mut BaseInput<'_>,
474    ) -> Result<Map<String, Value>, EncodeError> {
475        let model = request.model.clone().unwrap_or_else(|| self.model.clone());
476        let mut tools: Vec<ResponsesToolDefinition> = request
477            .tools
478            .iter()
479            .map(|tool| ResponsesToolDefinition::from(completion::ToolDefinition::clone(tool)))
480            .collect();
481        let extra = layers.raw_tools()?;
482        if !extra.is_empty() {
483            tools.extend(
484                serde_json::from_value::<Vec<ResponsesToolDefinition>>(Value::Array(extra))
485                    .map_err(|err| {
486                        EncodeError::request(format!(
487                            "Invalid OpenAI Responses tools payload in additional_params: {err}"
488                        ))
489                    })?,
490            );
491        }
492        tools.extend(self.tools.iter().cloned());
493        if self.strict_tools {
494            tools = tools
495                .into_iter()
496                .map(ResponsesToolDefinition::with_strict)
497                .collect();
498        }
499        let mut custom = Custom {
500            tools: tools
501                .iter()
502                .filter(|tool| tool.kind == "custom")
503                .map(|tool| tool.name.clone())
504                .collect(),
505            calls: Default::default(),
506        };
507        let stateless = codex || layers.param("store") == Some(&json!(false));
508        let caller = match layers.claim("input") {
509            None | Some(Value::Null) => Vec::new(),
510            Some(Value::Array(items)) => items,
511            Some(other) => {
512                return Err(EncodeError::request(format!(
513                    "the OpenAI Responses `input` option must be an array of items, got {other}"
514                )));
515            }
516        };
517        // A result answering a held call takes that call's kind.
518        for item in &caller {
519            if let (Some(kind @ ("function_call" | "custom_tool_call")), Some(call_id)) =
520                (item.str("type"), item.str("call_id"))
521            {
522                custom
523                    .calls
524                    .insert(call_id.to_owned(), kind == "custom_tool_call");
525            }
526        }
527        let (items, layout) = input(&request.chat_history, self, &model, &mut custom, stateless)?;
528
529        let system = |item: &Value| {
530            (item.str("role") == Some(self.provider.dialect.quirks.responses.system_role)).then(
531                || {
532                    item.at("/content/0/text")
533                        .and_then(Value::as_str)
534                        .unwrap_or_default()
535                        .to_owned()
536                },
537            )
538        };
539        let lift: Vec<bool> = match self.system_instructions {
540            // The leading run of system messages, unless it is the whole
541            // request, which then keeps them in `input` so it is not empty.
542            SystemInstructionsPlacement::Instructions => {
543                let leading = items
544                    .iter()
545                    .take_while(|item| system(item).is_some())
546                    .count();
547                let leading = if leading < items.len() || !caller.is_empty() {
548                    leading
549                } else {
550                    0
551                };
552                (0..items.len()).map(|at| at < leading).collect()
553            }
554            SystemInstructionsPlacement::AllInstructions => {
555                items.iter().map(|item| system(item).is_some()).collect()
556            }
557            SystemInstructionsPlacement::InputSystemMessages => vec![false; items.len()],
558        };
559        let mut lifted = Vec::new();
560        let mut sent = caller;
561        let offset = sent.len();
562        for (item, lift) in items.into_iter().zip(&lift) {
563            if *lift {
564                lifted.extend(system(&item));
565            } else {
566                sent.push(item);
567            }
568        }
569        if sent.is_empty() {
570            return Err(EncodeError::request(if lifted.is_empty() {
571                "OpenAI Responses request input must contain at least one item"
572            } else {
573                "OpenAI Responses request input must contain at least one non-system item \
574             (system messages were lifted into the top-level `instructions` field)"
575            }));
576        }
577        if self.marks_cache_points(&model) && layers.cache() != Some(CacheRetention::None) {
578            // An item count of the request's own encoding, as one of `sent`.
579            let at =
580                |count: usize| offset + lift.iter().take(count).filter(|lifted| !**lifted).count();
581            let points = request
582                .cache_points
583                .iter()
584                .filter_map(|point| layout.boundary(*point))
585                .map(at);
586            mark_cache_points(&mut sent, offset, points);
587        }
588        let lifted: Vec<&str> = lifted
589            .iter()
590            .map(|text| text.trim())
591            .filter(|text| !text.is_empty())
592            .collect();
593        let lifted = lifted.join("\n\n");
594        // A gateway's own instructions go ahead of the caller's, once.
595        let instructions = match &self.provider.instructions {
596            Some(gateway) if lifted.is_empty() => Some(gateway.clone()),
597            Some(gateway) if !lifted.contains(gateway.as_str()) => {
598                Some(format!("{gateway}\n\n{lifted}"))
599            }
600            _ => (!lifted.is_empty()).then_some(lifted),
601        };
602        let text = request
603            .output_schema
604            .clone()
605            .filter(|_| !codex)
606            .map(|schema| {
607                let (name, schema) = super::structured_output_schema(schema);
608                json!({"format": {"type": "json_schema", "name": name, "schema": schema, "strict": true}})
609            });
610
611        Ok(crate::providers::internal::set_fields([
612            ("model", Some(Value::String(model))),
613            ("input", Some(Value::Array(sent))),
614            ("instructions", instructions.map(Value::from)),
615            (
616                "max_output_tokens",
617                request.max_tokens.filter(|_| !codex).map(Value::from),
618            ),
619            (
620                "temperature",
621                request.temperature.filter(|_| !codex).map(Value::from),
622            ),
623            (
624                "tool_choice",
625                request.tool_choice.clone().map(tool_choice).transpose()?,
626            ),
627            ("tools", (!tools.is_empty()).then(|| json!(tools))),
628            ("text", text),
629        ]))
630    }
631}
632
633impl wire::Responses {
634    /// Whether this wire places [`completion::CompletionRequest::cache_points`]
635    /// for `model`: OpenAI's own API, from GPT-5.6 on.
636    fn marks_cache_points(&self, model: &str) -> bool {
637        self.provider.dialect.name == super::wire::OPENAI.name
638            && super::options::caches_by_options(&self.facts, model)
639    }
640}
641
642/// Mark the end of each prefix in `ends` (item counts of `items`) with a
643/// `prompt_cache_breakpoint` on the last input part at or before it. The
644/// first `held` items are the caller's and are never changed. Every point
645/// is marked: the provider writes the latest few and looks up the rest.
646fn mark_cache_points(items: &mut [Value], held: usize, ends: impl Iterator<Item = usize>) {
647    for end in ends {
648        let last = (held..end.min(items.len()))
649            .rev()
650            .find(|at| items.get(*at).is_some_and(markable));
651        if let Some(item) = last.and_then(|at| items.get_mut(at)) {
652            mark(item);
653        }
654    }
655}
656
657/// The field of `item` holding input parts that can carry a breakpoint: a
658/// user, system or developer message's content, or a tool's output.
659fn parts_field(item: &Value) -> Option<&'static str> {
660    match item.str("type") {
661        Some("message") if item.str("role") != Some("assistant") => Some("content"),
662        Some("function_call_output" | "custom_tool_call_output") => Some("output"),
663        _ => None,
664    }
665}
666
667/// Whether `part` is an input part that takes a breakpoint.
668fn takes_breakpoint(part: &Value) -> bool {
669    part.str("type")
670        .is_some_and(|kind| ["input_text", "input_image", "input_file"].contains(&kind))
671}
672
673/// The parts of `item` that take a breakpoint. A string `content` has none.
674fn breakpoint_parts(item: &Value) -> impl Iterator<Item = &Value> {
675    parts_field(item)
676        .and_then(|field| item.get(field))
677        .and_then(Value::as_array)
678        .into_iter()
679        .flatten()
680        .filter(|part| takes_breakpoint(part))
681}
682
683/// Whether `item` can carry a breakpoint: it has a part that takes one, or
684/// is a tool output given as one text.
685fn markable(item: &Value) -> bool {
686    breakpoint_parts(item).next().is_some()
687        || (parts_field(item) == Some("output") && item.str("output").is_some())
688}
689
690/// Put a breakpoint on the last part of `item` that takes one. A tool output
691/// given as one text becomes that text's part, which the provider caches as
692/// the same prefix.
693fn mark(item: &mut Value) {
694    if parts_field(item) == Some("output")
695        && let Some(text) = item.str("output").map(str::to_owned)
696    {
697        set(
698            item,
699            "output",
700            json!([{"type": "input_text", "text": text}]),
701        );
702    }
703    parts_field(item)
704        .and_then(|field| item.get_mut(field))
705        .and_then(Value::as_array_mut)
706        .into_iter()
707        .filter_map(|parts| parts.iter_mut().rev().find(|part| takes_breakpoint(part)))
708        .for_each(|part| set(part, "prompt_cache_breakpoint", json!({"mode": "explicit"})));
709}
710
711/// A function or hosted tool available to a Responses request.
712#[derive(Debug, Deserialize, Clone, PartialEq)]
713pub struct ResponsesToolDefinition {
714    /// The type of tool.
715    #[serde(rename = "type")]
716    pub kind: String,
717    /// Tool name
718    #[serde(default)]
719    pub name: String,
720    /// The parameters' JSON schema. A strict function tool keeps to OpenAI's
721    /// strict schema subset.
722    #[serde(default)]
723    pub parameters: serde_json::Value,
724    /// Whether to use strict mode. Disabled by default; opt in with [`Self::with_strict`]
725    /// or [`wire::Responses::with_strict_tools`].
726    ///
727    /// Always serialized on a function tool: the Responses API treats an omitted `strict`
728    /// as "attempt strict mode", so `false` must reach the wire for non-strict tools to
729    /// actually be non-strict. Never serialized on a hosted tool, which answers the field
730    /// with a 400 (`Unknown parameter: 'tools[0].strict'`).
731    #[serde(default, deserialize_with = "json_utils::null_or_default")]
732    pub strict: bool,
733    /// Tool description.
734    #[serde(default, deserialize_with = "json_utils::null_or_default")]
735    pub description: String,
736    /// Additional provider-specific configuration for hosted tools.
737    #[serde(flatten, default)]
738    pub config: Map<String, Value>,
739}
740
741impl Serialize for ResponsesToolDefinition {
742    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
743    where
744        S: Serializer,
745    {
746        use serde::ser::SerializeMap;
747
748        let mut map = serializer.serialize_map(None)?;
749        map.serialize_entry("type", &self.kind)?;
750        if !self.name.is_empty() {
751            map.serialize_entry("name", &self.name)?;
752        }
753        if !self.parameters.is_null() {
754            map.serialize_entry("parameters", &self.parameters)?;
755        }
756        if self.kind == "function" {
757            map.serialize_entry("strict", &self.strict)?;
758        }
759        if !self.description.is_empty() {
760            map.serialize_entry("description", &self.description)?;
761        }
762        for (key, value) in &self.config {
763            map.serialize_entry(key, value)?;
764        }
765        map.end()
766    }
767}
768
769impl ResponsesToolDefinition {
770    /// Creates a function tool definition with strict mode disabled.
771    pub fn function(
772        name: impl Into<String>,
773        description: impl Into<String>,
774        parameters: serde_json::Value,
775    ) -> Self {
776        Self {
777            kind: "function".to_string(),
778            name: name.into(),
779            parameters,
780            strict: false,
781            description: description.into(),
782            config: Map::new(),
783        }
784    }
785
786    /// Creates a strict function tool definition.
787    ///
788    /// The schema is sanitized to OpenAI's strict subset (`additionalProperties: false`
789    /// added and every property forced into `required`).
790    pub fn strict_function(
791        name: impl Into<String>,
792        description: impl Into<String>,
793        parameters: serde_json::Value,
794    ) -> Self {
795        Self::function(name, description, parameters).with_strict()
796    }
797
798    /// Enables strict mode for this function tool.
799    ///
800    /// Function schemas are sanitized to OpenAI's strict subset. Hosted tools are
801    /// returned unchanged because strict mode only applies to function tools.
802    pub fn with_strict(mut self) -> Self {
803        if self.kind == "function" {
804            super::sanitize_schema(&mut self.parameters);
805            self.strict = true;
806        }
807        self
808    }
809
810    /// Creates a hosted tool definition for an arbitrary hosted tool type.
811    pub fn hosted(kind: impl Into<String>) -> Self {
812        Self {
813            kind: kind.into(),
814            name: String::new(),
815            parameters: Value::Null,
816            strict: false,
817            description: String::new(),
818            config: Map::new(),
819        }
820    }
821
822    /// Creates a hosted `web_search` tool definition.
823    pub fn web_search() -> Self {
824        Self::hosted("web_search")
825    }
826
827    /// Creates a hosted `file_search` tool definition.
828    pub fn file_search() -> Self {
829        Self::hosted("file_search")
830    }
831
832    /// Creates a hosted `computer_use` tool definition.
833    pub fn computer_use() -> Self {
834        Self::hosted("computer_use")
835    }
836
837    /// Adds hosted-tool configuration fields.
838    pub fn with_config(mut self, key: impl Into<String>, value: Value) -> Self {
839        self.config.insert(key.into(), value);
840        self
841    }
842}
843
844impl From<completion::ToolDefinition> for ResponsesToolDefinition {
845    fn from(value: completion::ToolDefinition) -> Self {
846        // The annotations are the host's: never sent.
847        let completion::ToolDefinition {
848            name,
849            parameters,
850            description,
851            annotations: _,
852        } = value;
853
854        Self::function(name, description, parameters)
855    }
856}
857
858/// Controls where Rig system instructions are placed in an OpenAI Responses request.
859///
860/// Serialized because it is a field of the [`wire::Responses`] wire, which is
861/// data a host may store.
862#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
863#[serde(rename_all = "snake_case")]
864pub enum SystemInstructionsPlacement {
865    /// Send the leading run of system instructions (the preamble and any system
866    /// messages that open the conversation) through the official top-level
867    /// `instructions` field. Mid-conversation system messages keep their
868    /// position in `input`.
869    #[default]
870    Instructions,
871    /// Send every system message through the top-level `instructions` field,
872    /// including mid-conversation ones.
873    ///
874    /// Use this for backends that reject the `system` role in `input` entirely.
875    AllInstructions,
876    /// Send system instructions as `system` messages in `input`.
877    ///
878    /// Use this only for OpenAI-compatible providers that do not support top-level
879    /// `instructions`.
880    InputSystemMessages,
881}
882
883#[cfg(test)]
884mod history_tests;
885#[cfg(test)]
886mod tests;