Skip to main content

embacle_server/
openai_types.rs

1// ABOUTME: OpenAI-compatible request/response envelope types for the REST API
2// ABOUTME: Maps between OpenAI chat completion format and embacle ChatRequest/ChatResponse
3//
4// SPDX-License-Identifier: Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7use serde::{Deserialize, Serialize};
8
9// ============================================================================
10// Request Types
11// ============================================================================
12
13/// OpenAI-compatible chat completion request
14///
15/// Accepts either a single model string or an array of model strings
16/// for multiplex mode. Each model string may contain a provider prefix
17/// (e.g., "copilot:gpt-4o") parsed by the provider resolver.
18#[derive(Debug, Deserialize)]
19pub struct ChatCompletionRequest {
20    /// Model identifier(s) — single string or array for multiplex
21    pub model: ModelField,
22    /// Conversation messages
23    pub messages: Vec<ChatCompletionMessage>,
24    /// Whether to stream the response
25    #[serde(default)]
26    pub stream: bool,
27    /// Temperature for response randomness (0.0 - 2.0)
28    #[serde(default)]
29    pub temperature: Option<f32>,
30    /// Maximum tokens to generate
31    #[serde(default)]
32    pub max_tokens: Option<u32>,
33    /// Enable strict capability checking (reject unsupported parameters)
34    #[serde(default)]
35    pub strict_capabilities: Option<bool>,
36    /// Tool definitions for function calling
37    #[serde(default)]
38    pub tools: Option<Vec<ToolDefinition>>,
39    /// Controls which tools the model may call
40    #[serde(default)]
41    pub tool_choice: Option<ToolChoice>,
42}
43
44/// A model field that can be either a single string or an array of strings
45#[derive(Debug, Clone, Deserialize)]
46#[serde(untagged)]
47pub enum ModelField {
48    /// Single model string (standard `OpenAI`)
49    Single(String),
50    /// Array of model strings (multiplex extension)
51    Multiple(Vec<String>),
52}
53
54/// OpenAI-compatible message in a chat completion request
55#[derive(Debug, Clone, Deserialize)]
56pub struct ChatCompletionMessage {
57    /// Role: "system", "user", "assistant", or "tool"
58    pub role: String,
59    /// Message content (None for tool-call-only assistant messages)
60    pub content: Option<String>,
61    /// Tool calls requested by the assistant
62    #[serde(default)]
63    pub tool_calls: Option<Vec<ToolCall>>,
64    /// ID of the tool call this message responds to (role="tool")
65    #[serde(default)]
66    pub tool_call_id: Option<String>,
67    /// Function name for tool result messages
68    #[serde(default)]
69    pub name: Option<String>,
70}
71
72// ============================================================================
73// Tool Calling Types
74// ============================================================================
75
76/// A tool definition in the `OpenAI` format
77#[derive(Debug, Clone, Deserialize)]
78pub struct ToolDefinition {
79    /// Tool type (always "function" currently)
80    #[serde(rename = "type")]
81    pub tool_type: String,
82    /// Function definition
83    pub function: FunctionObject,
84}
85
86/// A function definition within a tool
87#[derive(Debug, Clone, Deserialize)]
88pub struct FunctionObject {
89    /// Name of the function
90    pub name: String,
91    /// Description of what the function does
92    #[serde(default)]
93    pub description: Option<String>,
94    /// JSON Schema for the function parameters
95    #[serde(default)]
96    pub parameters: Option<serde_json::Value>,
97}
98
99/// Controls which tools the model may call
100#[derive(Debug, Clone, Deserialize)]
101#[serde(untagged)]
102pub enum ToolChoice {
103    /// String variant: "none", "auto", or "required"
104    Mode(String),
105    /// Specific function variant: {"type": "function", "function": {"name": "..."}}
106    Specific(ToolChoiceSpecific),
107}
108
109/// A specific tool choice forcing a particular function
110#[derive(Debug, Clone, Deserialize)]
111pub struct ToolChoiceSpecific {
112    /// Tool type (always "function")
113    #[serde(rename = "type")]
114    pub tool_type: String,
115    /// Function to force
116    pub function: ToolChoiceFunction,
117}
118
119/// Function name within a specific tool choice
120#[derive(Debug, Clone, Deserialize)]
121pub struct ToolChoiceFunction {
122    /// Name of the function to call
123    pub name: String,
124}
125
126/// A tool call issued by the assistant
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct ToolCall {
129    /// Position index of this tool call in the array (required by `OpenAI` spec)
130    #[serde(default)]
131    pub index: usize,
132    /// Unique identifier for this tool call
133    pub id: String,
134    /// Tool type (always "function")
135    #[serde(rename = "type")]
136    pub tool_type: String,
137    /// Function call details
138    pub function: ToolCallFunction,
139}
140
141/// Function call details within a tool call
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct ToolCallFunction {
144    /// Name of the function to call
145    pub name: String,
146    /// JSON-encoded arguments
147    pub arguments: String,
148}
149
150// ============================================================================
151// Response Types (non-streaming)
152// ============================================================================
153
154/// OpenAI-compatible chat completion response
155#[derive(Debug, Serialize)]
156pub struct ChatCompletionResponse {
157    /// Unique response identifier
158    pub id: String,
159    /// Object type (always "chat.completion")
160    pub object: &'static str,
161    /// Unix timestamp of creation
162    pub created: u64,
163    /// Model used for generation
164    pub model: String,
165    /// Response choices (always one for embacle)
166    pub choices: Vec<Choice>,
167    /// Token usage statistics
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub usage: Option<Usage>,
170    /// Warnings about unsupported request parameters
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub warnings: Option<Vec<String>>,
173}
174
175/// A single choice in a chat completion response
176#[derive(Debug, Serialize)]
177pub struct Choice {
178    /// Choice index (always 0)
179    pub index: u32,
180    /// Generated message
181    pub message: ResponseMessage,
182    /// Reason the generation stopped
183    pub finish_reason: Option<String>,
184}
185
186/// Message in a chat completion response
187#[derive(Debug, Serialize)]
188pub struct ResponseMessage {
189    /// Role (always "assistant")
190    pub role: &'static str,
191    /// Generated content (None when `tool_calls` are present)
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub content: Option<String>,
194    /// Tool calls requested by the assistant
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub tool_calls: Option<Vec<ToolCall>>,
197}
198
199/// Token usage statistics
200#[derive(Debug, Serialize)]
201pub struct Usage {
202    /// Tokens in the prompt
203    #[serde(rename = "prompt_tokens")]
204    pub prompt: u32,
205    /// Tokens in the completion
206    #[serde(rename = "completion_tokens")]
207    pub completion: u32,
208    /// Total tokens
209    #[serde(rename = "total_tokens")]
210    pub total: u32,
211}
212
213// ============================================================================
214// Streaming Response Types
215// ============================================================================
216
217/// OpenAI-compatible streaming chunk
218#[derive(Debug, Serialize)]
219pub struct ChatCompletionChunk {
220    /// Unique response identifier (same across all chunks)
221    pub id: String,
222    /// Object type (always "chat.completion.chunk")
223    pub object: &'static str,
224    /// Unix timestamp of creation
225    pub created: u64,
226    /// Model used for generation
227    pub model: String,
228    /// Streaming choices
229    pub choices: Vec<ChunkChoice>,
230}
231
232/// A single choice in a streaming chunk
233#[derive(Debug, Serialize)]
234pub struct ChunkChoice {
235    /// Choice index (always 0)
236    pub index: u32,
237    /// Content delta
238    pub delta: Delta,
239    /// Reason the generation stopped (only on final chunk)
240    pub finish_reason: Option<String>,
241}
242
243/// Delta content in a streaming chunk
244#[derive(Debug, Serialize)]
245pub struct Delta {
246    /// Role (only present on first chunk)
247    #[serde(skip_serializing_if = "Option::is_none")]
248    pub role: Option<&'static str>,
249    /// Content token (empty string on role-only or final chunk)
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub content: Option<String>,
252    /// Tool calls (reserved for future streaming tool call support)
253    #[serde(skip_serializing_if = "Option::is_none")]
254    pub tool_calls: Option<Vec<ToolCall>>,
255}
256
257// ============================================================================
258// Multiplex Response (non-standard extension)
259// ============================================================================
260
261/// Response for multiplex requests (multiple providers)
262#[derive(Debug, Serialize)]
263pub struct MultiplexResponse {
264    /// Unique response identifier
265    pub id: String,
266    /// Object type (always "chat.completion.multiplex")
267    pub object: &'static str,
268    /// Unix timestamp of creation
269    pub created: u64,
270    /// Per-provider results
271    pub results: Vec<MultiplexProviderResult>,
272    /// Human-readable summary
273    pub summary: String,
274}
275
276/// Result from a single provider in a multiplex request
277#[derive(Debug, Serialize)]
278pub struct MultiplexProviderResult {
279    /// Provider identifier
280    pub provider: String,
281    /// Model used
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub model: Option<String>,
284    /// Response content (None on failure)
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub content: Option<String>,
287    /// Error message (None on success)
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub error: Option<String>,
290    /// Wall-clock time in milliseconds
291    pub duration_ms: u64,
292}
293
294// ============================================================================
295// Models Endpoint
296// ============================================================================
297
298/// Response for GET /v1/models
299#[derive(Debug, Serialize)]
300pub struct ModelsResponse {
301    /// Object type (always "list")
302    pub object: &'static str,
303    /// Available models
304    pub data: Vec<ModelObject>,
305}
306
307/// A single model entry in the models list
308#[derive(Debug, Serialize)]
309pub struct ModelObject {
310    /// Model identifier (e.g., "copilot:gpt-4o")
311    pub id: String,
312    /// Object type (always "model")
313    pub object: &'static str,
314    /// Owner/provider name
315    pub owned_by: String,
316}
317
318// ============================================================================
319// Health Endpoint
320// ============================================================================
321
322/// Response for GET /health
323#[derive(Debug, Serialize)]
324pub struct HealthResponse {
325    /// Overall status
326    pub status: &'static str,
327    /// Per-provider readiness
328    pub providers: std::collections::HashMap<String, String>,
329}
330
331// ============================================================================
332// Error Response
333// ============================================================================
334
335/// OpenAI-compatible error response
336#[derive(Debug, Serialize)]
337pub struct ErrorResponse {
338    /// Error details
339    pub error: ErrorDetail,
340}
341
342/// Error detail within an `OpenAI` error response
343#[derive(Debug, Serialize)]
344pub struct ErrorDetail {
345    /// Error message
346    pub message: String,
347    /// Error type
348    #[serde(rename = "type")]
349    pub error_type: String,
350    /// Parameter that caused the error (if applicable)
351    #[serde(skip_serializing_if = "Option::is_none")]
352    pub param: Option<String>,
353    /// Error code
354    #[serde(skip_serializing_if = "Option::is_none")]
355    pub code: Option<String>,
356}
357
358impl ErrorResponse {
359    /// Build an error response with the given type and message
360    pub fn new(error_type: impl Into<String>, message: impl Into<String>) -> Self {
361        Self {
362            error: ErrorDetail {
363                message: message.into(),
364                error_type: error_type.into(),
365                param: None,
366                code: None,
367            },
368        }
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn deserialize_single_model() {
378        let json = r#"{"model":"copilot:gpt-4o","messages":[{"role":"user","content":"hi"}]}"#;
379        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize");
380        match req.model {
381            ModelField::Single(m) => assert_eq!(m, "copilot:gpt-4o"),
382            ModelField::Multiple(_) => panic!("expected single"),
383        }
384        assert!(!req.stream);
385    }
386
387    #[test]
388    fn deserialize_multiple_models() {
389        let json = r#"{"model":["copilot:gpt-4o","claude:opus"],"messages":[{"role":"user","content":"hi"}]}"#;
390        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize");
391        match req.model {
392            ModelField::Multiple(models) => {
393                assert_eq!(models.len(), 2);
394                assert_eq!(models[0], "copilot:gpt-4o");
395                assert_eq!(models[1], "claude:opus");
396            }
397            ModelField::Single(_) => panic!("expected multiple"),
398        }
399    }
400
401    #[test]
402    fn deserialize_with_stream_flag() {
403        let json =
404            r#"{"model":"copilot","messages":[{"role":"user","content":"hi"}],"stream":true}"#;
405        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize");
406        assert!(req.stream);
407    }
408
409    #[test]
410    fn deserialize_message_with_null_content() {
411        let json = r#"{"model":"copilot","messages":[{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"search","arguments":"{}"}}]}]}"#;
412        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize");
413        assert!(req.messages[0].content.is_none());
414        assert!(req.messages[0].tool_calls.is_some());
415    }
416
417    #[test]
418    fn deserialize_message_without_content_field() {
419        let json = r#"{"model":"copilot","messages":[{"role":"tool","tool_call_id":"call_1","name":"search","content":"{\"result\":\"found\"}"}]}"#;
420        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize");
421        assert_eq!(req.messages[0].role, "tool");
422        assert_eq!(req.messages[0].tool_call_id.as_deref(), Some("call_1"));
423        assert_eq!(req.messages[0].name.as_deref(), Some("search"));
424    }
425
426    #[test]
427    fn deserialize_tool_definitions() {
428        let json = r#"{
429            "model": "copilot",
430            "messages": [{"role": "user", "content": "hi"}],
431            "tools": [{
432                "type": "function",
433                "function": {
434                    "name": "get_weather",
435                    "description": "Get weather for a city",
436                    "parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}
437                }
438            }]
439        }"#;
440        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize");
441        let tools = req.tools.expect("tools present");
442        assert_eq!(tools.len(), 1);
443        assert_eq!(tools[0].tool_type, "function");
444        assert_eq!(tools[0].function.name, "get_weather");
445        assert!(tools[0].function.parameters.is_some());
446    }
447
448    #[test]
449    fn deserialize_tool_choice_auto() {
450        let json = r#"{"model":"copilot","messages":[{"role":"user","content":"hi"}],"tool_choice":"auto"}"#;
451        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize");
452        match req.tool_choice.expect("tool_choice present") {
453            ToolChoice::Mode(m) => assert_eq!(m, "auto"),
454            ToolChoice::Specific(_) => panic!("expected mode"),
455        }
456    }
457
458    #[test]
459    fn deserialize_tool_choice_specific() {
460        let json = r#"{"model":"copilot","messages":[{"role":"user","content":"hi"}],"tool_choice":{"type":"function","function":{"name":"get_weather"}}}"#;
461        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize");
462        match req.tool_choice.expect("tool_choice present") {
463            ToolChoice::Specific(s) => assert_eq!(s.function.name, "get_weather"),
464            ToolChoice::Mode(_) => panic!("expected specific"),
465        }
466    }
467
468    #[test]
469    fn serialize_completion_response() {
470        let resp = ChatCompletionResponse {
471            id: "chatcmpl-test".to_owned(),
472            object: "chat.completion",
473            created: 1_700_000_000,
474            model: "copilot:gpt-4o".to_owned(),
475            choices: vec![Choice {
476                index: 0,
477                message: ResponseMessage {
478                    role: "assistant",
479                    content: Some("Hello!".to_owned()),
480                    tool_calls: None,
481                },
482                finish_reason: Some("stop".to_owned()),
483            }],
484            usage: None,
485            warnings: None,
486        };
487        let json = serde_json::to_string(&resp).expect("serialize");
488        assert!(json.contains("chat.completion"));
489        assert!(json.contains("Hello!"));
490        assert!(!json.contains("tool_calls"));
491    }
492
493    #[test]
494    fn serialize_response_with_tool_calls() {
495        let resp = ChatCompletionResponse {
496            id: "chatcmpl-test".to_owned(),
497            object: "chat.completion",
498            created: 1_700_000_000,
499            model: "copilot:gpt-4o".to_owned(),
500            choices: vec![Choice {
501                index: 0,
502                message: ResponseMessage {
503                    role: "assistant",
504                    content: None,
505                    tool_calls: Some(vec![ToolCall {
506                        index: 0,
507                        id: "call_abc123".to_owned(),
508                        tool_type: "function".to_owned(),
509                        function: ToolCallFunction {
510                            name: "get_weather".to_owned(),
511                            arguments: r#"{"city":"Paris"}"#.to_owned(),
512                        },
513                    }]),
514                },
515                finish_reason: Some("tool_calls".to_owned()),
516            }],
517            usage: None,
518            warnings: None,
519        };
520        let json = serde_json::to_string(&resp).expect("serialize");
521        assert!(json.contains("tool_calls"));
522        assert!(json.contains("call_abc123"));
523        assert!(json.contains("get_weather"));
524        assert!(!json.contains(r#""content""#));
525    }
526
527    #[test]
528    fn serialize_error_response() {
529        let resp = ErrorResponse::new("invalid_request_error", "Unknown model");
530        let json = serde_json::to_string(&resp).expect("serialize");
531        assert!(json.contains("invalid_request_error"));
532        assert!(json.contains("Unknown model"));
533    }
534
535    #[test]
536    fn serialize_chunk_response() {
537        let chunk = ChatCompletionChunk {
538            id: "chatcmpl-test".to_owned(),
539            object: "chat.completion.chunk",
540            created: 1_700_000_000,
541            model: "copilot".to_owned(),
542            choices: vec![ChunkChoice {
543                index: 0,
544                delta: Delta {
545                    role: None,
546                    content: Some("token".to_owned()),
547                    tool_calls: None,
548                },
549                finish_reason: None,
550            }],
551        };
552        let json = serde_json::to_string(&chunk).expect("serialize");
553        assert!(json.contains("chat.completion.chunk"));
554        assert!(json.contains("token"));
555        assert!(!json.contains("tool_calls"));
556    }
557
558    #[test]
559    fn serialize_models_response() {
560        let resp = ModelsResponse {
561            object: "list",
562            data: vec![ModelObject {
563                id: "copilot:gpt-4o".to_owned(),
564                object: "model",
565                owned_by: "copilot".to_owned(),
566            }],
567        };
568        let json = serde_json::to_string(&resp).expect("serialize");
569        assert!(json.contains("copilot:gpt-4o"));
570    }
571}