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