Skip to main content

openrouter/types/
response.rs

1//! Response payloads.
2
3use serde::{Deserialize, Serialize};
4
5use super::{Message, Role, ToolCall};
6
7/// Chat-completions response.
8#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
9pub struct ChatCompletionResponse {
10    /// Generation id. Optional because some providers omit it on
11    /// streaming chunks that only carry tool-call deltas.
12    #[serde(default)]
13    pub id: Option<String>,
14    /// Wire object discriminator (e.g. `"chat.completion"`).
15    #[serde(default)]
16    pub object: Option<String>,
17    /// Unix-seconds timestamp of generation.
18    #[serde(default)]
19    pub created: Option<u64>,
20    /// Model that produced the response.
21    pub model: String,
22    /// Generated choices.
23    pub choices: Vec<Choice>,
24    /// Token usage accounting, when reported by the provider.
25    #[serde(default)]
26    pub usage: Option<Usage>,
27    /// Provider that served the request.
28    #[serde(default)]
29    pub provider: Option<String>,
30    /// Provider-specific build / fingerprint identifier.
31    #[serde(default)]
32    pub system_fingerprint: Option<String>,
33}
34
35/// One choice in a chat-completions response (also used for streaming chunks).
36#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
37pub struct Choice {
38    /// Choice index within the response.
39    pub index: u32,
40    /// Full message (non-streaming) or final reconciled message.
41    #[serde(default)]
42    pub message: Option<Message>,
43    /// Incremental delta (streaming chunks).
44    #[serde(default)]
45    pub delta: Option<Delta>,
46    /// Why generation stopped (`stop`, `length`, `tool_calls`, ...).
47    #[serde(default)]
48    pub finish_reason: Option<String>,
49    /// Provider-native finish reason, when different.
50    #[serde(default)]
51    pub native_finish_reason: Option<String>,
52    /// Token-level log probabilities, when requested.
53    #[serde(default)]
54    pub logprobs: Option<LogProbs>,
55}
56
57/// Incremental token delta for streaming responses.
58#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
59pub struct Delta {
60    /// Role of the streaming message (only on the first delta).
61    #[serde(default)]
62    pub role: Option<Role>,
63    /// Text content fragment.
64    #[serde(default)]
65    pub content: Option<String>,
66    /// Streaming tool-call fragments. Use
67    /// [`crate::ToolCallAccumulator`] to reassemble.
68    #[serde(default)]
69    pub tool_calls: Option<Vec<ToolCall>>,
70    /// Streaming reasoning text fragment.
71    #[serde(default)]
72    pub reasoning: Option<String>,
73}
74
75/// Token-level log probabilities. Shape varies by provider; kept opaque.
76pub type LogProbs = serde_json::Value;
77
78/// Legacy text-completions response.
79#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
80pub struct CompletionResponse {
81    /// Generation id.
82    #[serde(default)]
83    pub id: Option<String>,
84    /// Wire object discriminator.
85    #[serde(default)]
86    pub object: Option<String>,
87    /// Unix-seconds timestamp of generation.
88    #[serde(default)]
89    pub created: Option<u64>,
90    /// Model that produced the response.
91    pub model: String,
92    /// Generated choices.
93    pub choices: Vec<CompletionChoice>,
94    /// Token usage accounting.
95    #[serde(default)]
96    pub usage: Option<Usage>,
97    /// Provider that served the request.
98    #[serde(default)]
99    pub provider: Option<String>,
100}
101
102/// One choice in a legacy completion response.
103#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
104pub struct CompletionChoice {
105    /// Choice index within the response.
106    pub index: u32,
107    /// Generated text.
108    pub text: String,
109    /// Why generation stopped.
110    #[serde(default)]
111    pub finish_reason: Option<String>,
112    /// Provider-native finish reason, when different.
113    #[serde(default)]
114    pub native_finish_reason: Option<String>,
115    /// Token-level log probabilities.
116    #[serde(default)]
117    pub logprobs: Option<LogProbs>,
118}
119
120/// Token usage accounting.
121#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
122pub struct Usage {
123    /// Tokens consumed by the prompt.
124    #[serde(default)]
125    pub prompt_tokens: Option<u32>,
126    /// Tokens generated for the completion.
127    #[serde(default)]
128    pub completion_tokens: Option<u32>,
129    /// Sum of prompt + completion tokens (when reported).
130    #[serde(default)]
131    pub total_tokens: Option<u32>,
132    /// Breakdown of prompt tokens (cached vs. fresh).
133    #[serde(default)]
134    pub prompt_tokens_details: Option<TokenDetails>,
135    /// Breakdown of completion tokens (reasoning vs. visible).
136    #[serde(default)]
137    pub completion_tokens_details: Option<TokenDetails>,
138    /// USD cost of the request, when reported.
139    #[serde(default)]
140    pub cost: Option<f64>,
141}
142
143/// Sub-breakdown of token usage (cached, reasoning, etc.).
144#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
145pub struct TokenDetails {
146    /// Tokens served from cache.
147    #[serde(default)]
148    pub cached_tokens: Option<u32>,
149    /// Tokens spent on hidden reasoning.
150    #[serde(default)]
151    pub reasoning_tokens: Option<u32>,
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use pretty_assertions::assert_eq;
158
159    #[test]
160    fn round_trip_chat_response() {
161        let raw = r#"{
162            "id":"gen-1",
163            "object":"chat.completion",
164            "created":1700000000,
165            "model":"anthropic/claude-3-opus",
166            "provider":"Anthropic",
167            "choices":[{
168                "index":0,
169                "message":{"role":"assistant","content":"hi"},
170                "finish_reason":"stop"
171            }],
172            "usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}
173        }"#;
174        let r: ChatCompletionResponse = serde_json::from_str(raw).unwrap();
175        assert_eq!(r.id.as_deref(), Some("gen-1"));
176        assert_eq!(r.choices.len(), 1);
177        let c = &r.choices[0];
178        assert_eq!(c.message.as_ref().unwrap().content_text(), Some("hi"));
179        assert_eq!(r.usage.as_ref().unwrap().total_tokens, Some(4));
180    }
181
182    #[test]
183    fn round_trip_streaming_chunk() {
184        let raw = r#"{
185            "id":"gen-2",
186            "model":"x/y",
187            "choices":[{
188                "index":0,
189                "delta":{"role":"assistant","content":"Hel"},
190                "finish_reason":null
191            }]
192        }"#;
193        let r: ChatCompletionResponse = serde_json::from_str(raw).unwrap();
194        let d = r.choices[0].delta.as_ref().unwrap();
195        assert_eq!(d.content.as_deref(), Some("Hel"));
196        assert_eq!(d.role, Some(Role::Assistant));
197    }
198
199    #[test]
200    fn annotations_round_trip_url_citations() {
201        let raw = r#"{
202            "id":"gen-a","model":"x/y",
203            "choices":[{
204                "index":0,
205                "message":{
206                    "role":"assistant",
207                    "content":"see source",
208                    "annotations":[{
209                        "type":"url_citation",
210                        "url_citation":{
211                            "url":"https://example.com",
212                            "title":"Example",
213                            "start_index":0,
214                            "end_index":10
215                        }
216                    }]
217                },
218                "finish_reason":"stop"
219            }]
220        }"#;
221        let r: ChatCompletionResponse = serde_json::from_str(raw).unwrap();
222        let msg = r.choices[0].message.as_ref().unwrap();
223        let anns = msg.annotations.as_ref().unwrap();
224        assert_eq!(anns.len(), 1);
225        match &anns[0] {
226            crate::types::Annotation::UrlCitation { url_citation } => {
227                assert_eq!(url_citation.url, "https://example.com");
228                assert_eq!(url_citation.title.as_deref(), Some("Example"));
229            }
230            other => panic!("unexpected annotation: {other:?}"),
231        }
232    }
233
234    #[test]
235    fn reasoning_fields_round_trip() {
236        let raw = r#"{
237            "id":"gen-r","model":"x/y",
238            "choices":[{
239                "index":0,
240                "message":{"role":"assistant","content":"42","reasoning":"long chain"},
241                "finish_reason":"stop"
242            }],
243            "usage":{
244                "prompt_tokens":3,"completion_tokens":5,"total_tokens":8,
245                "completion_tokens_details":{"reasoning_tokens":17}
246            }
247        }"#;
248        let r: ChatCompletionResponse = serde_json::from_str(raw).unwrap();
249        let msg = r.choices[0].message.as_ref().unwrap();
250        assert_eq!(msg.reasoning.as_deref(), Some("long chain"));
251        let details = r
252            .usage
253            .as_ref()
254            .unwrap()
255            .completion_tokens_details
256            .as_ref()
257            .unwrap();
258        assert_eq!(details.reasoning_tokens, Some(17));
259    }
260
261    #[test]
262    fn round_trip_tool_call_response() {
263        let raw = r#"{
264            "id":"gen-3","model":"x/y",
265            "choices":[{
266                "index":0,
267                "message":{
268                    "role":"assistant",
269                    "content":"",
270                    "tool_calls":[{"id":"c1","type":"function","function":{"name":"f","arguments":"{}"}}]
271                },
272                "finish_reason":"tool_calls"
273            }]
274        }"#;
275        let r: ChatCompletionResponse = serde_json::from_str(raw).unwrap();
276        let calls = r.choices[0]
277            .message
278            .as_ref()
279            .unwrap()
280            .tool_calls
281            .as_ref()
282            .unwrap();
283        assert_eq!(calls.len(), 1);
284        assert_eq!(calls[0].function.name.as_deref(), Some("f"));
285    }
286}