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