use serde::{de, Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatCompletionsRequest {
pub model: String,
pub messages: Vec<ChatMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_completion_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_k: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repetition_penalty: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub n: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ignore_eos: Option<bool>,
#[serde(default, deserialize_with = "deserialize_stop_sequences")]
#[serde(skip_serializing_if = "Option::is_none")]
pub stop: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub presence_penalty: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub frequency_penalty: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub logit_bias: Option<HashMap<String, f32>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub logprobs: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_logprobs: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response_format: Option<OpenAiResponseFormat>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<ferrum_types::ReasoningEffort>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<ChatTool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<ToolChoice>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream_options: Option<StreamOptions>,
#[serde(skip_serializing_if = "Option::is_none")]
pub functions: Option<Vec<ChatFunction>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub function_call: Option<FunctionCallChoice>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<HashMap<String, serde_json::Value>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub chat_template_kwargs: Option<HashMap<String, serde_json::Value>>,
}
#[derive(Debug, Clone, Serialize)]
pub struct StreamOptions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub include_usage: Option<bool>,
}
impl<'de> Deserialize<'de> for StreamOptions {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Object {
#[serde(default)]
include_usage: Option<bool>,
}
let value = serde_json::Value::deserialize(deserializer)?;
if !value.is_object() {
return Err(de::Error::custom("stream_options must be a JSON object"));
}
let parsed = serde_json::from_value::<Object>(value).map_err(de::Error::custom)?;
Ok(Self {
include_usage: parsed.include_usage,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatTool {
#[serde(rename = "type")]
pub tool_type: String,
pub function: ChatFunction,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatFunction {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parameters: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub strict: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ToolChoice {
Mode(String),
Function {
#[serde(rename = "type")]
tool_type: String,
function: ToolChoiceFunction,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolChoiceFunction {
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum FunctionCallChoice {
Mode(String),
Function { name: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAiResponseFormat {
#[serde(rename = "type")]
pub format_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub json_schema: Option<OpenAiJsonSchema>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAiJsonSchema {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub strict: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(try_from = "ChatMessageWire")]
pub struct ChatMessage {
pub role: MessageRole,
#[serde(default)]
#[serde(deserialize_with = "deserialize_message_content")]
pub content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ChatToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub function_call: Option<ChatFunctionCall>,
}
#[derive(Deserialize)]
struct ChatMessageWire {
role: MessageRole,
#[serde(default, deserialize_with = "deserialize_message_content")]
content: String,
#[serde(default)]
reasoning: serde_json::Value,
#[serde(default)]
reasoning_content: serde_json::Value,
#[serde(default)]
name: Option<String>,
#[serde(default)]
tool_calls: Option<Vec<ChatToolCall>>,
#[serde(default)]
tool_call_id: Option<String>,
#[serde(default)]
function_call: Option<ChatFunctionCall>,
}
impl TryFrom<ChatMessageWire> for ChatMessage {
type Error = String;
fn try_from(message: ChatMessageWire) -> Result<Self, Self::Error> {
let reasoning = match message.reasoning {
serde_json::Value::String(reasoning) => Some(reasoning),
serde_json::Value::Null => match message.reasoning_content {
serde_json::Value::String(reasoning) => Some(reasoning),
serde_json::Value::Null => None,
_ => return Err("reasoning_content must be a string or null".to_string()),
},
_ => return Err("reasoning must be a string or null".to_string()),
};
Ok(Self {
role: message.role,
content: message.content,
reasoning,
name: message.name,
tool_calls: message.tool_calls,
tool_call_id: message.tool_call_id,
function_call: message.function_call,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatToolCall {
#[serde(skip_serializing_if = "Option::is_none")]
pub index: Option<u32>,
pub id: String,
#[serde(rename = "type")]
pub tool_type: String,
pub function: ChatFunctionCall,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatFunctionCall {
pub name: String,
pub arguments: String,
}
fn deserialize_message_content<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
match value {
serde_json::Value::Null => Ok(String::new()),
serde_json::Value::String(s) => Ok(s),
serde_json::Value::Array(parts) => {
let mut text_parts = Vec::with_capacity(parts.len());
for part in parts {
let ty = part
.get("type")
.and_then(|v| v.as_str())
.ok_or_else(|| de::Error::custom("message content part missing type"))?;
if ty != "text" {
return Err(de::Error::custom(format!(
"unsupported message content part type `{ty}`"
)));
}
if let Some(text) = part.get("text").and_then(|v| v.as_str()) {
text_parts.push(text.to_string());
}
}
Ok(text_parts.join("\n"))
}
_ => Err(de::Error::custom(
"message content must be a string, null, or an array of text parts",
)),
}
}
fn deserialize_stop_sequences<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<serde_json::Value>::deserialize(deserializer)?;
match value {
None | Some(serde_json::Value::Null) => Ok(None),
Some(serde_json::Value::String(stop)) => Ok(Some(vec![stop])),
Some(serde_json::Value::Array(values)) => {
let mut stops = Vec::with_capacity(values.len());
for value in values {
match value {
serde_json::Value::String(stop) => stops.push(stop),
_ => {
return Err(de::Error::custom(
"stop must be a string or an array of strings",
))
}
}
}
Ok(Some(stops))
}
_ => Err(de::Error::custom(
"stop must be a string or an array of strings",
)),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MessageRole {
System,
User,
Assistant,
Function,
Tool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum AssistantMessagePhase {
Commentary,
FinalAnswer,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatCompletionsResponse {
pub id: String,
pub object: String,
pub created: u64,
pub model: String,
pub choices: Vec<ChatChoice>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<Usage>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatChoice {
pub index: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<ChatMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delta: Option<ChatMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub finish_reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompletionsRequest {
pub model: String,
#[serde(default)]
pub prompt: CompletionPrompt,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub n: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
#[serde(default, deserialize_with = "deserialize_stop_sequences")]
#[serde(skip_serializing_if = "Option::is_none")]
pub stop: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub logprobs: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub logit_bias: Option<HashMap<String, f32>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum CompletionPrompt {
Text(String),
Unsupported(serde_json::Value),
}
impl Default for CompletionPrompt {
fn default() -> Self {
Self::Unsupported(serde_json::Value::Null)
}
}
impl CompletionPrompt {
pub fn as_text(&self) -> Option<&str> {
match self {
Self::Text(text) => Some(text),
Self::Unsupported(_) => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompletionsResponse {
pub id: String,
pub object: String,
pub created: u64,
pub model: String,
pub choices: Vec<CompletionChoice>,
pub usage: Option<Usage>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompletionChoice {
pub text: String,
pub index: u32,
pub finish_reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Usage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_tokens: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelListResponse {
pub object: String,
pub data: Vec<ModelInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelInfo {
pub id: String,
pub object: String,
pub created: u64,
pub owned_by: String,
pub modalities: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_model_len: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning: Option<ModelReasoningCapabilities>,
pub permission: Vec<ModelPermission>,
pub root: Option<String>,
pub parent: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelReasoningCapabilities {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supported_efforts: Option<Vec<ferrum_types::ReasoningEffort>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thinking: Option<ModelThinkingCapability>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelThinkingCapability {
pub default_enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelPermission {
pub id: String,
pub object: String,
pub created: u64,
pub allow_create_engine: bool,
pub allow_sampling: bool,
pub allow_logprobs: bool,
pub allow_search_indices: bool,
pub allow_view: bool,
pub allow_fine_tuning: bool,
pub organization: String,
pub group: Option<String>,
pub is_blocking: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbeddingsRequest {
pub model: String,
pub input: EmbeddingInput,
#[serde(skip_serializing_if = "Option::is_none")]
pub encoding_format: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum EmbeddingInput {
Single(String),
Batch(Vec<String>),
SingleObject(EmbeddingItem),
BatchObjects(Vec<EmbeddingItem>),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbeddingItem {
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbeddingsResponse {
pub object: String,
pub data: Vec<EmbeddingData>,
pub model: String,
pub usage: EmbeddingUsage,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbeddingData {
pub object: String,
pub embedding: Vec<f32>,
pub index: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbeddingUsage {
pub prompt_tokens: u32,
pub total_tokens: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TranscriptionResponse {
pub text: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAiError {
pub error: OpenAiErrorDetail,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAiErrorDetail {
pub message: String,
#[serde(rename = "type")]
pub error_type: String,
pub param: Option<String>,
pub code: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum OpenAiErrorType {
InvalidRequestError,
AuthenticationError,
PermissionError,
NotFoundError,
RateLimitError,
InternalServerError,
ServiceUnavailableError,
}
#[derive(Debug, Clone)]
pub struct SseEvent {
pub event: Option<String>,
pub data: String,
pub id: Option<String>,
pub retry: Option<u32>,
}
impl SseEvent {
pub fn data(data: String) -> Self {
Self {
event: None,
data,
id: None,
retry: None,
}
}
pub fn json(value: &serde_json::Value) -> Result<Self, serde_json::Error> {
Ok(Self::data(serde_json::to_string(value)?))
}
pub fn to_string(&self) -> String {
let mut result = String::new();
if let Some(event) = &self.event {
result.push_str(&format!("event: {}\n", event));
}
if let Some(id) = &self.id {
result.push_str(&format!("id: {}\n", id));
}
if let Some(retry) = self.retry {
result.push_str(&format!("retry: {}\n", retry));
}
result.push_str(&format!("data: {}\n\n", self.data));
result
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpeechRequest {
#[serde(default = "default_tts_model")]
pub model: String,
pub input: String,
#[serde(default = "default_voice")]
pub voice: String,
#[serde(default = "default_audio_format")]
pub response_format: String,
#[serde(default = "default_language")]
pub language: String,
#[serde(default)]
pub stream: bool,
}
fn default_tts_model() -> String {
"qwen3-tts".to_string()
}
fn default_voice() -> String {
"default".to_string()
}
fn default_audio_format() -> String {
"wav".to_string()
}
fn default_language() -> String {
"auto".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn chat_request_with_assistant_fields(fields: &str) -> String {
format!(r#"{{"model":"test","messages":[{{"role":"assistant","content":null{fields}}}]}}"#)
}
#[test]
fn chat_request_normalizes_reasoning_content_at_the_wire_boundary() {
let cases = [
("missing", "", None),
("compatibility null", r#", "reasoning_content": null"#, None),
(
"compatibility empty",
r#", "reasoning_content": """#,
Some(""),
),
(
"compatibility text",
r#", "reasoning_content": "compatibility""#,
Some("compatibility"),
),
(
"canonical text",
r#", "reasoning": "canonical""#,
Some("canonical"),
),
(
"compatibility then canonical",
r#", "reasoning_content": "compatibility", "reasoning": "canonical""#,
Some("canonical"),
),
(
"canonical then compatibility",
r#", "reasoning": "canonical", "reasoning_content": "compatibility""#,
Some("canonical"),
),
(
"canonical empty wins",
r#", "reasoning": "", "reasoning_content": "compatibility""#,
Some(""),
),
(
"canonical null falls back",
r#", "reasoning": null, "reasoning_content": "compatibility""#,
Some("compatibility"),
),
(
"canonical text ignores invalid compatibility",
r#", "reasoning_content": 7, "reasoning": "canonical""#,
Some("canonical"),
),
(
"canonical empty ignores invalid compatibility",
r#", "reasoning": "", "reasoning_content": {"unexpected": true}"#,
Some(""),
),
];
for (name, fields, expected) in cases {
let request: ChatCompletionsRequest =
serde_json::from_str(&chat_request_with_assistant_fields(fields))
.unwrap_or_else(|error| panic!("{name}: {error}"));
assert_eq!(request.messages[0].reasoning.as_deref(), expected, "{name}");
let normalized = serde_json::to_value(request).expect("normalized request JSON");
let message = &normalized["messages"][0];
assert!(message.get("reasoning_content").is_none(), "{name}");
match expected {
Some(expected) => assert_eq!(message["reasoning"], expected, "{name}"),
None => assert!(message.get("reasoning").is_none(), "{name}"),
}
}
}
#[test]
fn chat_request_rejects_non_string_reasoning_fields() {
for (name, fields) in [
("compatibility", r#", "reasoning_content": 7"#),
(
"canonical is not masked by compatibility",
r#", "reasoning": 7, "reasoning_content": "compatibility""#,
),
(
"canonical null validates compatibility",
r#", "reasoning": null, "reasoning_content": 7"#,
),
] {
let error = serde_json::from_str::<ChatCompletionsRequest>(
&chat_request_with_assistant_fields(fields),
)
.expect_err(name);
assert!(error.to_string().contains("string"), "{name}: {error}");
}
}
}