Skip to main content

sie_sdk/types/
generate.rs

1//! Text generation: the native `/v1/generate` shape plus the OpenAI-compatible ones.
2
3// Wire-mirror types: field names are the API contract itself, and the ones whose
4// meaning is not obvious carry their own doc comment.
5#![allow(missing_docs)]
6
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10use super::RequestMetadata;
11
12/// Why a native generation stopped.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum FinishReason {
16    Stop,
17    Length,
18    Cancelled,
19    ContentFilter,
20    Error,
21}
22
23/// Why an OpenAI-compatible completion stopped.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum ChatFinishReason {
27    Stop,
28    Length,
29    ToolCalls,
30    ContentFilter,
31}
32
33/// Who authored a chat message.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "lowercase")]
36pub enum ChatRole {
37    System,
38    User,
39    Assistant,
40    Tool,
41    Developer,
42}
43
44/// A constraint on the shape of the generated text.
45///
46/// Exactly one of the three forms applies; the enum makes that structural rather than a
47/// runtime check.
48#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
49#[serde(untagged)]
50pub enum Grammar {
51    /// Output must validate against this JSON schema.
52    JsonSchema {
53        json_schema: Value,
54        #[serde(default, skip_serializing_if = "Option::is_none")]
55        label: Option<String>,
56        #[serde(default, skip_serializing_if = "Option::is_none")]
57        strict: Option<bool>,
58    },
59    /// Output must match this regular expression.
60    Regex {
61        regex: String,
62        #[serde(default, skip_serializing_if = "Option::is_none")]
63        label: Option<String>,
64        #[serde(default, skip_serializing_if = "Option::is_none")]
65        strict: Option<bool>,
66    },
67    /// Output must parse under this EBNF grammar.
68    Ebnf {
69        ebnf: String,
70        #[serde(default, skip_serializing_if = "Option::is_none")]
71        label: Option<String>,
72        #[serde(default, skip_serializing_if = "Option::is_none")]
73        strict: Option<bool>,
74    },
75}
76
77impl Grammar {
78    /// Constrain output to a JSON schema.
79    pub fn json_schema(schema: Value) -> Self {
80        Self::JsonSchema {
81            json_schema: schema,
82            label: None,
83            strict: None,
84        }
85    }
86
87    /// Constrain output to a regular expression.
88    pub fn regex(pattern: impl Into<String>) -> Self {
89        Self::Regex {
90            regex: pattern.into(),
91            label: None,
92            strict: None,
93        }
94    }
95
96    /// Constrain output to an EBNF grammar.
97    pub fn ebnf(grammar: impl Into<String>) -> Self {
98        Self::Ebnf {
99            ebnf: grammar.into(),
100            label: None,
101            strict: None,
102        }
103    }
104}
105
106/// Token counts for a native generation.
107#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
108pub struct GenerationUsage {
109    #[serde(default, deserialize_with = "crate::types::null_as_default")]
110    pub prompt_tokens: u64,
111    #[serde(default, deserialize_with = "crate::types::null_as_default")]
112    pub completion_tokens: u64,
113    #[serde(default, deserialize_with = "crate::types::null_as_default")]
114    pub total_tokens: u64,
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub credits_charged: Option<u64>,
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub rate_book_version: Option<String>,
119}
120
121/// A completed native generation.
122#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
123pub struct GenerateResult {
124    pub model: String,
125    pub text: String,
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub finish_reason: Option<FinishReason>,
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub usage: Option<GenerationUsage>,
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub attempt_id: Option<String>,
132    /// Time to first token, in milliseconds.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub ttft_ms: Option<f64>,
135    /// Time per output token, in milliseconds.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub tpot_ms: Option<f64>,
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub request: Option<RequestMetadata>,
140}
141
142/// One event of a native generation stream.
143#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
144pub struct GenerateChunk {
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub request_id: Option<String>,
147    /// Monotonic within one attempt.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub seq: Option<u64>,
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub text_delta: Option<String>,
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub logprobs: Option<Vec<Value>>,
154    #[serde(default, deserialize_with = "crate::types::null_as_default")]
155    pub done: bool,
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub finish_reason: Option<FinishReason>,
158    /// Present only on the terminal chunk.
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub usage: Option<GenerationUsage>,
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub ttft_ms: Option<f64>,
163}
164
165/// An image referenced by a chat content part.
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(untagged)]
168pub enum ChatImageUrl {
169    /// A bare URL or `data:` URI.
170    Url(String),
171    /// The object form `OpenAI` also accepts.
172    Object { url: String },
173}
174
175/// One part of a multimodal chat message.
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177pub struct ChatContentPart {
178    #[serde(rename = "type")]
179    pub kind: String,
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub text: Option<String>,
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub image_url: Option<ChatImageUrl>,
184}
185
186impl ChatContentPart {
187    /// A text part.
188    pub fn text(text: impl Into<String>) -> Self {
189        Self {
190            kind: "text".to_string(),
191            text: Some(text.into()),
192            image_url: None,
193        }
194    }
195
196    /// An image part, referenced by URL or `data:` URI.
197    pub fn image_url(url: impl Into<String>) -> Self {
198        Self {
199            kind: "image_url".to_string(),
200            text: None,
201            image_url: Some(ChatImageUrl::Url(url.into())),
202        }
203    }
204}
205
206/// Message content: plain text, or an ordered list of parts.
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208#[serde(untagged)]
209pub enum ChatContent {
210    Text(String),
211    Parts(Vec<ChatContentPart>),
212}
213
214/// One message in a chat conversation. Used both in requests and in responses.
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216pub struct ChatMessage {
217    pub role: ChatRole,
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub content: Option<ChatContent>,
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub name: Option<String>,
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub tool_call_id: Option<String>,
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub tool_calls: Option<Vec<Value>>,
226}
227
228impl ChatMessage {
229    fn simple(role: ChatRole, content: impl Into<String>) -> Self {
230        Self {
231            role,
232            content: Some(ChatContent::Text(content.into())),
233            name: None,
234            tool_call_id: None,
235            tool_calls: None,
236        }
237    }
238
239    /// A system prompt.
240    pub fn system(content: impl Into<String>) -> Self {
241        Self::simple(ChatRole::System, content)
242    }
243
244    /// A user turn.
245    pub fn user(content: impl Into<String>) -> Self {
246        Self::simple(ChatRole::User, content)
247    }
248
249    /// An assistant turn.
250    pub fn assistant(content: impl Into<String>) -> Self {
251        Self::simple(ChatRole::Assistant, content)
252    }
253
254    /// A developer instruction.
255    pub fn developer(content: impl Into<String>) -> Self {
256        Self::simple(ChatRole::Developer, content)
257    }
258
259    /// The result of a tool call, answering `tool_call_id`.
260    pub fn tool(tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
261        Self {
262            tool_call_id: Some(tool_call_id.into()),
263            ..Self::simple(ChatRole::Tool, content)
264        }
265    }
266
267    /// A user turn made of ordered parts, for multimodal input.
268    pub fn user_parts(parts: impl IntoIterator<Item = ChatContentPart>) -> Self {
269        Self {
270            role: ChatRole::User,
271            content: Some(ChatContent::Parts(parts.into_iter().collect())),
272            name: None,
273            tool_call_id: None,
274            tool_calls: None,
275        }
276    }
277
278    /// The message text, when the content is plain text.
279    pub fn text(&self) -> Option<&str> {
280        match &self.content {
281            Some(ChatContent::Text(text)) => Some(text),
282            _ => None,
283        }
284    }
285}
286
287/// Token counts for a chat completion.
288#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
289pub struct ChatUsage {
290    #[serde(default, deserialize_with = "crate::types::null_as_default")]
291    pub prompt_tokens: u64,
292    #[serde(default, deserialize_with = "crate::types::null_as_default")]
293    pub completion_tokens: u64,
294    #[serde(default, deserialize_with = "crate::types::null_as_default")]
295    pub total_tokens: u64,
296}
297
298/// One completion candidate.
299#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
300pub struct ChatChoice {
301    #[serde(default, deserialize_with = "crate::types::null_as_default")]
302    pub index: u32,
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub message: Option<ChatMessage>,
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub finish_reason: Option<ChatFinishReason>,
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub logprobs: Option<Value>,
309}
310
311/// A completed chat completion.
312#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
313pub struct ChatCompletion {
314    #[serde(default, deserialize_with = "crate::types::null_as_default")]
315    pub id: String,
316    #[serde(
317        default,
318        deserialize_with = "crate::types::null_as_default",
319        rename = "object"
320    )]
321    pub object_kind: String,
322    #[serde(default, deserialize_with = "crate::types::null_as_default")]
323    pub created: i64,
324    #[serde(default, deserialize_with = "crate::types::null_as_default")]
325    pub model: String,
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub system_fingerprint: Option<String>,
328    #[serde(default, deserialize_with = "crate::types::null_as_default")]
329    pub choices: Vec<ChatChoice>,
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub usage: Option<ChatUsage>,
332    #[serde(default, skip_serializing_if = "Option::is_none")]
333    pub request: Option<RequestMetadata>,
334}
335
336impl ChatCompletion {
337    /// The first choice's text, which is what single-candidate callers want.
338    pub fn text(&self) -> Option<&str> {
339        self.choices.first()?.message.as_ref()?.text()
340    }
341}
342
343/// The incremental part of a streamed choice.
344#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
345pub struct ChatDelta {
346    #[serde(default, skip_serializing_if = "Option::is_none")]
347    pub role: Option<String>,
348    #[serde(default, skip_serializing_if = "Option::is_none")]
349    pub content: Option<String>,
350    #[serde(default, skip_serializing_if = "Option::is_none")]
351    pub tool_calls: Option<Vec<Value>>,
352}
353
354/// One streamed choice.
355#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
356pub struct ChatChunkChoice {
357    #[serde(default, deserialize_with = "crate::types::null_as_default")]
358    pub index: u32,
359    #[serde(default, deserialize_with = "crate::types::null_as_default")]
360    pub delta: ChatDelta,
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub finish_reason: Option<ChatFinishReason>,
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub logprobs: Option<Value>,
365}
366
367/// One event of a chat completion stream.
368///
369/// The terminal usage-only chunk has an empty `choices` list; it appears only when the
370/// request asked for it through `stream_options`.
371#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
372pub struct ChatCompletionChunk {
373    #[serde(default, deserialize_with = "crate::types::null_as_default")]
374    pub id: String,
375    #[serde(
376        default,
377        deserialize_with = "crate::types::null_as_default",
378        rename = "object"
379    )]
380    pub object_kind: String,
381    #[serde(default, deserialize_with = "crate::types::null_as_default")]
382    pub created: i64,
383    #[serde(default, deserialize_with = "crate::types::null_as_default")]
384    pub model: String,
385    #[serde(default, skip_serializing_if = "Option::is_none")]
386    pub system_fingerprint: Option<String>,
387    #[serde(default, deserialize_with = "crate::types::null_as_default")]
388    pub choices: Vec<ChatChunkChoice>,
389    #[serde(default, skip_serializing_if = "Option::is_none")]
390    pub usage: Option<ChatUsage>,
391}
392
393impl ChatCompletionChunk {
394    /// The text this chunk adds, if any.
395    pub fn delta(&self) -> Option<&str> {
396        self.choices.first()?.delta.content.as_deref()
397    }
398}
399
400/// A message in a Responses-API request.
401#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
402pub struct ResponseInputMessage {
403    pub role: ChatRole,
404    pub content: ChatContent,
405}
406
407impl ResponseInputMessage {
408    /// A user turn.
409    pub fn user(content: impl Into<String>) -> Self {
410        Self {
411            role: ChatRole::User,
412            content: ChatContent::Text(content.into()),
413        }
414    }
415
416    /// A system prompt.
417    pub fn system(content: impl Into<String>) -> Self {
418        Self {
419            role: ChatRole::System,
420            content: ChatContent::Text(content.into()),
421        }
422    }
423}
424
425/// One text block of a response output message.
426#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
427pub struct ResponseOutputText {
428    #[serde(rename = "type", default)]
429    pub kind: String,
430    #[serde(default, deserialize_with = "crate::types::null_as_default")]
431    pub text: String,
432    #[serde(default, deserialize_with = "crate::types::null_as_default")]
433    pub annotations: Vec<Value>,
434}
435
436/// One output message of a response.
437#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
438pub struct ResponseOutputMessage {
439    #[serde(rename = "type", default)]
440    pub kind: String,
441    #[serde(default, deserialize_with = "crate::types::null_as_default")]
442    pub id: String,
443    #[serde(default, deserialize_with = "crate::types::null_as_default")]
444    pub role: String,
445    #[serde(default, deserialize_with = "crate::types::null_as_default")]
446    pub status: String,
447    #[serde(default, deserialize_with = "crate::types::null_as_default")]
448    pub content: Vec<ResponseOutputText>,
449}
450
451/// Token counts for a response.
452///
453/// The key names differ from [`ChatUsage`]: this is the Responses API's own vocabulary.
454#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
455pub struct ResponseUsage {
456    #[serde(default, deserialize_with = "crate::types::null_as_default")]
457    pub input_tokens: u64,
458    #[serde(default, deserialize_with = "crate::types::null_as_default")]
459    pub output_tokens: u64,
460    #[serde(default, deserialize_with = "crate::types::null_as_default")]
461    pub total_tokens: u64,
462}
463
464/// A completed response.
465#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
466pub struct ResponseResult {
467    #[serde(default, deserialize_with = "crate::types::null_as_default")]
468    pub id: String,
469    #[serde(
470        default,
471        deserialize_with = "crate::types::null_as_default",
472        rename = "object"
473    )]
474    pub object_kind: String,
475    #[serde(default, deserialize_with = "crate::types::null_as_default")]
476    pub created_at: i64,
477    #[serde(default, deserialize_with = "crate::types::null_as_default")]
478    pub model: String,
479    #[serde(default, deserialize_with = "crate::types::null_as_default")]
480    pub status: String,
481    #[serde(default, deserialize_with = "crate::types::null_as_default")]
482    pub output: Vec<ResponseOutputMessage>,
483    #[serde(default, skip_serializing_if = "Option::is_none")]
484    pub usage: Option<ResponseUsage>,
485    #[serde(default, skip_serializing_if = "Option::is_none")]
486    pub request: Option<RequestMetadata>,
487}
488
489impl ResponseResult {
490    /// The first output message's text.
491    pub fn text(&self) -> Option<&str> {
492        Some(self.output.first()?.content.first()?.text.as_str())
493    }
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499    use serde_json::json;
500
501    #[test]
502    fn grammar_serializes_to_exactly_one_arm() {
503        assert_eq!(
504            serde_json::to_value(Grammar::json_schema(json!({"type": "object"}))).unwrap(),
505            json!({"json_schema": {"type": "object"}})
506        );
507        assert_eq!(
508            serde_json::to_value(Grammar::regex("[0-9]+")).unwrap(),
509            json!({"regex": "[0-9]+"})
510        );
511        assert_eq!(
512            serde_json::to_value(Grammar::ebnf("root ::= \"a\"")).unwrap(),
513            json!({"ebnf": "root ::= \"a\""})
514        );
515    }
516
517    #[test]
518    fn grammar_carries_optional_label_and_strict() {
519        let grammar = Grammar::Regex {
520            regex: "a+".to_string(),
521            label: Some("digits".to_string()),
522            strict: Some(true),
523        };
524        assert_eq!(
525            serde_json::to_value(grammar).unwrap(),
526            json!({"regex": "a+", "label": "digits", "strict": true})
527        );
528    }
529
530    #[test]
531    fn chat_messages_serialize_only_what_was_set() {
532        assert_eq!(
533            serde_json::to_value(ChatMessage::user("hi")).unwrap(),
534            json!({"role": "user", "content": "hi"})
535        );
536        assert_eq!(
537            serde_json::to_value(ChatMessage::tool("call_1", "42")).unwrap(),
538            json!({"role": "tool", "content": "42", "tool_call_id": "call_1"})
539        );
540    }
541
542    #[test]
543    fn multimodal_messages_keep_their_part_order() {
544        let message = ChatMessage::user_parts([
545            ChatContentPart::text("What is this?"),
546            ChatContentPart::image_url("https://example.com/a.png"),
547        ]);
548        assert_eq!(
549            serde_json::to_value(message).unwrap(),
550            json!({"role": "user", "content": [
551                {"type": "text", "text": "What is this?"},
552                {"type": "image_url", "image_url": "https://example.com/a.png"}
553            ]})
554        );
555    }
556
557    #[test]
558    fn completions_expose_their_first_choice_text() {
559        let completion: ChatCompletion = serde_json::from_value(json!({
560            "id": "cmpl-1", "object": "chat.completion", "created": 1, "model": "m",
561            "choices": [{"index": 0, "message": {"role": "assistant", "content": "hello"},
562                         "finish_reason": "stop"}],
563            "usage": {"prompt_tokens": 3, "completion_tokens": 1, "total_tokens": 4}
564        }))
565        .unwrap();
566        assert_eq!(completion.text(), Some("hello"));
567        assert_eq!(completion.object_kind, "chat.completion");
568        assert_eq!(
569            completion.choices[0].finish_reason,
570            Some(ChatFinishReason::Stop)
571        );
572        assert_eq!(completion.usage.unwrap().total_tokens, 4);
573    }
574
575    #[test]
576    fn stream_chunks_expose_their_delta_and_tolerate_the_usage_only_tail() {
577        let chunk: ChatCompletionChunk = serde_json::from_value(json!({
578            "id": "cmpl-1", "choices": [{"index": 0, "delta": {"content": "he"}}]
579        }))
580        .unwrap();
581        assert_eq!(chunk.delta(), Some("he"));
582
583        let tail: ChatCompletionChunk = serde_json::from_value(json!({
584            "id": "cmpl-1", "choices": [],
585            "usage": {"prompt_tokens": 3, "completion_tokens": 1, "total_tokens": 4}
586        }))
587        .unwrap();
588        assert!(tail.delta().is_none());
589        assert_eq!(tail.usage.unwrap().completion_tokens, 1);
590    }
591
592    #[test]
593    fn responses_expose_their_first_output_text() {
594        let result: ResponseResult = serde_json::from_value(json!({
595            "id": "resp-1", "object": "response", "model": "m", "status": "completed",
596            "output": [{"type": "message", "id": "m1", "role": "assistant", "status": "completed",
597                        "content": [{"type": "output_text", "text": "answer", "annotations": []}]}],
598            "usage": {"input_tokens": 2, "output_tokens": 1, "total_tokens": 3}
599        }))
600        .unwrap();
601        assert_eq!(result.text(), Some("answer"));
602        assert_eq!(result.usage.unwrap().input_tokens, 2);
603    }
604
605    #[test]
606    fn native_generate_results_decode() {
607        let result: GenerateResult = serde_json::from_value(json!({
608            "model": "m", "text": "out", "finish_reason": "length",
609            "usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3,
610                      "credits_charged": 7, "rate_book_version": "2026-01"},
611            "ttft_ms": 12.5
612        }))
613        .unwrap();
614        assert_eq!(result.finish_reason, Some(FinishReason::Length));
615        assert_eq!(result.usage.unwrap().credits_charged, Some(7));
616        assert_eq!(result.ttft_ms, Some(12.5));
617    }
618}