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}
34
35/// A model field that can be either a single string or an array of strings
36#[derive(Debug, Clone, Deserialize)]
37#[serde(untagged)]
38pub enum ModelField {
39    /// Single model string (standard `OpenAI`)
40    Single(String),
41    /// Array of model strings (multiplex extension)
42    Multiple(Vec<String>),
43}
44
45/// OpenAI-compatible message in a chat completion request
46#[derive(Debug, Clone, Deserialize)]
47pub struct ChatCompletionMessage {
48    /// Role: "system", "user", or "assistant"
49    pub role: String,
50    /// Message content
51    pub content: String,
52}
53
54// ============================================================================
55// Response Types (non-streaming)
56// ============================================================================
57
58/// OpenAI-compatible chat completion response
59#[derive(Debug, Serialize)]
60pub struct ChatCompletionResponse {
61    /// Unique response identifier
62    pub id: String,
63    /// Object type (always "chat.completion")
64    pub object: &'static str,
65    /// Unix timestamp of creation
66    pub created: u64,
67    /// Model used for generation
68    pub model: String,
69    /// Response choices (always one for embacle)
70    pub choices: Vec<Choice>,
71    /// Token usage statistics
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub usage: Option<Usage>,
74}
75
76/// A single choice in a chat completion response
77#[derive(Debug, Serialize)]
78pub struct Choice {
79    /// Choice index (always 0)
80    pub index: u32,
81    /// Generated message
82    pub message: ResponseMessage,
83    /// Reason the generation stopped
84    pub finish_reason: Option<String>,
85}
86
87/// Message in a chat completion response
88#[derive(Debug, Serialize)]
89pub struct ResponseMessage {
90    /// Role (always "assistant")
91    pub role: &'static str,
92    /// Generated content
93    pub content: String,
94}
95
96/// Token usage statistics
97#[derive(Debug, Serialize)]
98pub struct Usage {
99    /// Tokens in the prompt
100    #[serde(rename = "prompt_tokens")]
101    pub prompt: u32,
102    /// Tokens in the completion
103    #[serde(rename = "completion_tokens")]
104    pub completion: u32,
105    /// Total tokens
106    #[serde(rename = "total_tokens")]
107    pub total: u32,
108}
109
110// ============================================================================
111// Streaming Response Types
112// ============================================================================
113
114/// OpenAI-compatible streaming chunk
115#[derive(Debug, Serialize)]
116pub struct ChatCompletionChunk {
117    /// Unique response identifier (same across all chunks)
118    pub id: String,
119    /// Object type (always "chat.completion.chunk")
120    pub object: &'static str,
121    /// Unix timestamp of creation
122    pub created: u64,
123    /// Model used for generation
124    pub model: String,
125    /// Streaming choices
126    pub choices: Vec<ChunkChoice>,
127}
128
129/// A single choice in a streaming chunk
130#[derive(Debug, Serialize)]
131pub struct ChunkChoice {
132    /// Choice index (always 0)
133    pub index: u32,
134    /// Content delta
135    pub delta: Delta,
136    /// Reason the generation stopped (only on final chunk)
137    pub finish_reason: Option<String>,
138}
139
140/// Delta content in a streaming chunk
141#[derive(Debug, Serialize)]
142pub struct Delta {
143    /// Role (only present on first chunk)
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub role: Option<&'static str>,
146    /// Content token (empty string on role-only or final chunk)
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub content: Option<String>,
149}
150
151// ============================================================================
152// Multiplex Response (non-standard extension)
153// ============================================================================
154
155/// Response for multiplex requests (multiple providers)
156#[derive(Debug, Serialize)]
157pub struct MultiplexResponse {
158    /// Unique response identifier
159    pub id: String,
160    /// Object type (always "chat.completion.multiplex")
161    pub object: &'static str,
162    /// Unix timestamp of creation
163    pub created: u64,
164    /// Per-provider results
165    pub results: Vec<MultiplexProviderResult>,
166    /// Human-readable summary
167    pub summary: String,
168}
169
170/// Result from a single provider in a multiplex request
171#[derive(Debug, Serialize)]
172pub struct MultiplexProviderResult {
173    /// Provider identifier
174    pub provider: String,
175    /// Model used
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub model: Option<String>,
178    /// Response content (None on failure)
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub content: Option<String>,
181    /// Error message (None on success)
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub error: Option<String>,
184    /// Wall-clock time in milliseconds
185    pub duration_ms: u64,
186}
187
188// ============================================================================
189// Models Endpoint
190// ============================================================================
191
192/// Response for GET /v1/models
193#[derive(Debug, Serialize)]
194pub struct ModelsResponse {
195    /// Object type (always "list")
196    pub object: &'static str,
197    /// Available models
198    pub data: Vec<ModelObject>,
199}
200
201/// A single model entry in the models list
202#[derive(Debug, Serialize)]
203pub struct ModelObject {
204    /// Model identifier (e.g., "copilot:gpt-4o")
205    pub id: String,
206    /// Object type (always "model")
207    pub object: &'static str,
208    /// Owner/provider name
209    pub owned_by: String,
210}
211
212// ============================================================================
213// Health Endpoint
214// ============================================================================
215
216/// Response for GET /health
217#[derive(Debug, Serialize)]
218pub struct HealthResponse {
219    /// Overall status
220    pub status: &'static str,
221    /// Per-provider readiness
222    pub providers: std::collections::HashMap<String, String>,
223}
224
225// ============================================================================
226// Error Response
227// ============================================================================
228
229/// OpenAI-compatible error response
230#[derive(Debug, Serialize)]
231pub struct ErrorResponse {
232    /// Error details
233    pub error: ErrorDetail,
234}
235
236/// Error detail within an `OpenAI` error response
237#[derive(Debug, Serialize)]
238pub struct ErrorDetail {
239    /// Error message
240    pub message: String,
241    /// Error type
242    #[serde(rename = "type")]
243    pub error_type: String,
244    /// Parameter that caused the error (if applicable)
245    #[serde(skip_serializing_if = "Option::is_none")]
246    pub param: Option<String>,
247    /// Error code
248    #[serde(skip_serializing_if = "Option::is_none")]
249    pub code: Option<String>,
250}
251
252impl ErrorResponse {
253    /// Build an error response with the given type and message
254    pub fn new(error_type: impl Into<String>, message: impl Into<String>) -> Self {
255        Self {
256            error: ErrorDetail {
257                message: message.into(),
258                error_type: error_type.into(),
259                param: None,
260                code: None,
261            },
262        }
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn deserialize_single_model() {
272        let json = r#"{"model":"copilot:gpt-4o","messages":[{"role":"user","content":"hi"}]}"#;
273        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize");
274        match req.model {
275            ModelField::Single(m) => assert_eq!(m, "copilot:gpt-4o"),
276            ModelField::Multiple(_) => panic!("expected single"),
277        }
278        assert!(!req.stream);
279    }
280
281    #[test]
282    fn deserialize_multiple_models() {
283        let json = r#"{"model":["copilot:gpt-4o","claude:opus"],"messages":[{"role":"user","content":"hi"}]}"#;
284        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize");
285        match req.model {
286            ModelField::Multiple(models) => {
287                assert_eq!(models.len(), 2);
288                assert_eq!(models[0], "copilot:gpt-4o");
289                assert_eq!(models[1], "claude:opus");
290            }
291            ModelField::Single(_) => panic!("expected multiple"),
292        }
293    }
294
295    #[test]
296    fn deserialize_with_stream_flag() {
297        let json =
298            r#"{"model":"copilot","messages":[{"role":"user","content":"hi"}],"stream":true}"#;
299        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize");
300        assert!(req.stream);
301    }
302
303    #[test]
304    fn serialize_completion_response() {
305        let resp = ChatCompletionResponse {
306            id: "chatcmpl-test".to_owned(),
307            object: "chat.completion",
308            created: 1_700_000_000,
309            model: "copilot:gpt-4o".to_owned(),
310            choices: vec![Choice {
311                index: 0,
312                message: ResponseMessage {
313                    role: "assistant",
314                    content: "Hello!".to_owned(),
315                },
316                finish_reason: Some("stop".to_owned()),
317            }],
318            usage: None,
319        };
320        let json = serde_json::to_string(&resp).expect("serialize");
321        assert!(json.contains("chat.completion"));
322        assert!(json.contains("Hello!"));
323    }
324
325    #[test]
326    fn serialize_error_response() {
327        let resp = ErrorResponse::new("invalid_request_error", "Unknown model");
328        let json = serde_json::to_string(&resp).expect("serialize");
329        assert!(json.contains("invalid_request_error"));
330        assert!(json.contains("Unknown model"));
331    }
332
333    #[test]
334    fn serialize_chunk_response() {
335        let chunk = ChatCompletionChunk {
336            id: "chatcmpl-test".to_owned(),
337            object: "chat.completion.chunk",
338            created: 1_700_000_000,
339            model: "copilot".to_owned(),
340            choices: vec![ChunkChoice {
341                index: 0,
342                delta: Delta {
343                    role: None,
344                    content: Some("token".to_owned()),
345                },
346                finish_reason: None,
347            }],
348        };
349        let json = serde_json::to_string(&chunk).expect("serialize");
350        assert!(json.contains("chat.completion.chunk"));
351        assert!(json.contains("token"));
352    }
353
354    #[test]
355    fn serialize_models_response() {
356        let resp = ModelsResponse {
357            object: "list",
358            data: vec![ModelObject {
359                id: "copilot:gpt-4o".to_owned(),
360                object: "model",
361                owned_by: "copilot".to_owned(),
362            }],
363        };
364        let json = serde_json::to_string(&resp).expect("serialize");
365        assert!(json.contains("copilot:gpt-4o"));
366    }
367}