Skip to main content

openrouter/
responses.rs

1//! **\[BETA\]** OpenRouter Responses API.
2//!
3//! Gated behind the `beta` cargo feature; enable with
4//! `cargo build --features beta`.
5//!
6//! > **WARNING — beta API:** this surface may have breaking changes at any
7//! > time. For stable production use prefer [`crate::Client::chat_complete`].
8//!
9//! Mirrors the Go SDK (`responses.go`, `responses_models.go`,
10//! `responses_options.go`). The Rust port substitutes Go's functional
11//! options with a builder struct ([`ResponsesRequest`]).
12
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16use crate::client::Client;
17use crate::error::{Error, Result};
18use crate::request;
19use crate::stream::EventStream;
20use crate::types::Plugin;
21
22/// Reasoning effort levels accepted by the API.
23pub mod reasoning_effort {
24    /// Minimal reasoning effort.
25    pub const MINIMAL: &str = "minimal";
26    /// Low reasoning effort.
27    pub const LOW: &str = "low";
28    /// Medium reasoning effort.
29    pub const MEDIUM: &str = "medium";
30    /// High reasoning effort.
31    pub const HIGH: &str = "high";
32}
33
34/// Reasoning configuration for [`ResponsesRequest`].
35#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
36pub struct ResponsesReasoning {
37    /// One of `"minimal"`, `"low"`, `"medium"`, `"high"`. Validated at
38    /// request time.
39    pub effort: String,
40}
41
42/// A content part inside a [`ResponsesInputItem::message`] input. Currently
43/// only `input_text` is supported.
44#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
45pub struct ResponsesInputContent {
46    /// Always `"input_text"`.
47    #[serde(rename = "type")]
48    pub kind: String,
49    /// Text body.
50    pub text: String,
51}
52
53impl ResponsesInputContent {
54    /// Build an `input_text` content part.
55    pub fn input_text(text: impl Into<String>) -> Self {
56        Self {
57            kind: "input_text".into(),
58            text: text.into(),
59        }
60    }
61}
62
63/// One item in the structured input array. Use the builder constructors
64/// rather than constructing the struct directly.
65#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
66pub struct ResponsesInputItem {
67    /// `"message"` or `"function_call_output"`.
68    #[serde(rename = "type")]
69    pub kind: String,
70    /// Item identifier (server-assigned for replays).
71    #[serde(skip_serializing_if = "Option::is_none", default)]
72    pub id: Option<String>,
73    /// Item status, when carried.
74    #[serde(skip_serializing_if = "Option::is_none", default)]
75    pub status: Option<String>,
76    /// `"user"`, `"assistant"`, or `"system"` (only for `message` items).
77    #[serde(skip_serializing_if = "Option::is_none", default)]
78    pub role: Option<String>,
79    /// Content parts (only for `message` items).
80    #[serde(skip_serializing_if = "Vec::is_empty", default)]
81    pub content: Vec<ResponsesInputContent>,
82    /// Only for `function_call_output` items.
83    #[serde(skip_serializing_if = "Option::is_none", default)]
84    pub call_id: Option<String>,
85    /// Only for `function_call_output` items — the function's return value.
86    #[serde(skip_serializing_if = "Option::is_none", default)]
87    pub output: Option<String>,
88}
89
90impl ResponsesInputItem {
91    /// Build a message with an arbitrary role.
92    pub fn message(role: impl Into<String>, text: impl Into<String>) -> Self {
93        Self {
94            kind: "message".into(),
95            role: Some(role.into()),
96            content: vec![ResponsesInputContent::input_text(text)],
97            ..Default::default()
98        }
99    }
100
101    /// Build a `user` message.
102    pub fn user(text: impl Into<String>) -> Self {
103        Self::message("user", text)
104    }
105
106    /// Build an `assistant` message.
107    pub fn assistant(text: impl Into<String>) -> Self {
108        Self::message("assistant", text)
109    }
110
111    /// Build a `system` message.
112    pub fn system(text: impl Into<String>) -> Self {
113        Self::message("system", text)
114    }
115
116    /// Build the return value of an earlier function call.
117    pub fn function_call_output(call_id: impl Into<String>, output: impl Into<String>) -> Self {
118        Self {
119            kind: "function_call_output".into(),
120            call_id: Some(call_id.into()),
121            output: Some(output.into()),
122            ..Default::default()
123        }
124    }
125}
126
127/// Tool definition for the Responses API. Unlike `chat/completions` this
128/// is a flat structure (no nested `function: {…}`).
129#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
130pub struct ResponsesTool {
131    /// Always `"function"`.
132    #[serde(rename = "type")]
133    pub kind: String,
134    /// Function name as exposed to the model.
135    pub name: String,
136    /// Optional human-readable description.
137    #[serde(skip_serializing_if = "String::is_empty", default)]
138    pub description: String,
139    /// `Some(true)` enables strict schema validation; `None` keeps the
140    /// server default (serialized as `null` per the upstream contract).
141    pub strict: Option<bool>,
142    /// JSON Schema describing the function's parameters.
143    #[serde(skip_serializing_if = "Option::is_none", default)]
144    pub parameters: Option<Value>,
145}
146
147impl ResponsesTool {
148    /// Build a function tool.
149    pub fn function(
150        name: impl Into<String>,
151        description: impl Into<String>,
152        parameters: Value,
153    ) -> Self {
154        Self {
155            kind: "function".into(),
156            name: name.into(),
157            description: description.into(),
158            strict: None,
159            parameters: Some(parameters),
160        }
161    }
162}
163
164/// Input to [`Client::create_response`]: either a single string or a
165/// structured sequence of [`ResponsesInputItem`].
166#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
167#[serde(untagged)]
168pub enum ResponsesInput {
169    /// A single text prompt.
170    Text(String),
171    /// Structured sequence of input items.
172    Items(Vec<ResponsesInputItem>),
173}
174
175impl From<&str> for ResponsesInput {
176    fn from(s: &str) -> Self {
177        ResponsesInput::Text(s.to_string())
178    }
179}
180
181impl From<String> for ResponsesInput {
182    fn from(s: String) -> Self {
183        ResponsesInput::Text(s)
184    }
185}
186
187impl From<Vec<ResponsesInputItem>> for ResponsesInput {
188    fn from(v: Vec<ResponsesInputItem>) -> Self {
189        ResponsesInput::Items(v)
190    }
191}
192
193/// Request body for the Responses API.
194#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
195pub struct ResponsesRequest {
196    /// Model id.
197    pub model: String,
198    /// Input — either a single string or structured items.
199    pub input: Option<ResponsesInput>,
200    /// Stream-mode flag. The SDK forces `Some(true)` for streaming calls
201    /// and `Some(false)` for blocking ones.
202    #[serde(skip_serializing_if = "Option::is_none", default)]
203    pub stream: Option<bool>,
204    /// Max output tokens.
205    #[serde(skip_serializing_if = "Option::is_none", default)]
206    pub max_output_tokens: Option<u32>,
207    /// Sampling temperature.
208    #[serde(skip_serializing_if = "Option::is_none", default)]
209    pub temperature: Option<f64>,
210    /// Nucleus sampling probability.
211    #[serde(skip_serializing_if = "Option::is_none", default)]
212    pub top_p: Option<f64>,
213    /// Reasoning configuration.
214    #[serde(skip_serializing_if = "Option::is_none", default)]
215    pub reasoning: Option<ResponsesReasoning>,
216    /// Tools available to the model.
217    #[serde(skip_serializing_if = "Vec::is_empty", default)]
218    pub tools: Vec<ResponsesTool>,
219    /// `"auto"`, `"none"`, or `{"type":"function","function":{"name":...}}`.
220    #[serde(skip_serializing_if = "Option::is_none", default)]
221    pub tool_choice: Option<Value>,
222    /// Plugin configuration.
223    #[serde(skip_serializing_if = "Vec::is_empty", default)]
224    pub plugins: Vec<Plugin>,
225}
226
227impl ResponsesRequest {
228    /// Start a builder with the given model.
229    pub fn new(model: impl Into<String>) -> Self {
230        Self {
231            model: model.into(),
232            ..Default::default()
233        }
234    }
235
236    /// Set the input (string or structured items).
237    pub fn input(mut self, input: impl Into<ResponsesInput>) -> Self {
238        self.input = Some(input.into());
239        self
240    }
241
242    /// Cap the output length in tokens.
243    pub fn max_output_tokens(mut self, n: u32) -> Self {
244        self.max_output_tokens = Some(n);
245        self
246    }
247
248    /// Set sampling temperature.
249    pub fn temperature(mut self, t: f64) -> Self {
250        self.temperature = Some(t);
251        self
252    }
253
254    /// Set nucleus sampling probability.
255    pub fn top_p(mut self, p: f64) -> Self {
256        self.top_p = Some(p);
257        self
258    }
259
260    /// Set reasoning effort (`minimal`, `low`, `medium`, `high`).
261    pub fn reasoning_effort(mut self, effort: impl Into<String>) -> Self {
262        self.reasoning = Some(ResponsesReasoning {
263            effort: effort.into(),
264        });
265        self
266    }
267
268    /// Replace the tool list.
269    pub fn tools(mut self, tools: impl IntoIterator<Item = ResponsesTool>) -> Self {
270        self.tools = tools.into_iter().collect();
271        self
272    }
273
274    /// Set tool-selection strategy.
275    pub fn tool_choice(mut self, choice: Value) -> Self {
276        self.tool_choice = Some(choice);
277        self
278    }
279
280    /// Append plugins (does not overwrite existing ones).
281    pub fn plugins(mut self, plugins: impl IntoIterator<Item = Plugin>) -> Self {
282        self.plugins.extend(plugins);
283        self
284    }
285
286    /// Convenience: enable the `web` plugin with `max_results`.
287    pub fn web_search(mut self, max_results: u32) -> Self {
288        use crate::types::WebPluginConfig;
289        self.plugins.push(Plugin::web_with(
290            WebPluginConfig::new().with_max_results(max_results),
291        ));
292        self
293    }
294
295    /// Validate inputs that the Go SDK validates client-side before sending.
296    fn validate(&self) -> Result<()> {
297        if self.model.is_empty() {
298            return Err(Error::InvalidInput("model is required"));
299        }
300        let input = self
301            .input
302            .as_ref()
303            .ok_or(Error::InvalidInput("input is required"))?;
304        match input {
305            ResponsesInput::Text(s) if s.is_empty() => {
306                return Err(Error::InvalidInput("input string cannot be empty"));
307            }
308            ResponsesInput::Items(v) if v.is_empty() => {
309                return Err(Error::InvalidInput("input array cannot be empty"));
310            }
311            ResponsesInput::Items(items) => {
312                for item in items {
313                    if item.kind.is_empty() {
314                        return Err(Error::InvalidInput("input item type is required"));
315                    }
316                    match item.kind.as_str() {
317                        "message" => {
318                            let role = item
319                                .role
320                                .as_deref()
321                                .ok_or(Error::InvalidInput("message role is required"))?;
322                            if !matches!(role, "user" | "assistant" | "system") {
323                                return Err(Error::InvalidInput(
324                                    "message role must be user/assistant/system",
325                                ));
326                            }
327                        }
328                        "function_call_output"
329                            if item.call_id.as_deref().unwrap_or("").is_empty() =>
330                        {
331                            return Err(Error::InvalidInput(
332                                "function_call_output requires call_id",
333                            ));
334                        }
335                        _ => {}
336                    }
337                }
338            }
339            _ => {}
340        }
341        if let Some(r) = &self.reasoning {
342            if !matches!(
343                r.effort.as_str(),
344                reasoning_effort::MINIMAL
345                    | reasoning_effort::LOW
346                    | reasoning_effort::MEDIUM
347                    | reasoning_effort::HIGH
348            ) {
349                return Err(Error::InvalidInput(
350                    "reasoning.effort must be minimal/low/medium/high",
351                ));
352            }
353        }
354        for tool in &self.tools {
355            if tool.kind.is_empty() {
356                return Err(Error::InvalidInput("tool type is required"));
357            }
358            if tool.name.is_empty() {
359                return Err(Error::InvalidInput("tool name is required"));
360            }
361        }
362        Ok(())
363    }
364}
365
366/// Annotation attached to an [`ResponsesOutputContent`] (e.g. URL citation).
367#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
368pub struct ResponsesAnnotation {
369    /// Annotation type discriminator (e.g. `"url_citation"`).
370    #[serde(rename = "type", default)]
371    pub kind: String,
372    /// Cited URL.
373    #[serde(default, skip_serializing_if = "String::is_empty")]
374    pub url: String,
375    /// Start character offset within the output text.
376    #[serde(default)]
377    pub start_index: i64,
378    /// End character offset within the output text.
379    #[serde(default)]
380    pub end_index: i64,
381}
382
383/// A content item inside [`ResponsesOutput::content`].
384#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
385pub struct ResponsesOutputContent {
386    /// Content type (`"output_text"`, `"reasoning"`, ...).
387    #[serde(rename = "type", default)]
388    pub kind: String,
389    /// Text body.
390    #[serde(default, skip_serializing_if = "String::is_empty")]
391    pub text: String,
392    /// Inline annotations (citations, etc.).
393    #[serde(default, skip_serializing_if = "Vec::is_empty")]
394    pub annotations: Vec<ResponsesAnnotation>,
395    /// Encrypted reasoning chain (for `reasoning` content).
396    #[serde(default, skip_serializing_if = "String::is_empty")]
397    pub encrypted_content: String,
398    /// Key reasoning steps as text (for `reasoning` content).
399    #[serde(default, skip_serializing_if = "Vec::is_empty")]
400    pub summary: Vec<String>,
401}
402
403/// A single output item: `"message"` or `"function_call"`.
404#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
405pub struct ResponsesOutput {
406    /// Output type (`"message"`, `"function_call"`, ...).
407    #[serde(rename = "type", default)]
408    pub kind: String,
409    /// Stable item identifier.
410    #[serde(default)]
411    pub id: String,
412    /// Item status, when carried.
413    #[serde(default, skip_serializing_if = "String::is_empty")]
414    pub status: String,
415    /// Message role (only for `message` items).
416    #[serde(default, skip_serializing_if = "String::is_empty")]
417    pub role: String,
418    /// Content parts (only for `message` items).
419    #[serde(default, skip_serializing_if = "Vec::is_empty")]
420    pub content: Vec<ResponsesOutputContent>,
421    /// `function_call` only.
422    #[serde(default, skip_serializing_if = "String::is_empty")]
423    pub call_id: String,
424    /// `function_call` only.
425    #[serde(default, skip_serializing_if = "String::is_empty")]
426    pub name: String,
427    /// `function_call` only — JSON-encoded arguments.
428    #[serde(default, skip_serializing_if = "String::is_empty")]
429    pub arguments: String,
430}
431
432/// Token usage for a Responses request.
433#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
434pub struct ResponsesUsage {
435    /// Input token count.
436    #[serde(default)]
437    pub input_tokens: u64,
438    /// Output token count.
439    #[serde(default)]
440    pub output_tokens: u64,
441    /// Total tokens (input + output).
442    #[serde(default)]
443    pub total_tokens: u64,
444    /// Reasoning tokens (included in output).
445    #[serde(default)]
446    pub reasoning_tokens: u64,
447}
448
449/// Full unary or streaming-chunk response.
450#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
451pub struct ResponsesResponse {
452    /// Response identifier.
453    #[serde(default)]
454    pub id: String,
455    /// Wire object discriminator.
456    #[serde(default)]
457    pub object: String,
458    /// Unix-seconds creation timestamp.
459    #[serde(default)]
460    pub created_at: i64,
461    /// Model that produced the response.
462    #[serde(default)]
463    pub model: String,
464    /// Output items.
465    #[serde(default)]
466    pub output: Vec<ResponsesOutput>,
467    /// Token usage accounting.
468    #[serde(default)]
469    pub usage: ResponsesUsage,
470    /// Response status string.
471    #[serde(default)]
472    pub status: String,
473    /// Free-form provider metadata.
474    #[serde(default)]
475    pub metadata: Option<Value>,
476}
477
478impl ResponsesResponse {
479    /// Return the first `output_text` text content, or empty string.
480    pub fn text_content(&self) -> &str {
481        for o in &self.output {
482            if o.kind == "message" {
483                for c in &o.content {
484                    if c.kind == "output_text" && !c.text.is_empty() {
485                        return &c.text;
486                    }
487                }
488            }
489        }
490        ""
491    }
492
493    /// Return all `function_call` outputs.
494    pub fn function_calls(&self) -> Vec<&ResponsesOutput> {
495        self.output
496            .iter()
497            .filter(|o| o.kind == "function_call")
498            .collect()
499    }
500
501    /// Return all annotations across every output content item.
502    pub fn annotations(&self) -> Vec<&ResponsesAnnotation> {
503        self.output
504            .iter()
505            .flat_map(|o| o.content.iter().flat_map(|c| c.annotations.iter()))
506            .collect()
507    }
508
509    /// Return the reasoning summary if present.
510    pub fn reasoning_summary(&self) -> Option<&[String]> {
511        for o in &self.output {
512            for c in &o.content {
513                if c.kind == "reasoning" && !c.summary.is_empty() {
514                    return Some(&c.summary);
515                }
516            }
517        }
518        None
519    }
520}
521
522impl Client {
523    /// **\[BETA\]** Submit a unary Responses API request.
524    ///
525    /// `POST /responses`. `req.stream` is forced to `Some(false)` to keep
526    /// the unary path honest. Returns the decoded [`ResponsesResponse`].
527    pub async fn create_response(&self, mut req: ResponsesRequest) -> Result<ResponsesResponse> {
528        req.stream = Some(false);
529        req.validate()?;
530        request::execute_json(self, "responses", &req).await
531    }
532
533    /// **\[BETA\]** Open a streaming Responses API request.
534    ///
535    /// `POST /responses` with SSE. Returns an [`EventStream`] whose items
536    /// deserialize into [`ResponsesResponse`] chunks. Reconnect / cancel
537    /// semantics match [`Client::chat_complete_stream`].
538    pub async fn create_response_stream(
539        &self,
540        mut req: ResponsesRequest,
541    ) -> Result<EventStream<ResponsesResponse>> {
542        req.stream = Some(true);
543        req.validate()?;
544        self.open_event_stream("responses", &req).await
545    }
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551
552    #[test]
553    fn untagged_input_serializes_string() {
554        let req = ResponsesRequest::new("m").input("hello");
555        let v = serde_json::to_value(&req).unwrap();
556        assert_eq!(v["input"], serde_json::json!("hello"));
557    }
558
559    #[test]
560    fn untagged_input_serializes_items() {
561        let req = ResponsesRequest::new("m").input(vec![ResponsesInputItem::user("hi")]);
562        let v = serde_json::to_value(&req).unwrap();
563        assert_eq!(v["input"][0]["type"], "message");
564        assert_eq!(v["input"][0]["role"], "user");
565        assert_eq!(v["input"][0]["content"][0]["type"], "input_text");
566        assert_eq!(v["input"][0]["content"][0]["text"], "hi");
567    }
568
569    #[test]
570    fn validate_rejects_empty_text_input() {
571        let err = ResponsesRequest::new("m").input("").validate().unwrap_err();
572        assert!(matches!(err, Error::InvalidInput(_)));
573    }
574
575    #[test]
576    fn validate_rejects_bad_reasoning_effort() {
577        let mut req = ResponsesRequest::new("m").input("hi");
578        req.reasoning = Some(ResponsesReasoning {
579            effort: "absurd".into(),
580        });
581        let err = req.validate().unwrap_err();
582        assert!(matches!(err, Error::InvalidInput(_)));
583    }
584
585    #[test]
586    fn text_content_extraction() {
587        let r = ResponsesResponse {
588            output: vec![ResponsesOutput {
589                kind: "message".into(),
590                content: vec![ResponsesOutputContent {
591                    kind: "output_text".into(),
592                    text: "hello world".into(),
593                    ..Default::default()
594                }],
595                ..Default::default()
596            }],
597            ..Default::default()
598        };
599        assert_eq!(r.text_content(), "hello world");
600    }
601}