Skip to main content

ferrum_server/
openai.rs

1//! OpenAI API compatibility types
2//!
3//! This module defines types that match the OpenAI API specification
4//! for chat completions, completions, and model management.
5
6use serde::{de, Deserialize, Serialize};
7use std::collections::HashMap;
8
9/// Chat completions request (OpenAI compatible)
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ChatCompletionsRequest {
12    /// Model to use for completion
13    pub model: String,
14
15    /// List of messages
16    pub messages: Vec<ChatMessage>,
17
18    /// Maximum number of tokens to generate
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub max_tokens: Option<u32>,
21
22    /// Newer OpenAI chat field replacing `max_tokens` for completion budget.
23    /// When both are supplied, Ferrum uses this value.
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub max_completion_tokens: Option<u32>,
26
27    /// Temperature for sampling
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub temperature: Option<f32>,
30
31    /// Top-p for nucleus sampling
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub top_p: Option<f32>,
34
35    /// vLLM-compatible top-k sampling extension. Values `-1` and `0`
36    /// disable top-k filtering.
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub top_k: Option<i64>,
39
40    /// vLLM-compatible minimum probability sampling extension. A value of
41    /// `0` disables minimum-probability filtering.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub min_p: Option<f32>,
44
45    /// vLLM-compatible repetition penalty extension.
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub repetition_penalty: Option<f32>,
48
49    /// Number of completions to generate
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub n: Option<u32>,
52
53    /// Whether to stream responses
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub stream: Option<bool>,
56
57    /// vLLM-compatible extension for benchmark/throughput workloads.
58    /// When true, Ferrum ignores model EOS tokens and stops only on the
59    /// requested token budget or explicit user stop sequences.
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub ignore_eos: Option<bool>,
62
63    /// Stop sequences
64    #[serde(default, deserialize_with = "deserialize_stop_sequences")]
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub stop: Option<Vec<String>>,
67
68    /// Presence penalty
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub presence_penalty: Option<f32>,
71
72    /// Frequency penalty
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub frequency_penalty: Option<f32>,
75
76    /// Logit bias
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub logit_bias: Option<HashMap<String, f32>>,
79
80    /// Return log probabilities. Ferrum rejects this until implemented so
81    /// clients get an explicit OpenAI-style error instead of silent ignore.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub logprobs: Option<bool>,
84
85    /// Number of top log probabilities to return.
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub top_logprobs: Option<u32>,
88
89    /// User identifier
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub user: Option<String>,
92
93    /// Random seed
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub seed: Option<u64>,
96
97    /// Response format constraint (e.g., `{"type": "json_object"}`)
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub response_format: Option<OpenAiResponseFormat>,
100
101    /// Standard reasoning control. Omission/null retains model and server
102    /// defaults; explicit `none` requests disabled reasoning.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub reasoning_effort: Option<ferrum_types::ReasoningEffort>,
105
106    /// OpenAI tool definitions. Function tools are parsed, carried through
107    /// structured request data, and can shape model-emitted tool-call JSON.
108    /// Tool execution itself stays caller-owned.
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub tools: Option<Vec<ChatTool>>,
111
112    /// OpenAI tool selection policy.
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub tool_choice: Option<ToolChoice>,
115
116    /// Streaming response options.
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub stream_options: Option<StreamOptions>,
119
120    /// Legacy OpenAI functions compatibility.
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub functions: Option<Vec<ChatFunction>>,
123
124    /// Legacy OpenAI function-call selector.
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub function_call: Option<FunctionCallChoice>,
127
128    /// Ferrum extension metadata. Used for opt-in product features such as
129    /// `metadata.ferrum_session_id` when callers prefer body metadata over
130    /// the `X-Ferrum-Session` header.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub metadata: Option<HashMap<String, serde_json::Value>>,
133
134    /// vLLM-compatible chat-template variables. Ferrum forwards supported
135    /// values to the model-provided chat template; templates that do not read
136    /// a variable are unaffected.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub chat_template_kwargs: Option<HashMap<String, serde_json::Value>>,
139}
140
141/// OpenAI streaming options.
142#[derive(Debug, Clone, Serialize)]
143pub struct StreamOptions {
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub include_usage: Option<bool>,
146}
147
148impl<'de> Deserialize<'de> for StreamOptions {
149    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
150    where
151        D: serde::Deserializer<'de>,
152    {
153        #[derive(Deserialize)]
154        #[serde(deny_unknown_fields)]
155        struct Object {
156            #[serde(default)]
157            include_usage: Option<bool>,
158        }
159
160        let value = serde_json::Value::deserialize(deserializer)?;
161        if !value.is_object() {
162            return Err(de::Error::custom("stream_options must be a JSON object"));
163        }
164        let parsed = serde_json::from_value::<Object>(value).map_err(de::Error::custom)?;
165        Ok(Self {
166            include_usage: parsed.include_usage,
167        })
168    }
169}
170
171/// Tool definition in OpenAI chat-completion requests.
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct ChatTool {
174    #[serde(rename = "type")]
175    pub tool_type: String,
176    pub function: ChatFunction,
177}
178
179/// Function schema for `tools[].function` and legacy `functions[]`.
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct ChatFunction {
182    pub name: String,
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub description: Option<String>,
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub parameters: Option<serde_json::Value>,
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub strict: Option<bool>,
189}
190
191/// OpenAI `tool_choice` accepts either a simple mode string or a specific
192/// function-tool selector object.
193#[derive(Debug, Clone, Serialize, Deserialize)]
194#[serde(untagged)]
195pub enum ToolChoice {
196    Mode(String),
197    Function {
198        #[serde(rename = "type")]
199        tool_type: String,
200        function: ToolChoiceFunction,
201    },
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct ToolChoiceFunction {
206    pub name: String,
207}
208
209/// Legacy `function_call` accepts a simple mode string or a named function.
210#[derive(Debug, Clone, Serialize, Deserialize)]
211#[serde(untagged)]
212pub enum FunctionCallChoice {
213    Mode(String),
214    Function { name: String },
215}
216
217/// OpenAI-compatible response format specifier.
218///
219/// Mirrors OpenAI's `response_format` field on `/v1/chat/completions`:
220///   - `{"type": "text"}`         — default, no constraint
221///   - `{"type": "json_object"}`  — output must be valid JSON
222///   - `{"type": "json_schema", "json_schema": {"name":..., "strict":true,
223///      "schema": {...}}}` — output must conform to the inline JSON Schema
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct OpenAiResponseFormat {
226    #[serde(rename = "type")]
227    pub format_type: String,
228    /// Present only when `format_type == "json_schema"`.
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub json_schema: Option<OpenAiJsonSchema>,
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct OpenAiJsonSchema {
235    /// Optional name for the schema (ignored internally, kept for round-trip).
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub name: Option<String>,
238    /// The actual JSON Schema. Stored as raw JSON value so callers can pass
239    /// any valid schema object; we re-serialise when forwarding to the
240    /// guided-decoding pipeline. Optional at deserialization time so the
241    /// HTTP layer can return an OpenAI-shaped `param` error for missing
242    /// schemas instead of Axum's generic JSON rejection.
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub schema: Option<serde_json::Value>,
245    /// OpenAI's `strict` flag. When true, Ferrum rejects schemas outside the
246    /// currently supported guided-decoding subset instead of silently falling
247    /// back to best-effort JSON.
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub strict: Option<bool>,
250}
251
252/// Chat message
253#[derive(Debug, Clone, Serialize, Deserialize)]
254#[serde(try_from = "ChatMessageWire")]
255pub struct ChatMessage {
256    /// Message role
257    pub role: MessageRole,
258
259    /// Message content. Accepts either a plain string or the OpenAI
260    /// "typed parts" array form (`[{"type":"text","text":"..."}]`)
261    /// — both shapes deserialize into a single String. Non-text parts
262    /// fail deserialization so multimodal input is rejected instead of
263    /// silently dropped.
264    #[serde(default)]
265    #[serde(deserialize_with = "deserialize_message_content")]
266    pub content: String,
267
268    /// vLLM-compatible parsed reasoning text. When Ferrum parses
269    /// `<think>...</think>`, `content` contains only the final visible
270    /// answer and this field contains the reasoning block text.
271    /// Historical input also accepts `reasoning_content`. A string in
272    /// `reasoning` takes precedence, including an empty string; missing or
273    /// null `reasoning` falls back to the alias. Output uses only `reasoning`.
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub reasoning: Option<String>,
276
277    /// Message name (for function calls)
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub name: Option<String>,
280
281    /// Assistant tool calls.
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub tool_calls: Option<Vec<ChatToolCall>>,
284
285    /// Tool response correlation id.
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub tool_call_id: Option<String>,
288
289    /// Legacy assistant function call.
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub function_call: Option<ChatFunctionCall>,
292}
293
294/// Deserialization wire shape. Both reasoning field names are parsed separately
295/// so receiving both is not a duplicate-field error; conversion below leaves
296/// one canonical value for the rest of Ferrum.
297#[derive(Deserialize)]
298struct ChatMessageWire {
299    role: MessageRole,
300    #[serde(default, deserialize_with = "deserialize_message_content")]
301    content: String,
302    #[serde(default)]
303    reasoning: serde_json::Value,
304    #[serde(default)]
305    reasoning_content: serde_json::Value,
306    #[serde(default)]
307    name: Option<String>,
308    #[serde(default)]
309    tool_calls: Option<Vec<ChatToolCall>>,
310    #[serde(default)]
311    tool_call_id: Option<String>,
312    #[serde(default)]
313    function_call: Option<ChatFunctionCall>,
314}
315
316impl TryFrom<ChatMessageWire> for ChatMessage {
317    type Error = String;
318
319    fn try_from(message: ChatMessageWire) -> Result<Self, Self::Error> {
320        let reasoning = match message.reasoning {
321            serde_json::Value::String(reasoning) => Some(reasoning),
322            serde_json::Value::Null => match message.reasoning_content {
323                serde_json::Value::String(reasoning) => Some(reasoning),
324                serde_json::Value::Null => None,
325                _ => return Err("reasoning_content must be a string or null".to_string()),
326            },
327            _ => return Err("reasoning must be a string or null".to_string()),
328        };
329
330        Ok(Self {
331            role: message.role,
332            content: message.content,
333            reasoning,
334            name: message.name,
335            tool_calls: message.tool_calls,
336            tool_call_id: message.tool_call_id,
337            function_call: message.function_call,
338        })
339    }
340}
341
342/// Assistant tool call in OpenAI responses and historical conversation input.
343#[derive(Debug, Clone, Serialize, Deserialize)]
344pub struct ChatToolCall {
345    #[serde(skip_serializing_if = "Option::is_none")]
346    pub index: Option<u32>,
347    pub id: String,
348    #[serde(rename = "type")]
349    pub tool_type: String,
350    pub function: ChatFunctionCall,
351}
352
353/// Function call payload. OpenAI serializes arguments as a JSON string.
354#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct ChatFunctionCall {
356    pub name: String,
357    pub arguments: String,
358}
359
360/// Deserialize chat message content from either a plain string or the
361/// OpenAI typed-parts array form. Real OpenAI clients (and `vllm bench
362/// serve`'s openai-chat backend) send `content` as
363/// `[{"type":"text","text":"..."}]` even for plain text; refusing that
364/// breaks every standard client.
365fn deserialize_message_content<'de, D>(deserializer: D) -> Result<String, D::Error>
366where
367    D: serde::Deserializer<'de>,
368{
369    let value = serde_json::Value::deserialize(deserializer)?;
370    match value {
371        serde_json::Value::Null => Ok(String::new()),
372        serde_json::Value::String(s) => Ok(s),
373        serde_json::Value::Array(parts) => {
374            let mut text_parts = Vec::with_capacity(parts.len());
375            for part in parts {
376                let ty = part
377                    .get("type")
378                    .and_then(|v| v.as_str())
379                    .ok_or_else(|| de::Error::custom("message content part missing type"))?;
380                if ty != "text" {
381                    return Err(de::Error::custom(format!(
382                        "unsupported message content part type `{ty}`"
383                    )));
384                }
385                if let Some(text) = part.get("text").and_then(|v| v.as_str()) {
386                    text_parts.push(text.to_string());
387                }
388            }
389            Ok(text_parts.join("\n"))
390        }
391        _ => Err(de::Error::custom(
392            "message content must be a string, null, or an array of text parts",
393        )),
394    }
395}
396
397fn deserialize_stop_sequences<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
398where
399    D: serde::Deserializer<'de>,
400{
401    let value = Option::<serde_json::Value>::deserialize(deserializer)?;
402    match value {
403        None | Some(serde_json::Value::Null) => Ok(None),
404        Some(serde_json::Value::String(stop)) => Ok(Some(vec![stop])),
405        Some(serde_json::Value::Array(values)) => {
406            let mut stops = Vec::with_capacity(values.len());
407            for value in values {
408                match value {
409                    serde_json::Value::String(stop) => stops.push(stop),
410                    _ => {
411                        return Err(de::Error::custom(
412                            "stop must be a string or an array of strings",
413                        ))
414                    }
415                }
416            }
417            Ok(Some(stops))
418        }
419        _ => Err(de::Error::custom(
420            "stop must be a string or an array of strings",
421        )),
422    }
423}
424
425/// Message roles
426#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
427#[serde(rename_all = "lowercase")]
428pub enum MessageRole {
429    System,
430    User,
431    Assistant,
432    Function,
433    Tool,
434}
435
436/// Whether an assistant message is intermediate commentary or the terminal
437/// answer for a Responses API turn.
438#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
439#[serde(rename_all = "snake_case")]
440pub(crate) enum AssistantMessagePhase {
441    Commentary,
442    FinalAnswer,
443}
444
445/// Chat completions response
446#[derive(Debug, Clone, Serialize, Deserialize)]
447pub struct ChatCompletionsResponse {
448    /// Response ID
449    pub id: String,
450
451    /// Object type
452    pub object: String,
453
454    /// Creation timestamp
455    pub created: u64,
456
457    /// Model used
458    pub model: String,
459
460    /// Choices array
461    pub choices: Vec<ChatChoice>,
462
463    /// Token usage information
464    #[serde(skip_serializing_if = "Option::is_none")]
465    pub usage: Option<Usage>,
466}
467
468/// Chat choice
469#[derive(Debug, Clone, Serialize, Deserialize)]
470pub struct ChatChoice {
471    /// Choice index
472    pub index: u32,
473
474    /// Message content
475    #[serde(skip_serializing_if = "Option::is_none")]
476    pub message: Option<ChatMessage>,
477
478    /// Delta for streaming
479    #[serde(skip_serializing_if = "Option::is_none")]
480    pub delta: Option<ChatMessage>,
481
482    /// Finish reason
483    #[serde(skip_serializing_if = "Option::is_none")]
484    pub finish_reason: Option<String>,
485}
486
487/// Legacy completions request
488#[derive(Debug, Clone, Serialize, Deserialize)]
489pub struct CompletionsRequest {
490    /// Model to use
491    pub model: String,
492
493    /// Prompt text. OpenAI's legacy completions endpoint also accepts prompt
494    /// arrays, but Ferrum currently supports only a single string and rejects
495    /// other shapes with `param=prompt`.
496    #[serde(default)]
497    pub prompt: CompletionPrompt,
498
499    /// Maximum tokens
500    #[serde(skip_serializing_if = "Option::is_none")]
501    pub max_tokens: Option<u32>,
502
503    /// Temperature
504    #[serde(skip_serializing_if = "Option::is_none")]
505    pub temperature: Option<f32>,
506
507    /// Top-p
508    #[serde(skip_serializing_if = "Option::is_none")]
509    pub top_p: Option<f32>,
510
511    /// Number of completions to generate. Ferrum currently supports only
512    /// `n=1` and rejects larger values explicitly.
513    #[serde(skip_serializing_if = "Option::is_none")]
514    pub n: Option<u32>,
515
516    /// Stream responses
517    #[serde(skip_serializing_if = "Option::is_none")]
518    pub stream: Option<bool>,
519
520    /// Stop sequences
521    #[serde(default, deserialize_with = "deserialize_stop_sequences")]
522    #[serde(skip_serializing_if = "Option::is_none")]
523    pub stop: Option<Vec<String>>,
524
525    /// Legacy completions log probabilities. Explicitly rejected until
526    /// implemented so clients don't mistake a silent ignore for support.
527    #[serde(skip_serializing_if = "Option::is_none")]
528    pub logprobs: Option<u32>,
529
530    /// Logit bias.
531    #[serde(skip_serializing_if = "Option::is_none")]
532    pub logit_bias: Option<HashMap<String, f32>>,
533}
534
535/// Legacy completions prompt. Kept as a parsed enum so the HTTP layer can
536/// return an OpenAI-shaped field error instead of a generic JSON rejection.
537#[derive(Debug, Clone, Serialize, Deserialize)]
538#[serde(untagged)]
539pub enum CompletionPrompt {
540    Text(String),
541    Unsupported(serde_json::Value),
542}
543
544impl Default for CompletionPrompt {
545    fn default() -> Self {
546        Self::Unsupported(serde_json::Value::Null)
547    }
548}
549
550impl CompletionPrompt {
551    pub fn as_text(&self) -> Option<&str> {
552        match self {
553            Self::Text(text) => Some(text),
554            Self::Unsupported(_) => None,
555        }
556    }
557}
558
559/// Completions response
560#[derive(Debug, Clone, Serialize, Deserialize)]
561pub struct CompletionsResponse {
562    pub id: String,
563    pub object: String,
564    pub created: u64,
565    pub model: String,
566    pub choices: Vec<CompletionChoice>,
567    pub usage: Option<Usage>,
568}
569
570/// Completion choice
571#[derive(Debug, Clone, Serialize, Deserialize)]
572pub struct CompletionChoice {
573    pub text: String,
574    pub index: u32,
575    pub finish_reason: Option<String>,
576}
577
578/// Token usage information
579#[derive(Debug, Clone, Serialize, Deserialize)]
580pub struct Usage {
581    pub prompt_tokens: u32,
582    pub completion_tokens: u32,
583    pub total_tokens: u32,
584}
585
586/// Model list response
587#[derive(Debug, Clone, Serialize, Deserialize)]
588pub struct ModelListResponse {
589    pub object: String,
590    pub data: Vec<ModelInfo>,
591}
592
593/// Model information
594#[derive(Debug, Clone, Serialize, Deserialize)]
595pub struct ModelInfo {
596    pub id: String,
597    pub object: String,
598    pub created: u64,
599    pub owned_by: String,
600    pub modalities: Vec<String>,
601    /// Effective input-plus-output capacity reported by the loaded LLM engine.
602    #[serde(default, skip_serializing_if = "Option::is_none")]
603    pub max_model_len: Option<usize>,
604    /// Optional model-declared controls; omission means support is unknown.
605    #[serde(default, skip_serializing_if = "Option::is_none")]
606    pub reasoning: Option<ModelReasoningCapabilities>,
607    pub permission: Vec<ModelPermission>,
608    pub root: Option<String>,
609    pub parent: Option<String>,
610}
611
612/// Optional extension for clients to discover actual reasoning controls.
613#[derive(Debug, Clone, Serialize, Deserialize)]
614pub struct ModelReasoningCapabilities {
615    /// Only populated from a declaration, including an explicitly empty set.
616    #[serde(default, skip_serializing_if = "Option::is_none")]
617    pub supported_efforts: Option<Vec<ferrum_types::ReasoningEffort>>,
618    /// Present only when template probing establishes an enable/disable control.
619    #[serde(default, skip_serializing_if = "Option::is_none")]
620    pub thinking: Option<ModelThinkingCapability>,
621}
622
623#[derive(Debug, Clone, Serialize, Deserialize)]
624pub struct ModelThinkingCapability {
625    /// Effective service default, including an explicit server override.
626    pub default_enabled: bool,
627}
628
629/// Model permission
630#[derive(Debug, Clone, Serialize, Deserialize)]
631pub struct ModelPermission {
632    pub id: String,
633    pub object: String,
634    pub created: u64,
635    pub allow_create_engine: bool,
636    pub allow_sampling: bool,
637    pub allow_logprobs: bool,
638    pub allow_search_indices: bool,
639    pub allow_view: bool,
640    pub allow_fine_tuning: bool,
641    pub organization: String,
642    pub group: Option<String>,
643    pub is_blocking: bool,
644}
645
646// ======================== Embeddings API ========================
647
648/// Embeddings request (OpenAI-compatible, extended for images)
649#[derive(Debug, Clone, Serialize, Deserialize)]
650pub struct EmbeddingsRequest {
651    /// Model identifier
652    pub model: String,
653
654    /// Input to embed — text string, array of strings, or objects with text/image fields
655    pub input: EmbeddingInput,
656
657    /// Encoding format: "float" (default) or "base64"
658    #[serde(skip_serializing_if = "Option::is_none")]
659    pub encoding_format: Option<String>,
660}
661
662/// Polymorphic embedding input.
663/// Supports: single string, array of strings, single object, array of objects.
664#[derive(Debug, Clone, Serialize, Deserialize)]
665#[serde(untagged)]
666pub enum EmbeddingInput {
667    /// Single text string (OpenAI standard)
668    Single(String),
669    /// Batch of text strings (OpenAI standard)
670    Batch(Vec<String>),
671    /// Single multimodal item (Jina-style extension)
672    SingleObject(EmbeddingItem),
673    /// Batch of multimodal items
674    BatchObjects(Vec<EmbeddingItem>),
675}
676
677/// A single embedding input item — text or image.
678#[derive(Debug, Clone, Serialize, Deserialize)]
679pub struct EmbeddingItem {
680    /// Text to embed
681    #[serde(skip_serializing_if = "Option::is_none")]
682    pub text: Option<String>,
683    /// Image: file path or base64 data URI
684    #[serde(skip_serializing_if = "Option::is_none")]
685    pub image: Option<String>,
686}
687
688/// Embeddings response (OpenAI-compatible)
689#[derive(Debug, Clone, Serialize, Deserialize)]
690pub struct EmbeddingsResponse {
691    pub object: String,
692    pub data: Vec<EmbeddingData>,
693    pub model: String,
694    pub usage: EmbeddingUsage,
695}
696
697/// Single embedding result
698#[derive(Debug, Clone, Serialize, Deserialize)]
699pub struct EmbeddingData {
700    pub object: String,
701    pub embedding: Vec<f32>,
702    pub index: usize,
703}
704
705/// Token usage for embeddings
706#[derive(Debug, Clone, Serialize, Deserialize)]
707pub struct EmbeddingUsage {
708    pub prompt_tokens: u32,
709    pub total_tokens: u32,
710}
711
712// ======================== Audio Transcription API ========================
713
714/// Transcription response (OpenAI-compatible)
715#[derive(Debug, Clone, Serialize, Deserialize)]
716pub struct TranscriptionResponse {
717    pub text: String,
718}
719
720// ======================== Error types ========================
721
722/// OpenAI API error
723#[derive(Debug, Clone, Serialize, Deserialize)]
724pub struct OpenAiError {
725    pub error: OpenAiErrorDetail,
726}
727
728/// OpenAI error detail
729#[derive(Debug, Clone, Serialize, Deserialize)]
730pub struct OpenAiErrorDetail {
731    pub message: String,
732    #[serde(rename = "type")]
733    pub error_type: String,
734    pub param: Option<String>,
735    pub code: Option<String>,
736}
737
738/// OpenAI error types
739#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
740pub enum OpenAiErrorType {
741    InvalidRequestError,
742    AuthenticationError,
743    PermissionError,
744    NotFoundError,
745    RateLimitError,
746    InternalServerError,
747    ServiceUnavailableError,
748}
749
750/// Server-sent event for streaming
751#[derive(Debug, Clone)]
752pub struct SseEvent {
753    pub event: Option<String>,
754    pub data: String,
755    pub id: Option<String>,
756    pub retry: Option<u32>,
757}
758
759impl SseEvent {
760    pub fn data(data: String) -> Self {
761        Self {
762            event: None,
763            data,
764            id: None,
765            retry: None,
766        }
767    }
768
769    pub fn json(value: &serde_json::Value) -> Result<Self, serde_json::Error> {
770        Ok(Self::data(serde_json::to_string(value)?))
771    }
772
773    pub fn to_string(&self) -> String {
774        let mut result = String::new();
775
776        if let Some(event) = &self.event {
777            result.push_str(&format!("event: {}\n", event));
778        }
779
780        if let Some(id) = &self.id {
781            result.push_str(&format!("id: {}\n", id));
782        }
783
784        if let Some(retry) = self.retry {
785            result.push_str(&format!("retry: {}\n", retry));
786        }
787
788        result.push_str(&format!("data: {}\n\n", self.data));
789        result
790    }
791}
792
793/// TTS speech request (OpenAI compatible /v1/audio/speech)
794#[derive(Debug, Clone, Serialize, Deserialize)]
795pub struct SpeechRequest {
796    /// Model name (e.g., "qwen3-tts", "tts-1")
797    #[serde(default = "default_tts_model")]
798    pub model: String,
799
800    /// Text to synthesize
801    pub input: String,
802
803    /// Voice preset (ignored for now — uses default speaker)
804    #[serde(default = "default_voice")]
805    pub voice: String,
806
807    /// Response format: "wav", "pcm" (default: "wav")
808    #[serde(default = "default_audio_format")]
809    pub response_format: String,
810
811    /// Language hint: "auto", "chinese", "english"
812    #[serde(default = "default_language")]
813    pub language: String,
814
815    /// Enable streaming (chunked transfer)
816    #[serde(default)]
817    pub stream: bool,
818}
819
820fn default_tts_model() -> String {
821    "qwen3-tts".to_string()
822}
823fn default_voice() -> String {
824    "default".to_string()
825}
826fn default_audio_format() -> String {
827    "wav".to_string()
828}
829fn default_language() -> String {
830    "auto".to_string()
831}
832
833#[cfg(test)]
834mod tests {
835    use super::*;
836
837    fn chat_request_with_assistant_fields(fields: &str) -> String {
838        format!(r#"{{"model":"test","messages":[{{"role":"assistant","content":null{fields}}}]}}"#)
839    }
840
841    #[test]
842    fn chat_request_normalizes_reasoning_content_at_the_wire_boundary() {
843        let cases = [
844            ("missing", "", None),
845            ("compatibility null", r#", "reasoning_content": null"#, None),
846            (
847                "compatibility empty",
848                r#", "reasoning_content": """#,
849                Some(""),
850            ),
851            (
852                "compatibility text",
853                r#", "reasoning_content": "compatibility""#,
854                Some("compatibility"),
855            ),
856            (
857                "canonical text",
858                r#", "reasoning": "canonical""#,
859                Some("canonical"),
860            ),
861            (
862                "compatibility then canonical",
863                r#", "reasoning_content": "compatibility", "reasoning": "canonical""#,
864                Some("canonical"),
865            ),
866            (
867                "canonical then compatibility",
868                r#", "reasoning": "canonical", "reasoning_content": "compatibility""#,
869                Some("canonical"),
870            ),
871            (
872                "canonical empty wins",
873                r#", "reasoning": "", "reasoning_content": "compatibility""#,
874                Some(""),
875            ),
876            (
877                "canonical null falls back",
878                r#", "reasoning": null, "reasoning_content": "compatibility""#,
879                Some("compatibility"),
880            ),
881            (
882                "canonical text ignores invalid compatibility",
883                r#", "reasoning_content": 7, "reasoning": "canonical""#,
884                Some("canonical"),
885            ),
886            (
887                "canonical empty ignores invalid compatibility",
888                r#", "reasoning": "", "reasoning_content": {"unexpected": true}"#,
889                Some(""),
890            ),
891        ];
892
893        for (name, fields, expected) in cases {
894            let request: ChatCompletionsRequest =
895                serde_json::from_str(&chat_request_with_assistant_fields(fields))
896                    .unwrap_or_else(|error| panic!("{name}: {error}"));
897            assert_eq!(request.messages[0].reasoning.as_deref(), expected, "{name}");
898
899            let normalized = serde_json::to_value(request).expect("normalized request JSON");
900            let message = &normalized["messages"][0];
901            assert!(message.get("reasoning_content").is_none(), "{name}");
902            match expected {
903                Some(expected) => assert_eq!(message["reasoning"], expected, "{name}"),
904                None => assert!(message.get("reasoning").is_none(), "{name}"),
905            }
906        }
907    }
908
909    #[test]
910    fn chat_request_rejects_non_string_reasoning_fields() {
911        for (name, fields) in [
912            ("compatibility", r#", "reasoning_content": 7"#),
913            (
914                "canonical is not masked by compatibility",
915                r#", "reasoning": 7, "reasoning_content": "compatibility""#,
916            ),
917            (
918                "canonical null validates compatibility",
919                r#", "reasoning": null, "reasoning_content": 7"#,
920            ),
921        ] {
922            let error = serde_json::from_str::<ChatCompletionsRequest>(
923                &chat_request_with_assistant_fields(fields),
924            )
925            .expect_err(name);
926            assert!(error.to_string().contains("string"), "{name}: {error}");
927        }
928    }
929}