Skip to main content

llmleaf_client/
types.rs

1//! Public SDK types — serde structs that mirror `proto/llmleaf/v1/llmleaf.proto` and
2//! serialise to / parse from the exact OpenAI/OpenRouter-shaped JSON the gateway speaks
3//! (see SPEC.md).
4//!
5//! Why not the prost-generated types? Prost emits protobuf-binary-shaped types: oneofs
6//! become Rust enums with no JSON union behaviour, proto enums are `i32`, and there is
7//! no OpenAI casing. These structs are the wire model; [`crate::pb`] is the codegen
8//! proof / canonical-proto mirror.
9//!
10//! ## Enum ⇄ wire mapping
11//!
12//! SPEC.md asks for a single `enumToWire`/`enumFromWire` pair that lowercases the enum
13//! value name. In serde the mechanical equivalent is `#[serde(rename_all =
14//! "snake_case")]` on every closed-set enum: it turns `TOOL_CALLS` → `"tool_calls"`,
15//! `IN_PROGRESS` → `"in_progress"`, etc. The proto `*_UNSPECIFIED` zero value ⇔ field
16//! absent is modelled by making the field `Option<_>` and skipping it when `None`.
17//!
18//! ## Free-form JSON fields
19//!
20//! `ChatRequest.extra`, `FunctionDef.parameters`, `ResponseFormat.json_schema`,
21//! `EmbeddingRequest.extra`, `SpeechRequest.extra`, `ModelEntry.default_parameters`
22//! are carried as [`serde_json::Value`] (a raw JSON value), so they are spliced into the
23//! body verbatim on encode and captured back as a sub-object on decode — never
24//! double-encoded as a string. `ChatRequest.extra` is additionally flattened so its keys
25//! merge at the top level of the request object.
26
27use serde::{Deserialize, Serialize};
28
29// ---------------------------------------------------------------------------
30// Common
31// ---------------------------------------------------------------------------
32
33/// Token accounting echoed on every response. `cost_usd` is an llmleaf addition and is
34/// absent when the model has no known price.
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36pub struct Usage {
37    #[serde(default)]
38    pub prompt_tokens: u32,
39    #[serde(default)]
40    pub completion_tokens: u32,
41    #[serde(default)]
42    pub total_tokens: u32,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub cost_usd: Option<f64>,
45    /// Prompt-cache hit accounting (OpenAI `usage.prompt_tokens_details`). Absent when the upstream
46    /// reported no caching; [`Usage::cached_tokens`] flattens it to a plain count.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub prompt_tokens_details: Option<PromptTokensDetails>,
49    /// Input tokens written to the provider's prompt cache this request — a cache *write* (creation).
50    /// An llmleaf extension (Anthropic reports it; OpenAI/OpenRouter do not); absent when there were none.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub cache_creation_tokens: Option<u32>,
53}
54
55impl Usage {
56    /// Prompt tokens served from the provider's cache this request — a cache *read* (hit). `0` when
57    /// the upstream reported no caching.
58    pub fn cached_tokens(&self) -> u32 {
59        self.prompt_tokens_details
60            .as_ref()
61            .and_then(|d| d.cached_tokens)
62            .unwrap_or(0)
63    }
64
65    /// Input tokens written to the provider's cache this request — a cache *write* (creation). `0`
66    /// when there were none (or the provider does not report writes).
67    pub fn cache_writes(&self) -> u32 {
68        self.cache_creation_tokens.unwrap_or(0)
69    }
70}
71
72/// Breakdown of [`Usage::prompt_tokens`]. Today only the cache-read (hit) share is surfaced — the
73/// count of prompt tokens served from the provider's cache rather than processed fresh.
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75pub struct PromptTokensDetails {
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub cached_tokens: Option<u32>,
78}
79
80// ---------------------------------------------------------------------------
81// Enums (closed-set; snake_case wire tokens; absent == unspecified)
82// ---------------------------------------------------------------------------
83
84/// Message author role.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub enum Role {
88    System,
89    User,
90    Assistant,
91    Tool,
92}
93
94/// Why the model stopped generating.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "snake_case")]
97pub enum FinishReason {
98    Stop,
99    Length,
100    ToolCalls,
101    ContentFilter,
102}
103
104/// Batch lifecycle status.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "snake_case")]
107pub enum BatchStatus {
108    Validating,
109    InProgress,
110    Finalizing,
111    Completed,
112    Failed,
113    Expired,
114    Canceling,
115    Canceled,
116}
117
118// ---------------------------------------------------------------------------
119// Chat — content parts (multimodal)
120// ---------------------------------------------------------------------------
121
122/// `{"url":"...","detail":"auto"}`.
123#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
124pub struct ImageUrl {
125    pub url: String,
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub detail: Option<String>,
128}
129
130/// A single content part of a multimodal message.
131///
132/// Serialises to the tagged OpenAI shapes:
133/// `{"type":"text","text":"..."}` and
134/// `{"type":"image_url","image_url":{...}}`.
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
136#[serde(tag = "type", rename_all = "snake_case")]
137pub enum ContentPart {
138    Text { text: String },
139    ImageUrl { image_url: ImageUrl },
140}
141
142impl ContentPart {
143    /// Convenience constructor for a text part.
144    pub fn text(text: impl Into<String>) -> Self {
145        ContentPart::Text { text: text.into() }
146    }
147
148    /// Convenience constructor for an image-url part.
149    pub fn image_url(url: impl Into<String>) -> Self {
150        ContentPart::ImageUrl {
151            image_url: ImageUrl {
152                url: url.into(),
153                detail: None,
154            },
155        }
156    }
157}
158
159/// Message `content`: a plain string when there is only text, else an array of parts.
160/// Untagged so it serialises as a bare string or a bare array, matching the wire.
161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
162#[serde(untagged)]
163pub enum Content {
164    Text(String),
165    Parts(Vec<ContentPart>),
166}
167
168impl From<String> for Content {
169    fn from(s: String) -> Self {
170        Content::Text(s)
171    }
172}
173
174impl From<&str> for Content {
175    fn from(s: &str) -> Self {
176        Content::Text(s.to_string())
177    }
178}
179
180impl From<Vec<ContentPart>> for Content {
181    fn from(p: Vec<ContentPart>) -> Self {
182        Content::Parts(p)
183    }
184}
185
186// ---------------------------------------------------------------------------
187// Chat — tool calls
188// ---------------------------------------------------------------------------
189
190/// A function the model called. `arguments` is a JSON-encoded string (OpenAI shape).
191#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
192pub struct FunctionCall {
193    pub name: String,
194    pub arguments: String,
195}
196
197/// A tool call emitted by the model.
198#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
199pub struct ToolCall {
200    pub id: String,
201    #[serde(rename = "type")]
202    pub kind: String,
203    pub function: FunctionCall,
204}
205
206/// Incremental tool-call fragment on a streaming delta.
207#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
208pub struct FunctionCallDelta {
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub name: Option<String>,
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub arguments: Option<String>,
213}
214
215/// Streaming tool-call delta; fields arrive piecemeal.
216#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
217pub struct ToolCallDelta {
218    pub index: u32,
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub id: Option<String>,
221    #[serde(default, skip_serializing_if = "Option::is_none", rename = "type")]
222    pub kind: Option<String>,
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub function: Option<FunctionCallDelta>,
225}
226
227// ---------------------------------------------------------------------------
228// Chat — reasoning ("thinking") blocks
229// ---------------------------------------------------------------------------
230
231/// One structured reasoning ("thinking") block (OpenRouter `reasoning_details[]`). It expresses both
232/// *open* reasoning — visible text, optionally signed — and *hidden* reasoning — an encrypted/redacted
233/// blob the provider returns in place of the text. `kind` (wire `type`) is the discriminator:
234///
235/// - `"reasoning.text"` → [`text`](Self::text) (+ optional [`signature`](Self::signature)) — **open**
236/// - `"reasoning.summary"` → [`summary`](Self::summary) — **open** (a summarised view)
237/// - `"reasoning.encrypted"` → [`data`](Self::data) — **hidden** (redacted / opaque)
238///
239/// `signature` and `data` are opaque and MUST be sent back verbatim in the next request's
240/// `reasoning_details` to continue a signed/encrypted reasoning turn (the upstream rejects an altered
241/// or dropped block — e.g. before a tool call). Use [`is_hidden`](Self::is_hidden) /
242/// [`open_text`](Self::open_text) to branch without matching on the raw `kind` string.
243#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
244pub struct ReasoningDetail {
245    #[serde(rename = "type")]
246    pub kind: String,
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub text: Option<String>,
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub summary: Option<String>,
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub data: Option<String>,
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub signature: Option<String>,
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    pub id: Option<String>,
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub format: Option<String>,
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub index: Option<u32>,
261}
262
263impl ReasoningDetail {
264    /// Whether this block is hidden (redacted / encrypted) rather than open visible reasoning.
265    pub fn is_hidden(&self) -> bool {
266        self.kind == "reasoning.encrypted" || (self.data.is_some() && self.text.is_none())
267    }
268
269    /// The visible reasoning text of an open block — its `text`, falling back to its `summary`.
270    /// `None` for a hidden block.
271    pub fn open_text(&self) -> Option<&str> {
272        self.text.as_deref().or(self.summary.as_deref())
273    }
274}
275
276// ---------------------------------------------------------------------------
277// Chat — messages
278// ---------------------------------------------------------------------------
279
280/// A chat message (request or response).
281#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
282pub struct ChatMessage {
283    pub role: Role,
284    /// `content` is a plain string or an array of content parts. Optional because an
285    /// assistant message that only calls tools may omit it.
286    #[serde(default, skip_serializing_if = "Option::is_none")]
287    pub content: Option<Content>,
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub name: Option<String>,
290    #[serde(default, skip_serializing_if = "Vec::is_empty")]
291    pub tool_calls: Vec<ToolCall>,
292    /// Set when `role == Tool`.
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub tool_call_id: Option<String>,
295    /// Open reasoning text the assistant emitted (OpenRouter `reasoning`), if any. The flat,
296    /// human-readable form; the structured [`reasoning_details`](Self::reasoning_details) is the
297    /// replay-safe one.
298    #[serde(default, skip_serializing_if = "Option::is_none")]
299    pub reasoning: Option<String>,
300    /// Structured reasoning blocks (open and hidden, with signatures — see [`ReasoningDetail`]). Echo
301    /// these back verbatim on the next request to preserve signed reasoning across a turn.
302    #[serde(default, skip_serializing_if = "Vec::is_empty")]
303    pub reasoning_details: Vec<ReasoningDetail>,
304}
305
306impl ChatMessage {
307    /// A `system` message with text content.
308    pub fn system(text: impl Into<String>) -> Self {
309        Self::with_text(Role::System, text)
310    }
311
312    /// A `user` message with text content.
313    pub fn user(text: impl Into<String>) -> Self {
314        Self::with_text(Role::User, text)
315    }
316
317    /// An `assistant` message with text content.
318    pub fn assistant(text: impl Into<String>) -> Self {
319        Self::with_text(Role::Assistant, text)
320    }
321
322    fn with_text(role: Role, text: impl Into<String>) -> Self {
323        ChatMessage {
324            role,
325            content: Some(Content::Text(text.into())),
326            name: None,
327            tool_calls: Vec::new(),
328            tool_call_id: None,
329            reasoning: None,
330            reasoning_details: Vec::new(),
331        }
332    }
333
334    /// Accumulated plain-text content, if the message carries a text body.
335    pub fn text(&self) -> Option<&str> {
336        match &self.content {
337            Some(Content::Text(s)) => Some(s.as_str()),
338            _ => None,
339        }
340    }
341
342    /// The visible (open) reasoning for this message: the flat `reasoning` text if present, else the
343    /// concatenation of the open `reasoning_details` blocks. `None` when the turn carried no visible
344    /// reasoning (it may still carry hidden blocks — see [`reasoning_details`](Self::reasoning_details)).
345    pub fn reasoning_text(&self) -> Option<String> {
346        if let Some(r) = &self.reasoning {
347            return Some(r.clone());
348        }
349        let joined: String = self
350            .reasoning_details
351            .iter()
352            .filter_map(ReasoningDetail::open_text)
353            .collect();
354        (!joined.is_empty()).then_some(joined)
355    }
356}
357
358// ---------------------------------------------------------------------------
359// Chat — tool / response-format definitions
360// ---------------------------------------------------------------------------
361
362/// A function the model MAY call. `parameters` is a raw JSON Schema value.
363#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
364pub struct FunctionDef {
365    pub name: String,
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    pub description: Option<String>,
368    /// Raw JSON object spliced verbatim.
369    #[serde(default, skip_serializing_if = "Option::is_none")]
370    pub parameters: Option<serde_json::Value>,
371}
372
373/// A tool definition (`{"type":"function","function":{...}}`).
374#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
375pub struct ToolDef {
376    #[serde(rename = "type")]
377    pub kind: String,
378    pub function: FunctionDef,
379}
380
381impl ToolDef {
382    /// A `function`-typed tool definition.
383    pub fn function(function: FunctionDef) -> Self {
384        ToolDef {
385            kind: "function".to_string(),
386            function,
387        }
388    }
389}
390
391/// Pin a specific function: `{"name":"..."}`.
392#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
393pub struct FunctionName {
394    pub name: String,
395}
396
397/// Named tool choice: `{"type":"function","function":{"name":"..."}}`.
398#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
399pub struct NamedToolChoice {
400    #[serde(rename = "type")]
401    pub kind: String,
402    pub function: FunctionName,
403}
404
405/// `tool_choice`: a bare mode string (`"auto"`/`"none"`/`"required"`) or a named object.
406/// Untagged so it serialises as a bare string or the object, matching the wire.
407#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
408#[serde(untagged)]
409pub enum ToolChoice {
410    Mode(String),
411    Named(NamedToolChoice),
412}
413
414impl ToolChoice {
415    /// `"auto"`, `"none"` or `"required"`.
416    pub fn mode(mode: impl Into<String>) -> Self {
417        ToolChoice::Mode(mode.into())
418    }
419
420    /// Pin a named function.
421    pub fn named(name: impl Into<String>) -> Self {
422        ToolChoice::Named(NamedToolChoice {
423            kind: "function".to_string(),
424            function: FunctionName { name: name.into() },
425        })
426    }
427}
428
429/// `response_format`: `{"type":"text"|"json_object"|"json_schema","json_schema":{...}}`.
430#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
431pub struct ResponseFormat {
432    #[serde(rename = "type")]
433    pub kind: String,
434    /// Raw JSON object, present when `type == "json_schema"`.
435    #[serde(default, skip_serializing_if = "Option::is_none")]
436    pub json_schema: Option<serde_json::Value>,
437}
438
439// ---------------------------------------------------------------------------
440// Chat — request
441// ---------------------------------------------------------------------------
442
443/// `stop`: a bare string for one element, else an array (the wire accepts either; we
444/// emit a string for a single element and an array otherwise). Untagged.
445#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
446#[serde(untagged)]
447pub enum Stop {
448    One(String),
449    Many(Vec<String>),
450}
451
452impl Stop {
453    /// Build the wire-appropriate `stop` from a list: a bare string for exactly one
454    /// element, an array otherwise. Returns `None` for an empty list.
455    pub fn from_vec(mut v: Vec<String>) -> Option<Self> {
456        match v.len() {
457            0 => None,
458            1 => Some(Stop::One(v.pop().unwrap())),
459            _ => Some(Stop::Many(v)),
460        }
461    }
462}
463
464/// `POST /v1/chat/completions` request body.
465#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
466pub struct ChatRequest {
467    pub model: String,
468    pub messages: Vec<ChatMessage>,
469    #[serde(default, skip_serializing_if = "Option::is_none")]
470    pub stream: Option<bool>,
471    #[serde(default, skip_serializing_if = "Option::is_none")]
472    pub temperature: Option<f32>,
473    #[serde(default, skip_serializing_if = "Option::is_none")]
474    pub top_p: Option<f32>,
475    /// Legacy token cap. Prefer `max_completion_tokens`; this is still sent when the
476    /// caller set only it.
477    #[serde(default, skip_serializing_if = "Option::is_none")]
478    pub max_tokens: Option<u32>,
479    /// Modern token cap (takes precedence on the wire).
480    #[serde(default, skip_serializing_if = "Option::is_none")]
481    pub max_completion_tokens: Option<u32>,
482    #[serde(default, skip_serializing_if = "Option::is_none")]
483    pub stop: Option<Stop>,
484    #[serde(default, skip_serializing_if = "Option::is_none")]
485    pub n: Option<u32>,
486    #[serde(default, skip_serializing_if = "Option::is_none")]
487    pub seed: Option<i64>,
488    #[serde(default, skip_serializing_if = "Option::is_none")]
489    pub frequency_penalty: Option<f32>,
490    #[serde(default, skip_serializing_if = "Option::is_none")]
491    pub presence_penalty: Option<f32>,
492    #[serde(default, skip_serializing_if = "Vec::is_empty")]
493    pub tools: Vec<ToolDef>,
494    #[serde(default, skip_serializing_if = "Option::is_none")]
495    pub tool_choice: Option<ToolChoice>,
496    #[serde(default, skip_serializing_if = "Option::is_none")]
497    pub response_format: Option<ResponseFormat>,
498    /// `"low" | "medium" | "high"`.
499    #[serde(default, skip_serializing_if = "Option::is_none")]
500    pub reasoning_effort: Option<String>,
501    /// Dialect-specific passthrough. Its keys are merged at the top level of the request
502    /// object (P7 transparent passthrough), so it is `#[serde(flatten)]`ed here.
503    #[serde(default, flatten, skip_serializing_if = "Option::is_none")]
504    pub extra: Option<serde_json::Map<String, serde_json::Value>>,
505}
506
507impl ChatRequest {
508    /// Start a chat request for `model` with `messages`.
509    pub fn new(model: impl Into<String>, messages: Vec<ChatMessage>) -> Self {
510        ChatRequest {
511            model: model.into(),
512            messages,
513            ..Default::default()
514        }
515    }
516}
517
518// ---------------------------------------------------------------------------
519// Chat — non-streaming response
520// ---------------------------------------------------------------------------
521
522/// One completion choice.
523#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
524pub struct Choice {
525    #[serde(default)]
526    pub index: u32,
527    pub message: ChatMessage,
528    #[serde(default, skip_serializing_if = "Option::is_none")]
529    pub finish_reason: Option<FinishReason>,
530}
531
532/// `POST /v1/chat/completions` non-streaming response (`object:"chat.completion"`).
533#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
534pub struct ChatResponse {
535    pub id: String,
536    pub object: String,
537    pub created: i64,
538    pub model: String,
539    pub choices: Vec<Choice>,
540    #[serde(default, skip_serializing_if = "Option::is_none")]
541    pub usage: Option<Usage>,
542}
543
544impl ChatResponse {
545    /// Plain text of the first choice, if any.
546    pub fn first_text(&self) -> Option<&str> {
547        self.choices.first().and_then(|c| c.message.text())
548    }
549}
550
551// ---------------------------------------------------------------------------
552// Chat — streaming chunk
553// ---------------------------------------------------------------------------
554
555/// Incremental delta on a streaming choice.
556#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
557pub struct Delta {
558    /// First chunk only.
559    #[serde(default, skip_serializing_if = "Option::is_none")]
560    pub role: Option<Role>,
561    /// Incremental text.
562    #[serde(default, skip_serializing_if = "Option::is_none")]
563    pub content: Option<String>,
564    #[serde(default, skip_serializing_if = "Vec::is_empty")]
565    pub tool_calls: Vec<ToolCallDelta>,
566    /// Incremental open reasoning text, if any.
567    #[serde(default, skip_serializing_if = "Option::is_none")]
568    pub reasoning: Option<String>,
569    /// Incremental structured reasoning blocks (open / hidden — see [`ReasoningDetail`]).
570    #[serde(default, skip_serializing_if = "Vec::is_empty")]
571    pub reasoning_details: Vec<ReasoningDetail>,
572}
573
574/// One streaming choice.
575#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
576pub struct ChunkChoice {
577    #[serde(default)]
578    pub index: u32,
579    pub delta: Delta,
580    #[serde(default, skip_serializing_if = "Option::is_none")]
581    pub finish_reason: Option<FinishReason>,
582}
583
584/// A streaming SSE frame (`object:"chat.completion.chunk"`).
585#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
586pub struct ChatCompletionChunk {
587    pub id: String,
588    pub object: String,
589    pub created: i64,
590    pub model: String,
591    pub choices: Vec<ChunkChoice>,
592    /// Terminal chunk only.
593    #[serde(default, skip_serializing_if = "Option::is_none")]
594    pub usage: Option<Usage>,
595}
596
597impl ChatCompletionChunk {
598    /// The incremental text of the first choice's delta, if any.
599    pub fn first_delta_text(&self) -> Option<&str> {
600        self.choices
601            .first()
602            .and_then(|c| c.delta.content.as_deref())
603    }
604
605    /// The incremental open reasoning text of the first choice's delta, if any.
606    pub fn first_delta_reasoning(&self) -> Option<&str> {
607        self.choices
608            .first()
609            .and_then(|c| c.delta.reasoning.as_deref())
610    }
611}
612
613// ---------------------------------------------------------------------------
614// Embeddings
615// ---------------------------------------------------------------------------
616
617/// `input`: the wire accepts a bare string or an array of strings. Untagged.
618#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
619#[serde(untagged)]
620pub enum EmbeddingInput {
621    One(String),
622    Many(Vec<String>),
623}
624
625impl From<String> for EmbeddingInput {
626    fn from(s: String) -> Self {
627        EmbeddingInput::One(s)
628    }
629}
630
631impl From<&str> for EmbeddingInput {
632    fn from(s: &str) -> Self {
633        EmbeddingInput::One(s.to_string())
634    }
635}
636
637impl From<Vec<String>> for EmbeddingInput {
638    fn from(v: Vec<String>) -> Self {
639        EmbeddingInput::Many(v)
640    }
641}
642
643/// `POST /v1/embeddings` request body.
644#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
645pub struct EmbeddingRequest {
646    pub model: String,
647    pub input: EmbeddingInput,
648    #[serde(default, skip_serializing_if = "Option::is_none")]
649    pub dimensions: Option<u32>,
650    /// `"float"` | `"base64"`. When `"base64"`, the transport decodes each embedding back
651    /// into a float vector before returning (see SPEC.md).
652    #[serde(default, skip_serializing_if = "Option::is_none")]
653    pub encoding_format: Option<String>,
654    /// Raw JSON object passthrough.
655    #[serde(default, skip_serializing_if = "Option::is_none")]
656    pub extra: Option<serde_json::Value>,
657}
658
659impl EmbeddingRequest {
660    /// Start an embedding request.
661    pub fn new(model: impl Into<String>, input: impl Into<EmbeddingInput>) -> Self {
662        EmbeddingRequest {
663            model: model.into(),
664            input: input.into(),
665            dimensions: None,
666            encoding_format: None,
667            extra: None,
668        }
669    }
670}
671
672/// A single embedding vector. The transport always presents `embedding` as floats, even
673/// when the wire carried base64.
674#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
675pub struct Embedding {
676    pub object: String,
677    #[serde(default)]
678    pub index: u32,
679    pub embedding: Vec<f32>,
680}
681
682/// `POST /v1/embeddings` response (`object:"list"`).
683#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
684pub struct EmbeddingResponse {
685    pub object: String,
686    pub data: Vec<Embedding>,
687    pub model: String,
688    #[serde(default, skip_serializing_if = "Option::is_none")]
689    pub usage: Option<Usage>,
690}
691
692// ---------------------------------------------------------------------------
693// Audio — speech / voices
694// ---------------------------------------------------------------------------
695
696/// `POST /v1/audio/speech` request body.
697#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
698pub struct SpeechRequest {
699    pub model: String,
700    pub input: String,
701    pub voice: String,
702    /// `mp3|opus|aac|flac|wav|pcm`.
703    #[serde(default, skip_serializing_if = "Option::is_none")]
704    pub response_format: Option<String>,
705    #[serde(default, skip_serializing_if = "Option::is_none")]
706    pub speed: Option<f32>,
707    /// Raw JSON object passthrough.
708    #[serde(default, skip_serializing_if = "Option::is_none")]
709    pub extra: Option<serde_json::Value>,
710}
711
712impl SpeechRequest {
713    /// Start a speech request.
714    pub fn new(
715        model: impl Into<String>,
716        input: impl Into<String>,
717        voice: impl Into<String>,
718    ) -> Self {
719        SpeechRequest {
720            model: model.into(),
721            input: input.into(),
722            voice: voice.into(),
723            response_format: None,
724            speed: None,
725            extra: None,
726        }
727    }
728}
729
730/// A TTS voice.
731#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
732pub struct Voice {
733    pub id: String,
734    #[serde(default, skip_serializing_if = "Option::is_none")]
735    pub name: Option<String>,
736    #[serde(default, skip_serializing_if = "Vec::is_empty")]
737    pub languages: Vec<String>,
738}
739
740/// `GET /v1/audio/voices` response.
741#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
742pub struct VoicesResponse {
743    pub model: String,
744    pub voices: Vec<Voice>,
745}
746
747// ---------------------------------------------------------------------------
748// Audio — transcriptions (STT)
749// ---------------------------------------------------------------------------
750
751/// The accompanying form fields for `POST /v1/audio/transcriptions`. The audio bytes are
752/// the multipart `file` part and are passed separately to the SDK call.
753#[derive(Debug, Clone, PartialEq, Default)]
754pub struct TranscriptionRequest {
755    pub model: String,
756    /// ISO-639-1 hint.
757    pub language: Option<String>,
758    /// Decoding bias.
759    pub prompt: Option<String>,
760    /// `json|text|verbose_json|srt|vtt`.
761    pub response_format: Option<String>,
762    pub temperature: Option<f32>,
763}
764
765impl TranscriptionRequest {
766    /// Start a transcription request for `model`.
767    pub fn new(model: impl Into<String>) -> Self {
768        TranscriptionRequest {
769            model: model.into(),
770            ..Default::default()
771        }
772    }
773}
774
775/// Structured transcription result (for `response_format` json / verbose_json).
776#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
777pub struct TranscriptionResponse {
778    pub text: String,
779    #[serde(default, skip_serializing_if = "Option::is_none")]
780    pub task: Option<String>,
781    #[serde(default, skip_serializing_if = "Option::is_none")]
782    pub language: Option<String>,
783    #[serde(default, skip_serializing_if = "Option::is_none")]
784    pub duration: Option<f32>,
785    #[serde(default, skip_serializing_if = "Option::is_none")]
786    pub usage: Option<Usage>,
787}
788
789/// Result of a transcription: a structured object (json/verbose_json) or a plain-text
790/// body (text/srt/vtt) — SPEC.md returns text directly for the latter.
791#[derive(Debug, Clone, PartialEq)]
792pub enum Transcription {
793    Json(TranscriptionResponse),
794    Text(String),
795}
796
797// ---------------------------------------------------------------------------
798// Model catalog
799// ---------------------------------------------------------------------------
800
801/// Model architecture metadata.
802#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
803pub struct Architecture {
804    #[serde(default, skip_serializing_if = "Vec::is_empty")]
805    pub input_modalities: Vec<String>,
806    #[serde(default, skip_serializing_if = "Vec::is_empty")]
807    pub output_modalities: Vec<String>,
808    #[serde(default, skip_serializing_if = "Option::is_none")]
809    pub modality: Option<String>,
810    #[serde(default)]
811    pub tokenizer: String,
812    #[serde(default, skip_serializing_if = "Option::is_none")]
813    pub instruct_type: Option<String>,
814}
815
816/// Per-token pricing (decimal strings, USD).
817#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
818pub struct Pricing {
819    pub prompt: String,
820    pub completion: String,
821}
822
823/// Top-provider capabilities for a model.
824#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
825pub struct TopProvider {
826    #[serde(default, skip_serializing_if = "Option::is_none")]
827    pub context_length: Option<u32>,
828    #[serde(default, skip_serializing_if = "Option::is_none")]
829    pub max_completion_tokens: Option<u32>,
830    #[serde(default)]
831    pub is_moderated: bool,
832    #[serde(default, skip_serializing_if = "Option::is_none")]
833    pub max_thinking_tokens: Option<u32>,
834}
835
836/// Admin-only fallback-chain entry (present only with a valid `x-admin-token`).
837#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
838pub struct ModelEndpoint {
839    pub provider: String,
840    pub model: String,
841    #[serde(default)]
842    pub down: bool,
843    /// `"route" | "prefix"`.
844    pub source: String,
845}
846
847/// One model in the catalog.
848#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
849pub struct ModelEntry {
850    pub id: String,
851    #[serde(default)]
852    pub canonical_slug: String,
853    #[serde(default)]
854    pub name: String,
855    #[serde(default)]
856    pub created: i64,
857    #[serde(default)]
858    pub description: String,
859    #[serde(default, skip_serializing_if = "Option::is_none")]
860    pub context_length: Option<u32>,
861    #[serde(default, skip_serializing_if = "Option::is_none")]
862    pub architecture: Option<Architecture>,
863    #[serde(default, skip_serializing_if = "Option::is_none")]
864    pub pricing: Option<Pricing>,
865    #[serde(default, skip_serializing_if = "Option::is_none")]
866    pub top_provider: Option<TopProvider>,
867    #[serde(default, skip_serializing_if = "Vec::is_empty")]
868    pub supported_parameters: Vec<String>,
869    #[serde(default, skip_serializing_if = "Vec::is_empty")]
870    pub unsupported_parameters: Vec<String>,
871    /// Raw JSON object.
872    #[serde(default, skip_serializing_if = "Option::is_none")]
873    pub default_parameters: Option<serde_json::Value>,
874    /// Admin-only.
875    #[serde(default, skip_serializing_if = "Vec::is_empty")]
876    pub endpoints: Vec<ModelEndpoint>,
877}
878
879/// `GET /v1/models` response.
880#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
881pub struct ListModelsResponse {
882    #[serde(default)]
883    pub data: Vec<ModelEntry>,
884}
885
886/// `type` query filter for `GET /v1/models`.
887#[derive(Debug, Clone, Copy, PartialEq, Eq)]
888pub enum ModelType {
889    All,
890    Llm,
891    Tts,
892    Stt,
893    Embedding,
894}
895
896impl ModelType {
897    pub(crate) fn as_str(self) -> &'static str {
898        match self {
899            ModelType::All => "all",
900            ModelType::Llm => "llm",
901            ModelType::Tts => "tts",
902            ModelType::Stt => "stt",
903            ModelType::Embedding => "embedding",
904        }
905    }
906}
907
908// ---------------------------------------------------------------------------
909// Batches
910// ---------------------------------------------------------------------------
911
912/// One item in a batch create request: a `custom_id` plus a `ChatRequest` body.
913#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
914pub struct BatchRequestItem {
915    pub custom_id: String,
916    pub body: ChatRequest,
917}
918
919/// `POST /v1/batches` request body.
920#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
921pub struct BatchCreateRequest {
922    pub requests: Vec<BatchRequestItem>,
923}
924
925/// Aggregate counts on a batch handle.
926#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
927pub struct BatchCounts {
928    #[serde(default)]
929    pub total: u64,
930    #[serde(default)]
931    pub processing: u64,
932    #[serde(default)]
933    pub succeeded: u64,
934    #[serde(default)]
935    pub errored: u64,
936    #[serde(default)]
937    pub canceled: u64,
938    #[serde(default)]
939    pub expired: u64,
940}
941
942/// A batch handle returned by create / retrieve / cancel.
943#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
944pub struct BatchHandle {
945    pub id: String,
946    pub status: BatchStatus,
947    #[serde(default)]
948    pub counts: BatchCounts,
949    #[serde(default, skip_serializing_if = "Option::is_none")]
950    pub created_at: Option<i64>,
951    #[serde(default, skip_serializing_if = "Option::is_none")]
952    pub expires_at: Option<i64>,
953    #[serde(default, skip_serializing_if = "Option::is_none")]
954    pub ended_at: Option<i64>,
955    #[serde(default, skip_serializing_if = "Option::is_none")]
956    pub endpoint: Option<String>,
957}
958
959/// A successful per-item batch response.
960#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
961pub struct BatchResponse {
962    pub status_code: u32,
963    pub body: ChatResponse,
964}
965
966/// A failed per-item batch response.
967#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
968pub struct BatchError {
969    pub code: String,
970    pub message: String,
971}
972
973/// One line of the JSONL results stream.
974#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
975pub struct BatchResultLine {
976    pub custom_id: String,
977    #[serde(default, skip_serializing_if = "Option::is_none")]
978    pub response: Option<BatchResponse>,
979    #[serde(default, skip_serializing_if = "Option::is_none")]
980    pub error: Option<BatchError>,
981}