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