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