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)]
249pub struct ChatMessage {
250    /// Message role
251    pub role: MessageRole,
252
253    /// Message content. Accepts either a plain string or the OpenAI
254    /// "typed parts" array form (`[{"type":"text","text":"..."}]`)
255    /// — both shapes deserialize into a single String. Non-text parts
256    /// fail deserialization so multimodal input is rejected instead of
257    /// silently dropped.
258    #[serde(default)]
259    #[serde(deserialize_with = "deserialize_message_content")]
260    pub content: String,
261
262    /// vLLM-compatible parsed reasoning text. When Ferrum parses
263    /// `<think>...</think>`, `content` contains only the final visible
264    /// answer and this field contains the reasoning block text.
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub reasoning: Option<String>,
267
268    /// Message name (for function calls)
269    #[serde(skip_serializing_if = "Option::is_none")]
270    pub name: Option<String>,
271
272    /// Assistant tool calls.
273    #[serde(skip_serializing_if = "Option::is_none")]
274    pub tool_calls: Option<Vec<ChatToolCall>>,
275
276    /// Tool response correlation id.
277    #[serde(skip_serializing_if = "Option::is_none")]
278    pub tool_call_id: Option<String>,
279
280    /// Legacy assistant function call.
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub function_call: Option<ChatFunctionCall>,
283}
284
285/// Assistant tool call in OpenAI responses and historical conversation input.
286#[derive(Debug, Clone, Serialize, Deserialize)]
287pub struct ChatToolCall {
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub index: Option<u32>,
290    pub id: String,
291    #[serde(rename = "type")]
292    pub tool_type: String,
293    pub function: ChatFunctionCall,
294}
295
296/// Function call payload. OpenAI serializes arguments as a JSON string.
297#[derive(Debug, Clone, Serialize, Deserialize)]
298pub struct ChatFunctionCall {
299    pub name: String,
300    pub arguments: String,
301}
302
303/// Deserialize chat message content from either a plain string or the
304/// OpenAI typed-parts array form. Real OpenAI clients (and `vllm bench
305/// serve`'s openai-chat backend) send `content` as
306/// `[{"type":"text","text":"..."}]` even for plain text; refusing that
307/// breaks every standard client.
308fn deserialize_message_content<'de, D>(deserializer: D) -> Result<String, D::Error>
309where
310    D: serde::Deserializer<'de>,
311{
312    let value = serde_json::Value::deserialize(deserializer)?;
313    match value {
314        serde_json::Value::Null => Ok(String::new()),
315        serde_json::Value::String(s) => Ok(s),
316        serde_json::Value::Array(parts) => {
317            let mut text_parts = Vec::with_capacity(parts.len());
318            for part in parts {
319                let ty = part
320                    .get("type")
321                    .and_then(|v| v.as_str())
322                    .ok_or_else(|| de::Error::custom("message content part missing type"))?;
323                if ty != "text" {
324                    return Err(de::Error::custom(format!(
325                        "unsupported message content part type `{ty}`"
326                    )));
327                }
328                if let Some(text) = part.get("text").and_then(|v| v.as_str()) {
329                    text_parts.push(text.to_string());
330                }
331            }
332            Ok(text_parts.join("\n"))
333        }
334        _ => Err(de::Error::custom(
335            "message content must be a string, null, or an array of text parts",
336        )),
337    }
338}
339
340fn deserialize_stop_sequences<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
341where
342    D: serde::Deserializer<'de>,
343{
344    let value = Option::<serde_json::Value>::deserialize(deserializer)?;
345    match value {
346        None | Some(serde_json::Value::Null) => Ok(None),
347        Some(serde_json::Value::String(stop)) => Ok(Some(vec![stop])),
348        Some(serde_json::Value::Array(values)) => {
349            let mut stops = Vec::with_capacity(values.len());
350            for value in values {
351                match value {
352                    serde_json::Value::String(stop) => stops.push(stop),
353                    _ => {
354                        return Err(de::Error::custom(
355                            "stop must be a string or an array of strings",
356                        ))
357                    }
358                }
359            }
360            Ok(Some(stops))
361        }
362        _ => Err(de::Error::custom(
363            "stop must be a string or an array of strings",
364        )),
365    }
366}
367
368/// Message roles
369#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
370#[serde(rename_all = "lowercase")]
371pub enum MessageRole {
372    System,
373    User,
374    Assistant,
375    Function,
376    Tool,
377}
378
379/// Chat completions response
380#[derive(Debug, Clone, Serialize, Deserialize)]
381pub struct ChatCompletionsResponse {
382    /// Response ID
383    pub id: String,
384
385    /// Object type
386    pub object: String,
387
388    /// Creation timestamp
389    pub created: u64,
390
391    /// Model used
392    pub model: String,
393
394    /// Choices array
395    pub choices: Vec<ChatChoice>,
396
397    /// Token usage information
398    #[serde(skip_serializing_if = "Option::is_none")]
399    pub usage: Option<Usage>,
400}
401
402/// Chat choice
403#[derive(Debug, Clone, Serialize, Deserialize)]
404pub struct ChatChoice {
405    /// Choice index
406    pub index: u32,
407
408    /// Message content
409    #[serde(skip_serializing_if = "Option::is_none")]
410    pub message: Option<ChatMessage>,
411
412    /// Delta for streaming
413    #[serde(skip_serializing_if = "Option::is_none")]
414    pub delta: Option<ChatMessage>,
415
416    /// Finish reason
417    #[serde(skip_serializing_if = "Option::is_none")]
418    pub finish_reason: Option<String>,
419}
420
421/// Legacy completions request
422#[derive(Debug, Clone, Serialize, Deserialize)]
423pub struct CompletionsRequest {
424    /// Model to use
425    pub model: String,
426
427    /// Prompt text. OpenAI's legacy completions endpoint also accepts prompt
428    /// arrays, but Ferrum currently supports only a single string and rejects
429    /// other shapes with `param=prompt`.
430    #[serde(default)]
431    pub prompt: CompletionPrompt,
432
433    /// Maximum tokens
434    #[serde(skip_serializing_if = "Option::is_none")]
435    pub max_tokens: Option<u32>,
436
437    /// Temperature
438    #[serde(skip_serializing_if = "Option::is_none")]
439    pub temperature: Option<f32>,
440
441    /// Top-p
442    #[serde(skip_serializing_if = "Option::is_none")]
443    pub top_p: Option<f32>,
444
445    /// Number of completions to generate. Ferrum currently supports only
446    /// `n=1` and rejects larger values explicitly.
447    #[serde(skip_serializing_if = "Option::is_none")]
448    pub n: Option<u32>,
449
450    /// Stream responses
451    #[serde(skip_serializing_if = "Option::is_none")]
452    pub stream: Option<bool>,
453
454    /// Stop sequences
455    #[serde(default, deserialize_with = "deserialize_stop_sequences")]
456    #[serde(skip_serializing_if = "Option::is_none")]
457    pub stop: Option<Vec<String>>,
458
459    /// Legacy completions log probabilities. Explicitly rejected until
460    /// implemented so clients don't mistake a silent ignore for support.
461    #[serde(skip_serializing_if = "Option::is_none")]
462    pub logprobs: Option<u32>,
463
464    /// Logit bias.
465    #[serde(skip_serializing_if = "Option::is_none")]
466    pub logit_bias: Option<HashMap<String, f32>>,
467}
468
469/// Legacy completions prompt. Kept as a parsed enum so the HTTP layer can
470/// return an OpenAI-shaped field error instead of a generic JSON rejection.
471#[derive(Debug, Clone, Serialize, Deserialize)]
472#[serde(untagged)]
473pub enum CompletionPrompt {
474    Text(String),
475    Unsupported(serde_json::Value),
476}
477
478impl Default for CompletionPrompt {
479    fn default() -> Self {
480        Self::Unsupported(serde_json::Value::Null)
481    }
482}
483
484impl CompletionPrompt {
485    pub fn as_text(&self) -> Option<&str> {
486        match self {
487            Self::Text(text) => Some(text),
488            Self::Unsupported(_) => None,
489        }
490    }
491}
492
493/// Completions response
494#[derive(Debug, Clone, Serialize, Deserialize)]
495pub struct CompletionsResponse {
496    pub id: String,
497    pub object: String,
498    pub created: u64,
499    pub model: String,
500    pub choices: Vec<CompletionChoice>,
501    pub usage: Option<Usage>,
502}
503
504/// Completion choice
505#[derive(Debug, Clone, Serialize, Deserialize)]
506pub struct CompletionChoice {
507    pub text: String,
508    pub index: u32,
509    pub finish_reason: Option<String>,
510}
511
512/// Token usage information
513#[derive(Debug, Clone, Serialize, Deserialize)]
514pub struct Usage {
515    pub prompt_tokens: u32,
516    pub completion_tokens: u32,
517    pub total_tokens: u32,
518}
519
520/// Model list response
521#[derive(Debug, Clone, Serialize, Deserialize)]
522pub struct ModelListResponse {
523    pub object: String,
524    pub data: Vec<ModelInfo>,
525}
526
527/// Model information
528#[derive(Debug, Clone, Serialize, Deserialize)]
529pub struct ModelInfo {
530    pub id: String,
531    pub object: String,
532    pub created: u64,
533    pub owned_by: String,
534    pub modalities: Vec<String>,
535    pub permission: Vec<ModelPermission>,
536    pub root: Option<String>,
537    pub parent: Option<String>,
538}
539
540/// Model permission
541#[derive(Debug, Clone, Serialize, Deserialize)]
542pub struct ModelPermission {
543    pub id: String,
544    pub object: String,
545    pub created: u64,
546    pub allow_create_engine: bool,
547    pub allow_sampling: bool,
548    pub allow_logprobs: bool,
549    pub allow_search_indices: bool,
550    pub allow_view: bool,
551    pub allow_fine_tuning: bool,
552    pub organization: String,
553    pub group: Option<String>,
554    pub is_blocking: bool,
555}
556
557// ======================== Embeddings API ========================
558
559/// Embeddings request (OpenAI-compatible, extended for images)
560#[derive(Debug, Clone, Serialize, Deserialize)]
561pub struct EmbeddingsRequest {
562    /// Model identifier
563    pub model: String,
564
565    /// Input to embed — text string, array of strings, or objects with text/image fields
566    pub input: EmbeddingInput,
567
568    /// Encoding format: "float" (default) or "base64"
569    #[serde(skip_serializing_if = "Option::is_none")]
570    pub encoding_format: Option<String>,
571}
572
573/// Polymorphic embedding input.
574/// Supports: single string, array of strings, single object, array of objects.
575#[derive(Debug, Clone, Serialize, Deserialize)]
576#[serde(untagged)]
577pub enum EmbeddingInput {
578    /// Single text string (OpenAI standard)
579    Single(String),
580    /// Batch of text strings (OpenAI standard)
581    Batch(Vec<String>),
582    /// Single multimodal item (Jina-style extension)
583    SingleObject(EmbeddingItem),
584    /// Batch of multimodal items
585    BatchObjects(Vec<EmbeddingItem>),
586}
587
588/// A single embedding input item — text or image.
589#[derive(Debug, Clone, Serialize, Deserialize)]
590pub struct EmbeddingItem {
591    /// Text to embed
592    #[serde(skip_serializing_if = "Option::is_none")]
593    pub text: Option<String>,
594    /// Image: file path or base64 data URI
595    #[serde(skip_serializing_if = "Option::is_none")]
596    pub image: Option<String>,
597}
598
599/// Embeddings response (OpenAI-compatible)
600#[derive(Debug, Clone, Serialize, Deserialize)]
601pub struct EmbeddingsResponse {
602    pub object: String,
603    pub data: Vec<EmbeddingData>,
604    pub model: String,
605    pub usage: EmbeddingUsage,
606}
607
608/// Single embedding result
609#[derive(Debug, Clone, Serialize, Deserialize)]
610pub struct EmbeddingData {
611    pub object: String,
612    pub embedding: Vec<f32>,
613    pub index: usize,
614}
615
616/// Token usage for embeddings
617#[derive(Debug, Clone, Serialize, Deserialize)]
618pub struct EmbeddingUsage {
619    pub prompt_tokens: u32,
620    pub total_tokens: u32,
621}
622
623// ======================== Audio Transcription API ========================
624
625/// Transcription response (OpenAI-compatible)
626#[derive(Debug, Clone, Serialize, Deserialize)]
627pub struct TranscriptionResponse {
628    pub text: String,
629}
630
631// ======================== Error types ========================
632
633/// OpenAI API error
634#[derive(Debug, Clone, Serialize, Deserialize)]
635pub struct OpenAiError {
636    pub error: OpenAiErrorDetail,
637}
638
639/// OpenAI error detail
640#[derive(Debug, Clone, Serialize, Deserialize)]
641pub struct OpenAiErrorDetail {
642    pub message: String,
643    #[serde(rename = "type")]
644    pub error_type: String,
645    pub param: Option<String>,
646    pub code: Option<String>,
647}
648
649/// OpenAI error types
650#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
651pub enum OpenAiErrorType {
652    InvalidRequestError,
653    AuthenticationError,
654    PermissionError,
655    NotFoundError,
656    RateLimitError,
657    InternalServerError,
658    ServiceUnavailableError,
659}
660
661/// Server-sent event for streaming
662#[derive(Debug, Clone)]
663pub struct SseEvent {
664    pub event: Option<String>,
665    pub data: String,
666    pub id: Option<String>,
667    pub retry: Option<u32>,
668}
669
670impl SseEvent {
671    pub fn data(data: String) -> Self {
672        Self {
673            event: None,
674            data,
675            id: None,
676            retry: None,
677        }
678    }
679
680    pub fn json(value: &serde_json::Value) -> Result<Self, serde_json::Error> {
681        Ok(Self::data(serde_json::to_string(value)?))
682    }
683
684    pub fn to_string(&self) -> String {
685        let mut result = String::new();
686
687        if let Some(event) = &self.event {
688            result.push_str(&format!("event: {}\n", event));
689        }
690
691        if let Some(id) = &self.id {
692            result.push_str(&format!("id: {}\n", id));
693        }
694
695        if let Some(retry) = self.retry {
696            result.push_str(&format!("retry: {}\n", retry));
697        }
698
699        result.push_str(&format!("data: {}\n\n", self.data));
700        result
701    }
702}
703
704/// TTS speech request (OpenAI compatible /v1/audio/speech)
705#[derive(Debug, Clone, Serialize, Deserialize)]
706pub struct SpeechRequest {
707    /// Model name (e.g., "qwen3-tts", "tts-1")
708    #[serde(default = "default_tts_model")]
709    pub model: String,
710
711    /// Text to synthesize
712    pub input: String,
713
714    /// Voice preset (ignored for now — uses default speaker)
715    #[serde(default = "default_voice")]
716    pub voice: String,
717
718    /// Response format: "wav", "pcm" (default: "wav")
719    #[serde(default = "default_audio_format")]
720    pub response_format: String,
721
722    /// Language hint: "auto", "chinese", "english"
723    #[serde(default = "default_language")]
724    pub language: String,
725
726    /// Enable streaming (chunked transfer)
727    #[serde(default)]
728    pub stream: bool,
729}
730
731fn default_tts_model() -> String {
732    "qwen3-tts".to_string()
733}
734fn default_voice() -> String {
735    "default".to_string()
736}
737fn default_audio_format() -> String {
738    "wav".to_string()
739}
740fn default_language() -> String {
741    "auto".to_string()
742}