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 std::collections::HashMap;
8
9use embacle::types::TokenUsage;
10use serde::{Deserialize, Serialize};
11
12// ============================================================================
13// Request Types
14// ============================================================================
15
16/// OpenAI-compatible chat completion request
17///
18/// Accepts either a single model string or an array of model strings
19/// for multiplex mode. Each model string may contain a provider prefix
20/// (e.g., "copilot:gpt-4o") parsed by the provider resolver.
21#[derive(Debug, Deserialize)]
22pub struct ChatCompletionRequest {
23    /// Model identifier(s) — single string or array for multiplex
24    pub model: ModelField,
25    /// Conversation messages
26    pub messages: Vec<ChatCompletionMessage>,
27    /// Whether to stream the response
28    #[serde(default)]
29    pub stream: bool,
30    /// Temperature for response randomness (0.0 - 2.0)
31    #[serde(default)]
32    pub temperature: Option<f32>,
33    /// Maximum tokens to generate
34    #[serde(default)]
35    pub max_tokens: Option<u32>,
36    /// Enable strict capability checking (reject unsupported parameters)
37    #[serde(default)]
38    pub strict_capabilities: Option<bool>,
39    /// Tool definitions for function calling
40    #[serde(default)]
41    pub tools: Option<Vec<ToolDefinition>>,
42    /// Controls which tools the model may call
43    #[serde(default)]
44    pub tool_choice: Option<ToolChoice>,
45    /// Where tool calls are executed (embacle extension)
46    ///
47    /// `client` (default) returns `tool_calls` for the caller to execute and
48    /// resubmit. `server` runs an autonomous agent loop against the server's
49    /// configured MCP tool servers and returns the final assistant message.
50    #[serde(default)]
51    pub tool_execution: Option<ToolExecutionMode>,
52    /// Controls the response format (text, `json_object`, or `json_schema`)
53    #[serde(default)]
54    pub response_format: Option<ResponseFormatRequest>,
55    /// Nucleus sampling parameter (0.0 - 1.0)
56    #[serde(default)]
57    pub top_p: Option<f32>,
58    /// Stop sequence(s) that halt generation — single string or array
59    #[serde(default)]
60    pub stop: Option<StopField>,
61}
62
63/// Where tool calls are executed for a request.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
65#[serde(rename_all = "lowercase")]
66pub enum ToolExecutionMode {
67    /// Return `tool_calls` to the caller for client-side execution (default, OpenAI-standard)
68    Client,
69    /// Execute tools server-side via configured MCP servers and return the final answer
70    Server,
71}
72
73/// Stop field that accepts either a single string or an array of strings
74///
75/// Per `OpenAI` spec, the `stop` parameter can be a string or an array of
76/// up to 4 strings.
77#[derive(Debug, Clone, Deserialize)]
78#[serde(untagged)]
79pub enum StopField {
80    /// Single stop sequence
81    Single(String),
82    /// Multiple stop sequences
83    Multiple(Vec<String>),
84}
85
86/// OpenAI-specified maximum number of stop sequences
87const MAX_STOP_SEQUENCES: usize = 4;
88
89impl StopField {
90    /// Number of stop sequences in this field
91    pub const fn len(&self) -> usize {
92        match self {
93            Self::Single(_) => 1,
94            Self::Multiple(v) => v.len(),
95        }
96    }
97
98    /// Returns true if no stop sequences are present
99    pub const fn is_empty(&self) -> bool {
100        matches!(self, Self::Multiple(v) if v.is_empty())
101    }
102
103    /// Convert to a Vec of strings regardless of the variant,
104    /// truncating to the `OpenAI`-specified maximum of 4 sequences
105    pub fn into_vec(self) -> Vec<String> {
106        match self {
107            Self::Single(s) => vec![s],
108            Self::Multiple(v) => v.into_iter().take(MAX_STOP_SEQUENCES).collect(),
109        }
110    }
111
112    /// Clone only the bounded subset (up to 4 sequences) without copying
113    /// the entire input — safe for use with user-controlled data
114    pub fn to_bounded_vec(&self) -> Vec<String> {
115        match self {
116            Self::Single(s) => vec![s.clone()],
117            Self::Multiple(v) => v.iter().take(MAX_STOP_SEQUENCES).cloned().collect(),
118        }
119    }
120}
121
122/// OpenAI-compatible response format request
123#[derive(Debug, Clone, Deserialize)]
124#[serde(tag = "type")]
125pub enum ResponseFormatRequest {
126    /// Default text response
127    #[serde(rename = "text")]
128    Text,
129    /// Force JSON object output
130    #[serde(rename = "json_object")]
131    JsonObject,
132    /// Force JSON output conforming to a specific schema
133    #[serde(rename = "json_schema")]
134    JsonSchema {
135        /// The JSON schema specification
136        json_schema: JsonSchemaSpec,
137    },
138}
139
140/// JSON schema specification within a response format request
141#[derive(Debug, Clone, Deserialize)]
142pub struct JsonSchemaSpec {
143    /// Schema name for identification
144    pub name: String,
145    /// The JSON Schema definition
146    pub schema: serde_json::Value,
147}
148
149/// A model field that can be either a single string or an array of strings
150#[derive(Debug, Clone, Deserialize)]
151#[serde(untagged)]
152pub enum ModelField {
153    /// Single model string (standard `OpenAI`)
154    Single(String),
155    /// Array of model strings (multiplex extension)
156    Multiple(Vec<String>),
157}
158
159/// Message content that can be either a plain string or an array of content parts
160///
161/// Per the `OpenAI` API spec, the `content` field of a message can be either a simple
162/// string or an array of typed content parts (text, `image_url`, etc.).
163#[derive(Debug, Clone, Deserialize)]
164#[serde(untagged)]
165pub enum MessageContent {
166    /// Plain text content
167    Text(String),
168    /// Array of typed content parts (text, `image_url`, etc.)
169    Parts(Vec<ContentPart>),
170}
171
172impl MessageContent {
173    /// Extract the text content, concatenating text parts if multipart
174    pub fn as_text(&self) -> String {
175        match self {
176            Self::Text(s) => s.clone(),
177            Self::Parts(parts) => parts
178                .iter()
179                .filter_map(|p| match p {
180                    ContentPart::Text { text } => Some(text.as_str()),
181                    ContentPart::ImageUrl { .. } => None,
182                })
183                .collect::<Vec<_>>()
184                .join(""),
185        }
186    }
187}
188
189/// A single content part within a multipart message
190#[derive(Debug, Clone, Deserialize)]
191#[serde(tag = "type")]
192pub enum ContentPart {
193    /// Text content part
194    #[serde(rename = "text")]
195    Text {
196        /// The text content
197        text: String,
198    },
199    /// Image URL content part (including data URIs)
200    #[serde(rename = "image_url")]
201    ImageUrl {
202        /// Image URL details
203        image_url: ImageUrlDetail,
204    },
205}
206
207/// Image URL details within a content part
208#[derive(Debug, Clone, Deserialize)]
209pub struct ImageUrlDetail {
210    /// The image URL (can be a data URI like `data:image/png;base64,...`)
211    pub url: String,
212}
213
214/// OpenAI-compatible message in a chat completion request
215#[derive(Debug, Clone, Deserialize)]
216pub struct ChatCompletionMessage {
217    /// Role: "system", "user", "assistant", or "tool"
218    pub role: String,
219    /// Message content (None for tool-call-only assistant messages)
220    pub content: Option<MessageContent>,
221    /// Tool calls requested by the assistant
222    #[serde(default)]
223    pub tool_calls: Option<Vec<ToolCall>>,
224    /// ID of the tool call this message responds to (role="tool")
225    #[serde(default)]
226    pub tool_call_id: Option<String>,
227    /// Function name for tool result messages
228    #[serde(default)]
229    pub name: Option<String>,
230}
231
232// ============================================================================
233// Tool Calling Types
234// ============================================================================
235
236/// A tool definition in the `OpenAI` format
237#[derive(Debug, Clone, Deserialize)]
238pub struct ToolDefinition {
239    /// Tool type (always "function" currently)
240    #[serde(rename = "type")]
241    pub tool_type: String,
242    /// Function definition
243    pub function: FunctionObject,
244}
245
246/// A function definition within a tool
247#[derive(Debug, Clone, Deserialize)]
248pub struct FunctionObject {
249    /// Name of the function
250    pub name: String,
251    /// Description of what the function does
252    #[serde(default)]
253    pub description: Option<String>,
254    /// JSON Schema for the function parameters
255    #[serde(default)]
256    pub parameters: Option<serde_json::Value>,
257}
258
259/// Controls which tools the model may call
260#[derive(Debug, Clone, Deserialize)]
261#[serde(untagged)]
262pub enum ToolChoice {
263    /// String variant: "none", "auto", or "required"
264    Mode(String),
265    /// Specific function variant: {"type": "function", "function": {"name": "..."}}
266    Specific(ToolChoiceSpecific),
267}
268
269/// A specific tool choice forcing a particular function
270#[derive(Debug, Clone, Deserialize)]
271pub struct ToolChoiceSpecific {
272    /// Tool type (always "function")
273    #[serde(rename = "type")]
274    pub tool_type: String,
275    /// Function to force
276    pub function: ToolChoiceFunction,
277}
278
279/// Function name within a specific tool choice
280#[derive(Debug, Clone, Deserialize)]
281pub struct ToolChoiceFunction {
282    /// Name of the function to call
283    pub name: String,
284}
285
286/// A tool call issued by the assistant
287#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct ToolCall {
289    /// Position index of this tool call in the array (required by `OpenAI` spec)
290    #[serde(default)]
291    pub index: usize,
292    /// Unique identifier for this tool call
293    pub id: String,
294    /// Tool type (always "function")
295    #[serde(rename = "type")]
296    pub tool_type: String,
297    /// Function call details
298    pub function: ToolCallFunction,
299}
300
301/// Function call details within a tool call
302#[derive(Debug, Clone, Serialize, Deserialize)]
303pub struct ToolCallFunction {
304    /// Name of the function to call
305    pub name: String,
306    /// JSON-encoded arguments
307    pub arguments: String,
308}
309
310// ============================================================================
311// Response Types (non-streaming)
312// ============================================================================
313
314/// OpenAI-compatible chat completion response
315#[derive(Debug, Serialize)]
316pub struct ChatCompletionResponse {
317    /// Unique response identifier
318    pub id: String,
319    /// Object type (always "chat.completion")
320    pub object: &'static str,
321    /// Unix timestamp of creation
322    pub created: u64,
323    /// Model used for generation
324    pub model: String,
325    /// Response choices (always one for embacle)
326    pub choices: Vec<Choice>,
327    /// Token usage statistics
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub usage: Option<Usage>,
330    /// Warnings about unsupported request parameters
331    #[serde(skip_serializing_if = "Option::is_none")]
332    pub warnings: Option<Vec<String>>,
333}
334
335/// A single choice in a chat completion response
336#[derive(Debug, Serialize)]
337pub struct Choice {
338    /// Choice index (always 0)
339    pub index: u32,
340    /// Generated message
341    pub message: ResponseMessage,
342    /// Reason the generation stopped
343    pub finish_reason: Option<String>,
344}
345
346/// Message in a chat completion response
347#[derive(Debug, Serialize)]
348pub struct ResponseMessage {
349    /// Role (always "assistant")
350    pub role: &'static str,
351    /// Generated content (None when `tool_calls` are present)
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub content: Option<String>,
354    /// Tool calls requested by the assistant
355    #[serde(skip_serializing_if = "Option::is_none")]
356    pub tool_calls: Option<Vec<ToolCall>>,
357}
358
359/// Token usage statistics in `OpenAI`'s shape.
360///
361/// The cache and reasoning shares ride in `OpenAI`'s own breakdown objects, so
362/// a stock client keeps reading the three counts and a cost-aware one finds
363/// the shares where it already looks. A share the runner did not report is
364/// absent, never a zero: `None` means "not measured", `Some(0)` "measured zero".
365///
366/// Reasoning is counted the opposite way on each side of this type. An
367/// embacle [`TokenUsage`] keeps it out of `completion_tokens` (additive, the
368/// way its pricing bills it); `OpenAI`'s `completion_tokens` includes it and
369/// `completion_tokens_details.reasoning_tokens` is a breakdown of that count.
370/// The [`From`] conversion folds reasoning into `completion_tokens`, so a
371/// client pricing by `completion_tokens` bills the reasoning, and one taking
372/// `completion_tokens - reasoning_tokens` gets the visible output.
373#[derive(Debug, Serialize)]
374pub struct Usage {
375    /// Tokens in the prompt, cached reads and writes included
376    #[serde(rename = "prompt_tokens")]
377    pub prompt: u32,
378    /// Tokens in the completion, reasoning tokens included
379    #[serde(rename = "completion_tokens")]
380    pub completion: u32,
381    /// Total tokens, never below `prompt_tokens + completion_tokens`
382    #[serde(rename = "total_tokens")]
383    pub total: u32,
384    /// The cached share of `prompt_tokens`, when the runner reported one
385    #[serde(skip_serializing_if = "Option::is_none")]
386    pub prompt_tokens_details: Option<PromptTokensDetails>,
387    /// The reasoning share of `completion_tokens`, when the runner reported one
388    #[serde(skip_serializing_if = "Option::is_none")]
389    pub completion_tokens_details: Option<CompletionTokensDetails>,
390    /// Model requests behind the counts, when the runner reported them. Not
391    /// an `OpenAI` field: an agent runtime can make several requests for one
392    /// completion, each re-sending the prompt, and the counts are their sum.
393    #[serde(skip_serializing_if = "Option::is_none")]
394    pub model_requests: Option<u32>,
395}
396
397/// `prompt_tokens_details`: the cached share of the prompt
398#[derive(Debug, Serialize)]
399pub struct PromptTokensDetails {
400    /// Prompt tokens served from the provider's cache (`OpenAI`'s field)
401    #[serde(skip_serializing_if = "Option::is_none")]
402    pub cached_tokens: Option<u32>,
403    /// Prompt tokens written into the cache this turn. `OpenAI` bills no
404    /// write and so has no field; this is the name `OpenRouter`'s
405    /// `OpenAI`-compatible usage gives the count Anthropic bills at a premium.
406    #[serde(skip_serializing_if = "Option::is_none")]
407    pub cache_write_tokens: Option<u32>,
408}
409
410/// `completion_tokens_details`: the reasoning share of `completion_tokens`
411#[derive(Debug, Serialize)]
412pub struct CompletionTokensDetails {
413    /// Reasoning tokens, a part of `completion_tokens` (`OpenAI`'s field)
414    pub reasoning_tokens: u32,
415}
416
417impl From<&TokenUsage> for Usage {
418    fn from(usage: &TokenUsage) -> Self {
419        let prompt_tokens_details =
420            if usage.cached_read_tokens.is_some() || usage.cached_write_tokens.is_some() {
421                Some(PromptTokensDetails {
422                    cached_tokens: usage.cached_read_tokens,
423                    cache_write_tokens: usage.cached_write_tokens,
424                })
425            } else {
426                None
427            };
428        // OpenAI's completion count includes reasoning; embacle's excludes it.
429        let completion = usage
430            .completion_tokens
431            .saturating_add(usage.reasoning_tokens.unwrap_or(0));
432        // Runners differ on whether their total carries reasoning, so the
433        // reported total is raised to OpenAI's prompt + completion when short.
434        let total = usage
435            .total_tokens
436            .max(usage.prompt_tokens.saturating_add(completion));
437        Self {
438            prompt: usage.prompt_tokens,
439            completion,
440            total,
441            prompt_tokens_details,
442            completion_tokens_details: usage
443                .reasoning_tokens
444                .map(|reasoning_tokens| CompletionTokensDetails { reasoning_tokens }),
445            model_requests: usage.model_requests,
446        }
447    }
448}
449
450// ============================================================================
451// Streaming Response Types
452// ============================================================================
453
454/// OpenAI-compatible streaming chunk
455#[derive(Debug, Serialize)]
456pub struct ChatCompletionChunk {
457    /// Unique response identifier (same across all chunks)
458    pub id: String,
459    /// Object type (always "chat.completion.chunk")
460    pub object: &'static str,
461    /// Unix timestamp of creation
462    pub created: u64,
463    /// Model used for generation
464    pub model: String,
465    /// Streaming choices
466    pub choices: Vec<ChunkChoice>,
467}
468
469/// A single choice in a streaming chunk
470#[derive(Debug, Serialize)]
471pub struct ChunkChoice {
472    /// Choice index (always 0)
473    pub index: u32,
474    /// Content delta
475    pub delta: Delta,
476    /// Reason the generation stopped (only on final chunk)
477    pub finish_reason: Option<String>,
478}
479
480/// Delta content in a streaming chunk
481#[derive(Debug, Serialize)]
482pub struct Delta {
483    /// Role (only present on first chunk)
484    #[serde(skip_serializing_if = "Option::is_none")]
485    pub role: Option<&'static str>,
486    /// Content token (empty string on role-only or final chunk)
487    #[serde(skip_serializing_if = "Option::is_none")]
488    pub content: Option<String>,
489    /// Tool calls (reserved for future streaming tool call support)
490    #[serde(skip_serializing_if = "Option::is_none")]
491    pub tool_calls: Option<Vec<ToolCall>>,
492}
493
494// ============================================================================
495// Multiplex Response (non-standard extension)
496// ============================================================================
497
498/// Response for multiplex requests (multiple providers)
499#[derive(Debug, Serialize)]
500pub struct MultiplexResponse {
501    /// Unique response identifier
502    pub id: String,
503    /// Object type (always "chat.completion.multiplex")
504    pub object: &'static str,
505    /// Unix timestamp of creation
506    pub created: u64,
507    /// Per-provider results
508    pub results: Vec<MultiplexProviderResult>,
509    /// Human-readable summary
510    pub summary: String,
511}
512
513/// Result from a single provider in a multiplex request
514#[derive(Debug, Serialize)]
515pub struct MultiplexProviderResult {
516    /// Provider identifier
517    pub provider: String,
518    /// Model used
519    #[serde(skip_serializing_if = "Option::is_none")]
520    pub model: Option<String>,
521    /// Response content (None on failure)
522    #[serde(skip_serializing_if = "Option::is_none")]
523    pub content: Option<String>,
524    /// Error message (None on success)
525    #[serde(skip_serializing_if = "Option::is_none")]
526    pub error: Option<String>,
527    /// Wall-clock time in milliseconds
528    pub duration_ms: u64,
529}
530
531// ============================================================================
532// Models Endpoint
533// ============================================================================
534
535/// Response for GET /v1/models
536#[derive(Debug, Serialize)]
537pub struct ModelsResponse {
538    /// Object type (always "list")
539    pub object: &'static str,
540    /// Available models
541    pub data: Vec<ModelObject>,
542}
543
544/// A single model entry in the models list
545#[derive(Debug, Serialize)]
546pub struct ModelObject {
547    /// Model identifier (e.g., "copilot:gpt-4o")
548    pub id: String,
549    /// Object type (always "model")
550    pub object: &'static str,
551    /// Owner/provider name
552    pub owned_by: String,
553}
554
555// ============================================================================
556// Health Endpoint
557// ============================================================================
558
559/// Response for GET /health
560#[derive(Debug, Serialize)]
561pub struct HealthResponse {
562    /// Overall status
563    pub status: &'static str,
564    /// Per-provider readiness
565    pub providers: HashMap<String, String>,
566}
567
568// ============================================================================
569// Error Response
570// ============================================================================
571
572/// OpenAI-compatible error response
573#[derive(Debug, Serialize)]
574pub struct ErrorResponse {
575    /// Error details
576    pub error: ErrorDetail,
577}
578
579/// Error detail within an `OpenAI` error response
580#[derive(Debug, Serialize)]
581pub struct ErrorDetail {
582    /// Error message
583    pub message: String,
584    /// Error type
585    #[serde(rename = "type")]
586    pub error_type: String,
587    /// Parameter that caused the error (if applicable)
588    #[serde(skip_serializing_if = "Option::is_none")]
589    pub param: Option<String>,
590    /// Error code
591    #[serde(skip_serializing_if = "Option::is_none")]
592    pub code: Option<String>,
593}
594
595impl ErrorResponse {
596    /// Build an error response with the given type and message
597    pub fn new(error_type: impl Into<String>, message: impl Into<String>) -> Self {
598        Self {
599            error: ErrorDetail {
600                message: message.into(),
601                error_type: error_type.into(),
602                param: None,
603                code: None,
604            },
605        }
606    }
607}
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612
613    #[test]
614    fn usage_without_breakdown_carries_the_three_counts_only() {
615        let json =
616            serde_json::to_value(Usage::from(&TokenUsage::new(10, 5, 15))).expect("serialize"); // Safe: test assertion
617        assert_eq!(json["prompt_tokens"], 10);
618        assert_eq!(json["completion_tokens"], 5);
619        assert_eq!(json["total_tokens"], 15);
620        // A share the runner did not report stays absent, never a zero.
621        assert!(json.get("prompt_tokens_details").is_none());
622        assert!(json.get("completion_tokens_details").is_none());
623    }
624
625    #[test]
626    fn usage_forwards_cache_and_reasoning_counts_in_openai_shape() {
627        // The counts copilot --acp reports on a warm session: most of the
628        // prompt is served from cache, which `prompt_tokens` alone hides.
629        let usage = TokenUsage::new(27_862, 4, 27_866)
630            .with_cache(Some(15_320), Some(12_540))
631            .with_reasoning(Some(0));
632        let json = serde_json::to_value(Usage::from(&usage)).expect("serialize"); // Safe: test assertion
633        assert_eq!(json["prompt_tokens"], 27_862);
634        assert_eq!(json["completion_tokens"], 4);
635        assert_eq!(json["total_tokens"], 27_866);
636        assert_eq!(json["prompt_tokens_details"]["cached_tokens"], 15_320);
637        assert_eq!(json["prompt_tokens_details"]["cache_write_tokens"], 12_540);
638        assert_eq!(json["completion_tokens_details"]["reasoning_tokens"], 0);
639    }
640
641    #[test]
642    fn usage_folds_reasoning_into_openai_completion_tokens() {
643        // embacle keeps reasoning apart from completion; OpenAI counts it in.
644        let usage = TokenUsage::new(100, 7, 127).with_reasoning(Some(20));
645        let json = serde_json::to_value(Usage::from(&usage)).expect("serialize"); // Safe: test assertion
646        assert_eq!(json["prompt_tokens"], 100);
647        assert_eq!(json["completion_tokens"], 27);
648        assert_eq!(json["completion_tokens_details"]["reasoning_tokens"], 20);
649        assert_eq!(json["total_tokens"], 127);
650    }
651
652    #[test]
653    fn usage_total_is_raised_when_the_runner_total_omits_reasoning() {
654        // A runner whose total is prompt + completion, reasoning left out.
655        let usage = TokenUsage::new(100, 7, 107).with_reasoning(Some(20));
656        let json = serde_json::to_value(Usage::from(&usage)).expect("serialize"); // Safe: test assertion
657        assert_eq!(json["completion_tokens"], 27);
658        assert_eq!(json["total_tokens"], 127);
659    }
660
661    #[test]
662    fn usage_omits_each_unreported_share_on_its_own() {
663        let usage = TokenUsage::new(100, 7, 107).with_cache(Some(64), None);
664        let json = serde_json::to_value(Usage::from(&usage)).expect("serialize"); // Safe: test assertion
665        assert_eq!(json["prompt_tokens_details"]["cached_tokens"], 64);
666        assert!(json["prompt_tokens_details"]
667            .get("cache_write_tokens")
668            .is_none());
669        assert!(json.get("completion_tokens_details").is_none());
670    }
671
672    #[test]
673    fn deserialize_single_model() {
674        let json = r#"{"model":"copilot:gpt-4o","messages":[{"role":"user","content":"hi"}]}"#;
675        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
676        match req.model {
677            ModelField::Single(m) => assert_eq!(m, "copilot:gpt-4o"),
678            ModelField::Multiple(_) => unreachable!("expected single"), // Safe: test assertion
679        }
680        assert!(!req.stream);
681    }
682
683    #[test]
684    fn deserialize_multiple_models() {
685        let json = r#"{"model":["copilot:gpt-4o","claude:opus"],"messages":[{"role":"user","content":"hi"}]}"#;
686        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
687        match req.model {
688            ModelField::Multiple(models) => {
689                assert_eq!(models.len(), 2);
690                assert_eq!(models[0], "copilot:gpt-4o");
691                assert_eq!(models[1], "claude:opus");
692            }
693            ModelField::Single(_) => unreachable!("expected multiple"), // Safe: test assertion
694        }
695    }
696
697    #[test]
698    fn deserialize_with_stream_flag() {
699        let json =
700            r#"{"model":"copilot","messages":[{"role":"user","content":"hi"}],"stream":true}"#;
701        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
702        assert!(req.stream);
703    }
704
705    #[test]
706    fn deserialize_message_with_null_content() {
707        let json = r#"{"model":"copilot","messages":[{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"search","arguments":"{}"}}]}]}"#;
708        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
709        assert!(req.messages[0].content.is_none());
710        assert!(req.messages[0].tool_calls.is_some());
711    }
712
713    #[test]
714    fn deserialize_message_without_content_field() {
715        let json = r#"{"model":"copilot","messages":[{"role":"tool","tool_call_id":"call_1","name":"search","content":"{\"result\":\"found\"}"}]}"#;
716        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
717        assert_eq!(req.messages[0].role, "tool");
718        assert_eq!(req.messages[0].tool_call_id.as_deref(), Some("call_1"));
719        assert_eq!(req.messages[0].name.as_deref(), Some("search"));
720    }
721
722    #[test]
723    fn deserialize_multipart_content() {
724        let json = r#"{
725            "model": "copilot",
726            "messages": [{
727                "role": "user",
728                "content": [
729                    {"type": "text", "text": "What is in this image?"},
730                    {"type": "image_url", "image_url": {"url": "data:image/png;base64,aGVsbG8="}}
731                ]
732            }]
733        }"#;
734        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
735        let content = req.messages[0].content.as_ref().expect("content present"); // Safe: test assertion
736        match content {
737            MessageContent::Parts(parts) => {
738                assert_eq!(parts.len(), 2);
739                assert!(
740                    matches!(&parts[0], ContentPart::Text { text } if text == "What is in this image?")
741                );
742                assert!(
743                    matches!(&parts[1], ContentPart::ImageUrl { image_url } if image_url.url.contains("base64"))
744                );
745            }
746            MessageContent::Text(_) => unreachable!("expected Parts variant"), // Safe: test assertion
747        }
748    }
749
750    #[test]
751    fn message_content_as_text_plain_string() {
752        let content = MessageContent::Text("hello".to_owned());
753        assert_eq!(content.as_text(), "hello");
754    }
755
756    #[test]
757    fn message_content_as_text_multipart() {
758        let content = MessageContent::Parts(vec![
759            ContentPart::Text {
760                text: "describe ".to_owned(),
761            },
762            ContentPart::ImageUrl {
763                image_url: ImageUrlDetail {
764                    url: "data:image/png;base64,abc".to_owned(),
765                },
766            },
767            ContentPart::Text {
768                text: "this image".to_owned(),
769            },
770        ]);
771        assert_eq!(content.as_text(), "describe this image");
772    }
773
774    #[test]
775    fn deserialize_plain_string_content_backward_compat() {
776        let json = r#"{"model":"copilot","messages":[{"role":"user","content":"hi"}]}"#;
777        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
778        let content = req.messages[0].content.as_ref().expect("content present"); // Safe: test assertion
779        match content {
780            MessageContent::Text(s) => assert_eq!(s, "hi"),
781            MessageContent::Parts(_) => unreachable!("expected Text variant"), // Safe: test assertion
782        }
783    }
784
785    #[test]
786    fn deserialize_tool_definitions() {
787        let json = r#"{
788            "model": "copilot",
789            "messages": [{"role": "user", "content": "hi"}],
790            "tools": [{
791                "type": "function",
792                "function": {
793                    "name": "get_weather",
794                    "description": "Get weather for a city",
795                    "parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}
796                }
797            }]
798        }"#;
799        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
800        let tools = req.tools.expect("tools present"); // Safe: test assertion
801        assert_eq!(tools.len(), 1);
802        assert_eq!(tools[0].tool_type, "function");
803        assert_eq!(tools[0].function.name, "get_weather");
804        assert!(tools[0].function.parameters.is_some());
805    }
806
807    #[test]
808    fn deserialize_tool_choice_auto() {
809        let json = r#"{"model":"copilot","messages":[{"role":"user","content":"hi"}],"tool_choice":"auto"}"#;
810        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
811        let tool_choice = req.tool_choice.expect("tool_choice present"); // Safe: test assertion
812        match tool_choice {
813            ToolChoice::Mode(m) => assert_eq!(m, "auto"),
814            ToolChoice::Specific(_) => unreachable!("expected mode"), // Safe: test assertion
815        }
816    }
817
818    #[test]
819    fn deserialize_tool_choice_specific() {
820        let json = r#"{"model":"copilot","messages":[{"role":"user","content":"hi"}],"tool_choice":{"type":"function","function":{"name":"get_weather"}}}"#;
821        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
822        let tool_choice = req.tool_choice.expect("tool_choice present"); // Safe: test assertion
823        match tool_choice {
824            ToolChoice::Specific(s) => assert_eq!(s.function.name, "get_weather"),
825            ToolChoice::Mode(_) => unreachable!("expected specific"), // Safe: test assertion
826        }
827    }
828
829    #[test]
830    fn serialize_completion_response() {
831        let resp = ChatCompletionResponse {
832            id: "chatcmpl-test".to_owned(),
833            object: "chat.completion",
834            created: 1_700_000_000,
835            model: "copilot:gpt-4o".to_owned(),
836            choices: vec![Choice {
837                index: 0,
838                message: ResponseMessage {
839                    role: "assistant",
840                    content: Some("Hello!".to_owned()),
841                    tool_calls: None,
842                },
843                finish_reason: Some("stop".to_owned()),
844            }],
845            usage: None,
846            warnings: None,
847        };
848        let json = serde_json::to_string(&resp).expect("serialize"); // Safe: test assertion
849        assert!(json.contains("chat.completion"));
850        assert!(json.contains("Hello!"));
851        assert!(!json.contains("tool_calls"));
852    }
853
854    #[test]
855    fn serialize_response_with_tool_calls() {
856        let resp = ChatCompletionResponse {
857            id: "chatcmpl-test".to_owned(),
858            object: "chat.completion",
859            created: 1_700_000_000,
860            model: "copilot:gpt-4o".to_owned(),
861            choices: vec![Choice {
862                index: 0,
863                message: ResponseMessage {
864                    role: "assistant",
865                    content: None,
866                    tool_calls: Some(vec![ToolCall {
867                        index: 0,
868                        id: "call_abc123".to_owned(),
869                        tool_type: "function".to_owned(),
870                        function: ToolCallFunction {
871                            name: "get_weather".to_owned(),
872                            arguments: r#"{"city":"Paris"}"#.to_owned(),
873                        },
874                    }]),
875                },
876                finish_reason: Some("tool_calls".to_owned()),
877            }],
878            usage: None,
879            warnings: None,
880        };
881        let json = serde_json::to_string(&resp).expect("serialize"); // Safe: test assertion
882        assert!(json.contains("tool_calls"));
883        assert!(json.contains("call_abc123"));
884        assert!(json.contains("get_weather"));
885        assert!(!json.contains(r#""content""#));
886    }
887
888    #[test]
889    fn serialize_error_response() {
890        let resp = ErrorResponse::new("invalid_request_error", "Unknown model");
891        let json = serde_json::to_string(&resp).expect("serialize"); // Safe: test assertion
892        assert!(json.contains("invalid_request_error"));
893        assert!(json.contains("Unknown model"));
894    }
895
896    #[test]
897    fn serialize_chunk_response() {
898        let chunk = ChatCompletionChunk {
899            id: "chatcmpl-test".to_owned(),
900            object: "chat.completion.chunk",
901            created: 1_700_000_000,
902            model: "copilot".to_owned(),
903            choices: vec![ChunkChoice {
904                index: 0,
905                delta: Delta {
906                    role: None,
907                    content: Some("token".to_owned()),
908                    tool_calls: None,
909                },
910                finish_reason: None,
911            }],
912        };
913        let json = serde_json::to_string(&chunk).expect("serialize"); // Safe: test assertion
914        assert!(json.contains("chat.completion.chunk"));
915        assert!(json.contains("token"));
916        assert!(!json.contains("tool_calls"));
917    }
918
919    #[test]
920    fn deserialize_tool_execution_server() {
921        let json = r#"{"model":"copilot","messages":[{"role":"user","content":"hi"}],"tool_execution":"server"}"#;
922        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
923        assert_eq!(req.tool_execution, Some(ToolExecutionMode::Server));
924    }
925
926    #[test]
927    fn deserialize_tool_execution_client() {
928        let json = r#"{"model":"copilot","messages":[{"role":"user","content":"hi"}],"tool_execution":"client"}"#;
929        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
930        assert_eq!(req.tool_execution, Some(ToolExecutionMode::Client));
931    }
932
933    #[test]
934    fn tool_execution_defaults_to_none() {
935        let json = r#"{"model":"copilot","messages":[{"role":"user","content":"hi"}]}"#;
936        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
937        assert_eq!(req.tool_execution, None);
938    }
939
940    #[test]
941    fn deserialize_tool_choice_none() {
942        let json = r#"{"model":"copilot","messages":[{"role":"user","content":"hi"}],"tool_choice":"none"}"#;
943        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
944        let tool_choice = req.tool_choice.expect("tool_choice present"); // Safe: test assertion
945        match tool_choice {
946            ToolChoice::Mode(m) => assert_eq!(m, "none"),
947            ToolChoice::Specific(_) => unreachable!("expected mode"), // Safe: test assertion
948        }
949    }
950
951    #[test]
952    fn deserialize_tool_choice_required() {
953        let json = r#"{"model":"copilot","messages":[{"role":"user","content":"hi"}],"tool_choice":"required"}"#;
954        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
955        let tool_choice = req.tool_choice.expect("tool_choice present"); // Safe: test assertion
956        match tool_choice {
957            ToolChoice::Mode(m) => assert_eq!(m, "required"),
958            ToolChoice::Specific(_) => unreachable!("expected mode"), // Safe: test assertion
959        }
960    }
961
962    #[test]
963    fn deserialize_response_format_text() {
964        let json = r#"{"model":"copilot","messages":[{"role":"user","content":"hi"}],"response_format":{"type":"text"}}"#;
965        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
966        assert!(matches!(
967            req.response_format,
968            Some(ResponseFormatRequest::Text)
969        ));
970    }
971
972    #[test]
973    fn deserialize_response_format_json_object() {
974        let json = r#"{"model":"copilot","messages":[{"role":"user","content":"hi"}],"response_format":{"type":"json_object"}}"#;
975        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
976        assert!(matches!(
977            req.response_format,
978            Some(ResponseFormatRequest::JsonObject)
979        ));
980    }
981
982    #[test]
983    fn deserialize_response_format_json_schema() {
984        let json = r#"{
985            "model": "copilot",
986            "messages": [{"role": "user", "content": "hi"}],
987            "response_format": {
988                "type": "json_schema",
989                "json_schema": {
990                    "name": "weather",
991                    "schema": {"type": "object", "properties": {"temp": {"type": "number"}}}
992                }
993            }
994        }"#;
995        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
996        match req.response_format {
997            Some(ResponseFormatRequest::JsonSchema { json_schema }) => {
998                assert_eq!(json_schema.name, "weather");
999                assert!(json_schema.schema["properties"]["temp"].is_object());
1000            }
1001            other => unreachable!("expected JsonSchema, got: {other:?}"), // Safe: test assertion
1002        }
1003    }
1004
1005    #[test]
1006    fn deserialize_top_p() {
1007        let json = r#"{"model":"copilot","messages":[{"role":"user","content":"hi"}],"top_p":0.9}"#;
1008        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
1009        assert_eq!(req.top_p, Some(0.9));
1010    }
1011
1012    #[test]
1013    fn deserialize_stop_single() {
1014        let json =
1015            r#"{"model":"copilot","messages":[{"role":"user","content":"hi"}],"stop":"END"}"#;
1016        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
1017        let stop = req.stop.expect("stop present"); // Safe: test assertion
1018        assert_eq!(stop.into_vec(), vec!["END"]);
1019    }
1020
1021    #[test]
1022    fn deserialize_stop_array() {
1023        let json = r#"{"model":"copilot","messages":[{"role":"user","content":"hi"}],"stop":["END","STOP"]}"#;
1024        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
1025        let stop = req.stop.expect("stop present"); // Safe: test assertion
1026        assert_eq!(stop.into_vec(), vec!["END", "STOP"]);
1027    }
1028
1029    #[test]
1030    fn stop_field_len() {
1031        let single = StopField::Single("END".to_owned());
1032        assert_eq!(single.len(), 1);
1033        let multiple = StopField::Multiple(vec!["A".to_owned(), "B".to_owned(), "C".to_owned()]);
1034        assert_eq!(multiple.len(), 3);
1035    }
1036
1037    #[test]
1038    fn stop_field_into_vec_truncates_at_four() {
1039        let oversized = StopField::Multiple((0..10).map(|i| format!("stop_{i}")).collect());
1040        let result = oversized.into_vec();
1041        assert_eq!(result.len(), 4);
1042        assert_eq!(result[0], "stop_0");
1043        assert_eq!(result[3], "stop_3");
1044    }
1045
1046    #[test]
1047    fn deserialize_all_optional_fields() {
1048        let json = r#"{
1049            "model": "copilot",
1050            "messages": [{"role": "user", "content": "hi"}],
1051            "temperature": 0.7,
1052            "max_tokens": 100,
1053            "top_p": 0.95,
1054            "stop": ["END"],
1055            "stream": true
1056        }"#;
1057        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
1058        assert_eq!(req.temperature, Some(0.7));
1059        assert_eq!(req.max_tokens, Some(100));
1060        assert_eq!(req.top_p, Some(0.95));
1061        assert!(req.stop.is_some());
1062        assert!(req.stream);
1063    }
1064
1065    #[test]
1066    fn serialize_models_response() {
1067        let resp = ModelsResponse {
1068            object: "list",
1069            data: vec![ModelObject {
1070                id: "copilot:gpt-4o".to_owned(),
1071                object: "model",
1072                owned_by: "copilot".to_owned(),
1073            }],
1074        };
1075        let json = serde_json::to_string(&resp).expect("serialize"); // Safe: test assertion
1076        assert!(json.contains("copilot:gpt-4o"));
1077    }
1078}