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}
391
392/// `prompt_tokens_details`: the cached share of the prompt
393#[derive(Debug, Serialize)]
394pub struct PromptTokensDetails {
395    /// Prompt tokens served from the provider's cache (`OpenAI`'s field)
396    #[serde(skip_serializing_if = "Option::is_none")]
397    pub cached_tokens: Option<u32>,
398    /// Prompt tokens written into the cache this turn. `OpenAI` bills no
399    /// write and so has no field; this is the name `OpenRouter`'s
400    /// `OpenAI`-compatible usage gives the count Anthropic bills at a premium.
401    #[serde(skip_serializing_if = "Option::is_none")]
402    pub cache_write_tokens: Option<u32>,
403}
404
405/// `completion_tokens_details`: the reasoning share of `completion_tokens`
406#[derive(Debug, Serialize)]
407pub struct CompletionTokensDetails {
408    /// Reasoning tokens, a part of `completion_tokens` (`OpenAI`'s field)
409    pub reasoning_tokens: u32,
410}
411
412impl From<&TokenUsage> for Usage {
413    fn from(usage: &TokenUsage) -> Self {
414        let prompt_tokens_details =
415            if usage.cached_read_tokens.is_some() || usage.cached_write_tokens.is_some() {
416                Some(PromptTokensDetails {
417                    cached_tokens: usage.cached_read_tokens,
418                    cache_write_tokens: usage.cached_write_tokens,
419                })
420            } else {
421                None
422            };
423        // OpenAI's completion count includes reasoning; embacle's excludes it.
424        let completion = usage
425            .completion_tokens
426            .saturating_add(usage.reasoning_tokens.unwrap_or(0));
427        // Runners differ on whether their total carries reasoning, so the
428        // reported total is raised to OpenAI's prompt + completion when short.
429        let total = usage
430            .total_tokens
431            .max(usage.prompt_tokens.saturating_add(completion));
432        Self {
433            prompt: usage.prompt_tokens,
434            completion,
435            total,
436            prompt_tokens_details,
437            completion_tokens_details: usage
438                .reasoning_tokens
439                .map(|reasoning_tokens| CompletionTokensDetails { reasoning_tokens }),
440        }
441    }
442}
443
444// ============================================================================
445// Streaming Response Types
446// ============================================================================
447
448/// OpenAI-compatible streaming chunk
449#[derive(Debug, Serialize)]
450pub struct ChatCompletionChunk {
451    /// Unique response identifier (same across all chunks)
452    pub id: String,
453    /// Object type (always "chat.completion.chunk")
454    pub object: &'static str,
455    /// Unix timestamp of creation
456    pub created: u64,
457    /// Model used for generation
458    pub model: String,
459    /// Streaming choices
460    pub choices: Vec<ChunkChoice>,
461}
462
463/// A single choice in a streaming chunk
464#[derive(Debug, Serialize)]
465pub struct ChunkChoice {
466    /// Choice index (always 0)
467    pub index: u32,
468    /// Content delta
469    pub delta: Delta,
470    /// Reason the generation stopped (only on final chunk)
471    pub finish_reason: Option<String>,
472}
473
474/// Delta content in a streaming chunk
475#[derive(Debug, Serialize)]
476pub struct Delta {
477    /// Role (only present on first chunk)
478    #[serde(skip_serializing_if = "Option::is_none")]
479    pub role: Option<&'static str>,
480    /// Content token (empty string on role-only or final chunk)
481    #[serde(skip_serializing_if = "Option::is_none")]
482    pub content: Option<String>,
483    /// Tool calls (reserved for future streaming tool call support)
484    #[serde(skip_serializing_if = "Option::is_none")]
485    pub tool_calls: Option<Vec<ToolCall>>,
486}
487
488// ============================================================================
489// Multiplex Response (non-standard extension)
490// ============================================================================
491
492/// Response for multiplex requests (multiple providers)
493#[derive(Debug, Serialize)]
494pub struct MultiplexResponse {
495    /// Unique response identifier
496    pub id: String,
497    /// Object type (always "chat.completion.multiplex")
498    pub object: &'static str,
499    /// Unix timestamp of creation
500    pub created: u64,
501    /// Per-provider results
502    pub results: Vec<MultiplexProviderResult>,
503    /// Human-readable summary
504    pub summary: String,
505}
506
507/// Result from a single provider in a multiplex request
508#[derive(Debug, Serialize)]
509pub struct MultiplexProviderResult {
510    /// Provider identifier
511    pub provider: String,
512    /// Model used
513    #[serde(skip_serializing_if = "Option::is_none")]
514    pub model: Option<String>,
515    /// Response content (None on failure)
516    #[serde(skip_serializing_if = "Option::is_none")]
517    pub content: Option<String>,
518    /// Error message (None on success)
519    #[serde(skip_serializing_if = "Option::is_none")]
520    pub error: Option<String>,
521    /// Wall-clock time in milliseconds
522    pub duration_ms: u64,
523}
524
525// ============================================================================
526// Models Endpoint
527// ============================================================================
528
529/// Response for GET /v1/models
530#[derive(Debug, Serialize)]
531pub struct ModelsResponse {
532    /// Object type (always "list")
533    pub object: &'static str,
534    /// Available models
535    pub data: Vec<ModelObject>,
536}
537
538/// A single model entry in the models list
539#[derive(Debug, Serialize)]
540pub struct ModelObject {
541    /// Model identifier (e.g., "copilot:gpt-4o")
542    pub id: String,
543    /// Object type (always "model")
544    pub object: &'static str,
545    /// Owner/provider name
546    pub owned_by: String,
547}
548
549// ============================================================================
550// Health Endpoint
551// ============================================================================
552
553/// Response for GET /health
554#[derive(Debug, Serialize)]
555pub struct HealthResponse {
556    /// Overall status
557    pub status: &'static str,
558    /// Per-provider readiness
559    pub providers: HashMap<String, String>,
560}
561
562// ============================================================================
563// Error Response
564// ============================================================================
565
566/// OpenAI-compatible error response
567#[derive(Debug, Serialize)]
568pub struct ErrorResponse {
569    /// Error details
570    pub error: ErrorDetail,
571}
572
573/// Error detail within an `OpenAI` error response
574#[derive(Debug, Serialize)]
575pub struct ErrorDetail {
576    /// Error message
577    pub message: String,
578    /// Error type
579    #[serde(rename = "type")]
580    pub error_type: String,
581    /// Parameter that caused the error (if applicable)
582    #[serde(skip_serializing_if = "Option::is_none")]
583    pub param: Option<String>,
584    /// Error code
585    #[serde(skip_serializing_if = "Option::is_none")]
586    pub code: Option<String>,
587}
588
589impl ErrorResponse {
590    /// Build an error response with the given type and message
591    pub fn new(error_type: impl Into<String>, message: impl Into<String>) -> Self {
592        Self {
593            error: ErrorDetail {
594                message: message.into(),
595                error_type: error_type.into(),
596                param: None,
597                code: None,
598            },
599        }
600    }
601}
602
603#[cfg(test)]
604mod tests {
605    use super::*;
606
607    #[test]
608    fn usage_without_breakdown_carries_the_three_counts_only() {
609        let json =
610            serde_json::to_value(Usage::from(&TokenUsage::new(10, 5, 15))).expect("serialize"); // Safe: test assertion
611        assert_eq!(json["prompt_tokens"], 10);
612        assert_eq!(json["completion_tokens"], 5);
613        assert_eq!(json["total_tokens"], 15);
614        // A share the runner did not report stays absent, never a zero.
615        assert!(json.get("prompt_tokens_details").is_none());
616        assert!(json.get("completion_tokens_details").is_none());
617    }
618
619    #[test]
620    fn usage_forwards_cache_and_reasoning_counts_in_openai_shape() {
621        // The counts copilot --acp reports on a warm session: most of the
622        // prompt is served from cache, which `prompt_tokens` alone hides.
623        let usage = TokenUsage::new(27_862, 4, 27_866)
624            .with_cache(Some(15_320), Some(12_540))
625            .with_reasoning(Some(0));
626        let json = serde_json::to_value(Usage::from(&usage)).expect("serialize"); // Safe: test assertion
627        assert_eq!(json["prompt_tokens"], 27_862);
628        assert_eq!(json["completion_tokens"], 4);
629        assert_eq!(json["total_tokens"], 27_866);
630        assert_eq!(json["prompt_tokens_details"]["cached_tokens"], 15_320);
631        assert_eq!(json["prompt_tokens_details"]["cache_write_tokens"], 12_540);
632        assert_eq!(json["completion_tokens_details"]["reasoning_tokens"], 0);
633    }
634
635    #[test]
636    fn usage_folds_reasoning_into_openai_completion_tokens() {
637        // embacle keeps reasoning apart from completion; OpenAI counts it in.
638        let usage = TokenUsage::new(100, 7, 127).with_reasoning(Some(20));
639        let json = serde_json::to_value(Usage::from(&usage)).expect("serialize"); // Safe: test assertion
640        assert_eq!(json["prompt_tokens"], 100);
641        assert_eq!(json["completion_tokens"], 27);
642        assert_eq!(json["completion_tokens_details"]["reasoning_tokens"], 20);
643        assert_eq!(json["total_tokens"], 127);
644    }
645
646    #[test]
647    fn usage_total_is_raised_when_the_runner_total_omits_reasoning() {
648        // A runner whose total is prompt + completion, reasoning left out.
649        let usage = TokenUsage::new(100, 7, 107).with_reasoning(Some(20));
650        let json = serde_json::to_value(Usage::from(&usage)).expect("serialize"); // Safe: test assertion
651        assert_eq!(json["completion_tokens"], 27);
652        assert_eq!(json["total_tokens"], 127);
653    }
654
655    #[test]
656    fn usage_omits_each_unreported_share_on_its_own() {
657        let usage = TokenUsage::new(100, 7, 107).with_cache(Some(64), None);
658        let json = serde_json::to_value(Usage::from(&usage)).expect("serialize"); // Safe: test assertion
659        assert_eq!(json["prompt_tokens_details"]["cached_tokens"], 64);
660        assert!(json["prompt_tokens_details"]
661            .get("cache_write_tokens")
662            .is_none());
663        assert!(json.get("completion_tokens_details").is_none());
664    }
665
666    #[test]
667    fn deserialize_single_model() {
668        let json = r#"{"model":"copilot:gpt-4o","messages":[{"role":"user","content":"hi"}]}"#;
669        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
670        match req.model {
671            ModelField::Single(m) => assert_eq!(m, "copilot:gpt-4o"),
672            ModelField::Multiple(_) => unreachable!("expected single"), // Safe: test assertion
673        }
674        assert!(!req.stream);
675    }
676
677    #[test]
678    fn deserialize_multiple_models() {
679        let json = r#"{"model":["copilot:gpt-4o","claude:opus"],"messages":[{"role":"user","content":"hi"}]}"#;
680        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
681        match req.model {
682            ModelField::Multiple(models) => {
683                assert_eq!(models.len(), 2);
684                assert_eq!(models[0], "copilot:gpt-4o");
685                assert_eq!(models[1], "claude:opus");
686            }
687            ModelField::Single(_) => unreachable!("expected multiple"), // Safe: test assertion
688        }
689    }
690
691    #[test]
692    fn deserialize_with_stream_flag() {
693        let json =
694            r#"{"model":"copilot","messages":[{"role":"user","content":"hi"}],"stream":true}"#;
695        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
696        assert!(req.stream);
697    }
698
699    #[test]
700    fn deserialize_message_with_null_content() {
701        let json = r#"{"model":"copilot","messages":[{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"search","arguments":"{}"}}]}]}"#;
702        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
703        assert!(req.messages[0].content.is_none());
704        assert!(req.messages[0].tool_calls.is_some());
705    }
706
707    #[test]
708    fn deserialize_message_without_content_field() {
709        let json = r#"{"model":"copilot","messages":[{"role":"tool","tool_call_id":"call_1","name":"search","content":"{\"result\":\"found\"}"}]}"#;
710        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
711        assert_eq!(req.messages[0].role, "tool");
712        assert_eq!(req.messages[0].tool_call_id.as_deref(), Some("call_1"));
713        assert_eq!(req.messages[0].name.as_deref(), Some("search"));
714    }
715
716    #[test]
717    fn deserialize_multipart_content() {
718        let json = r#"{
719            "model": "copilot",
720            "messages": [{
721                "role": "user",
722                "content": [
723                    {"type": "text", "text": "What is in this image?"},
724                    {"type": "image_url", "image_url": {"url": "data:image/png;base64,aGVsbG8="}}
725                ]
726            }]
727        }"#;
728        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
729        let content = req.messages[0].content.as_ref().expect("content present"); // Safe: test assertion
730        match content {
731            MessageContent::Parts(parts) => {
732                assert_eq!(parts.len(), 2);
733                assert!(
734                    matches!(&parts[0], ContentPart::Text { text } if text == "What is in this image?")
735                );
736                assert!(
737                    matches!(&parts[1], ContentPart::ImageUrl { image_url } if image_url.url.contains("base64"))
738                );
739            }
740            MessageContent::Text(_) => unreachable!("expected Parts variant"), // Safe: test assertion
741        }
742    }
743
744    #[test]
745    fn message_content_as_text_plain_string() {
746        let content = MessageContent::Text("hello".to_owned());
747        assert_eq!(content.as_text(), "hello");
748    }
749
750    #[test]
751    fn message_content_as_text_multipart() {
752        let content = MessageContent::Parts(vec![
753            ContentPart::Text {
754                text: "describe ".to_owned(),
755            },
756            ContentPart::ImageUrl {
757                image_url: ImageUrlDetail {
758                    url: "data:image/png;base64,abc".to_owned(),
759                },
760            },
761            ContentPart::Text {
762                text: "this image".to_owned(),
763            },
764        ]);
765        assert_eq!(content.as_text(), "describe this image");
766    }
767
768    #[test]
769    fn deserialize_plain_string_content_backward_compat() {
770        let json = r#"{"model":"copilot","messages":[{"role":"user","content":"hi"}]}"#;
771        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
772        let content = req.messages[0].content.as_ref().expect("content present"); // Safe: test assertion
773        match content {
774            MessageContent::Text(s) => assert_eq!(s, "hi"),
775            MessageContent::Parts(_) => unreachable!("expected Text variant"), // Safe: test assertion
776        }
777    }
778
779    #[test]
780    fn deserialize_tool_definitions() {
781        let json = r#"{
782            "model": "copilot",
783            "messages": [{"role": "user", "content": "hi"}],
784            "tools": [{
785                "type": "function",
786                "function": {
787                    "name": "get_weather",
788                    "description": "Get weather for a city",
789                    "parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}
790                }
791            }]
792        }"#;
793        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
794        let tools = req.tools.expect("tools present"); // Safe: test assertion
795        assert_eq!(tools.len(), 1);
796        assert_eq!(tools[0].tool_type, "function");
797        assert_eq!(tools[0].function.name, "get_weather");
798        assert!(tools[0].function.parameters.is_some());
799    }
800
801    #[test]
802    fn deserialize_tool_choice_auto() {
803        let json = r#"{"model":"copilot","messages":[{"role":"user","content":"hi"}],"tool_choice":"auto"}"#;
804        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
805        let tool_choice = req.tool_choice.expect("tool_choice present"); // Safe: test assertion
806        match tool_choice {
807            ToolChoice::Mode(m) => assert_eq!(m, "auto"),
808            ToolChoice::Specific(_) => unreachable!("expected mode"), // Safe: test assertion
809        }
810    }
811
812    #[test]
813    fn deserialize_tool_choice_specific() {
814        let json = r#"{"model":"copilot","messages":[{"role":"user","content":"hi"}],"tool_choice":{"type":"function","function":{"name":"get_weather"}}}"#;
815        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
816        let tool_choice = req.tool_choice.expect("tool_choice present"); // Safe: test assertion
817        match tool_choice {
818            ToolChoice::Specific(s) => assert_eq!(s.function.name, "get_weather"),
819            ToolChoice::Mode(_) => unreachable!("expected specific"), // Safe: test assertion
820        }
821    }
822
823    #[test]
824    fn serialize_completion_response() {
825        let resp = ChatCompletionResponse {
826            id: "chatcmpl-test".to_owned(),
827            object: "chat.completion",
828            created: 1_700_000_000,
829            model: "copilot:gpt-4o".to_owned(),
830            choices: vec![Choice {
831                index: 0,
832                message: ResponseMessage {
833                    role: "assistant",
834                    content: Some("Hello!".to_owned()),
835                    tool_calls: None,
836                },
837                finish_reason: Some("stop".to_owned()),
838            }],
839            usage: None,
840            warnings: None,
841        };
842        let json = serde_json::to_string(&resp).expect("serialize"); // Safe: test assertion
843        assert!(json.contains("chat.completion"));
844        assert!(json.contains("Hello!"));
845        assert!(!json.contains("tool_calls"));
846    }
847
848    #[test]
849    fn serialize_response_with_tool_calls() {
850        let resp = ChatCompletionResponse {
851            id: "chatcmpl-test".to_owned(),
852            object: "chat.completion",
853            created: 1_700_000_000,
854            model: "copilot:gpt-4o".to_owned(),
855            choices: vec![Choice {
856                index: 0,
857                message: ResponseMessage {
858                    role: "assistant",
859                    content: None,
860                    tool_calls: Some(vec![ToolCall {
861                        index: 0,
862                        id: "call_abc123".to_owned(),
863                        tool_type: "function".to_owned(),
864                        function: ToolCallFunction {
865                            name: "get_weather".to_owned(),
866                            arguments: r#"{"city":"Paris"}"#.to_owned(),
867                        },
868                    }]),
869                },
870                finish_reason: Some("tool_calls".to_owned()),
871            }],
872            usage: None,
873            warnings: None,
874        };
875        let json = serde_json::to_string(&resp).expect("serialize"); // Safe: test assertion
876        assert!(json.contains("tool_calls"));
877        assert!(json.contains("call_abc123"));
878        assert!(json.contains("get_weather"));
879        assert!(!json.contains(r#""content""#));
880    }
881
882    #[test]
883    fn serialize_error_response() {
884        let resp = ErrorResponse::new("invalid_request_error", "Unknown model");
885        let json = serde_json::to_string(&resp).expect("serialize"); // Safe: test assertion
886        assert!(json.contains("invalid_request_error"));
887        assert!(json.contains("Unknown model"));
888    }
889
890    #[test]
891    fn serialize_chunk_response() {
892        let chunk = ChatCompletionChunk {
893            id: "chatcmpl-test".to_owned(),
894            object: "chat.completion.chunk",
895            created: 1_700_000_000,
896            model: "copilot".to_owned(),
897            choices: vec![ChunkChoice {
898                index: 0,
899                delta: Delta {
900                    role: None,
901                    content: Some("token".to_owned()),
902                    tool_calls: None,
903                },
904                finish_reason: None,
905            }],
906        };
907        let json = serde_json::to_string(&chunk).expect("serialize"); // Safe: test assertion
908        assert!(json.contains("chat.completion.chunk"));
909        assert!(json.contains("token"));
910        assert!(!json.contains("tool_calls"));
911    }
912
913    #[test]
914    fn deserialize_tool_execution_server() {
915        let json = r#"{"model":"copilot","messages":[{"role":"user","content":"hi"}],"tool_execution":"server"}"#;
916        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
917        assert_eq!(req.tool_execution, Some(ToolExecutionMode::Server));
918    }
919
920    #[test]
921    fn deserialize_tool_execution_client() {
922        let json = r#"{"model":"copilot","messages":[{"role":"user","content":"hi"}],"tool_execution":"client"}"#;
923        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
924        assert_eq!(req.tool_execution, Some(ToolExecutionMode::Client));
925    }
926
927    #[test]
928    fn tool_execution_defaults_to_none() {
929        let json = r#"{"model":"copilot","messages":[{"role":"user","content":"hi"}]}"#;
930        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
931        assert_eq!(req.tool_execution, None);
932    }
933
934    #[test]
935    fn deserialize_tool_choice_none() {
936        let json = r#"{"model":"copilot","messages":[{"role":"user","content":"hi"}],"tool_choice":"none"}"#;
937        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
938        let tool_choice = req.tool_choice.expect("tool_choice present"); // Safe: test assertion
939        match tool_choice {
940            ToolChoice::Mode(m) => assert_eq!(m, "none"),
941            ToolChoice::Specific(_) => unreachable!("expected mode"), // Safe: test assertion
942        }
943    }
944
945    #[test]
946    fn deserialize_tool_choice_required() {
947        let json = r#"{"model":"copilot","messages":[{"role":"user","content":"hi"}],"tool_choice":"required"}"#;
948        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
949        let tool_choice = req.tool_choice.expect("tool_choice present"); // Safe: test assertion
950        match tool_choice {
951            ToolChoice::Mode(m) => assert_eq!(m, "required"),
952            ToolChoice::Specific(_) => unreachable!("expected mode"), // Safe: test assertion
953        }
954    }
955
956    #[test]
957    fn deserialize_response_format_text() {
958        let json = r#"{"model":"copilot","messages":[{"role":"user","content":"hi"}],"response_format":{"type":"text"}}"#;
959        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
960        assert!(matches!(
961            req.response_format,
962            Some(ResponseFormatRequest::Text)
963        ));
964    }
965
966    #[test]
967    fn deserialize_response_format_json_object() {
968        let json = r#"{"model":"copilot","messages":[{"role":"user","content":"hi"}],"response_format":{"type":"json_object"}}"#;
969        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
970        assert!(matches!(
971            req.response_format,
972            Some(ResponseFormatRequest::JsonObject)
973        ));
974    }
975
976    #[test]
977    fn deserialize_response_format_json_schema() {
978        let json = r#"{
979            "model": "copilot",
980            "messages": [{"role": "user", "content": "hi"}],
981            "response_format": {
982                "type": "json_schema",
983                "json_schema": {
984                    "name": "weather",
985                    "schema": {"type": "object", "properties": {"temp": {"type": "number"}}}
986                }
987            }
988        }"#;
989        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
990        match req.response_format {
991            Some(ResponseFormatRequest::JsonSchema { json_schema }) => {
992                assert_eq!(json_schema.name, "weather");
993                assert!(json_schema.schema["properties"]["temp"].is_object());
994            }
995            other => unreachable!("expected JsonSchema, got: {other:?}"), // Safe: test assertion
996        }
997    }
998
999    #[test]
1000    fn deserialize_top_p() {
1001        let json = r#"{"model":"copilot","messages":[{"role":"user","content":"hi"}],"top_p":0.9}"#;
1002        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
1003        assert_eq!(req.top_p, Some(0.9));
1004    }
1005
1006    #[test]
1007    fn deserialize_stop_single() {
1008        let json =
1009            r#"{"model":"copilot","messages":[{"role":"user","content":"hi"}],"stop":"END"}"#;
1010        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
1011        let stop = req.stop.expect("stop present"); // Safe: test assertion
1012        assert_eq!(stop.into_vec(), vec!["END"]);
1013    }
1014
1015    #[test]
1016    fn deserialize_stop_array() {
1017        let json = r#"{"model":"copilot","messages":[{"role":"user","content":"hi"}],"stop":["END","STOP"]}"#;
1018        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
1019        let stop = req.stop.expect("stop present"); // Safe: test assertion
1020        assert_eq!(stop.into_vec(), vec!["END", "STOP"]);
1021    }
1022
1023    #[test]
1024    fn stop_field_len() {
1025        let single = StopField::Single("END".to_owned());
1026        assert_eq!(single.len(), 1);
1027        let multiple = StopField::Multiple(vec!["A".to_owned(), "B".to_owned(), "C".to_owned()]);
1028        assert_eq!(multiple.len(), 3);
1029    }
1030
1031    #[test]
1032    fn stop_field_into_vec_truncates_at_four() {
1033        let oversized = StopField::Multiple((0..10).map(|i| format!("stop_{i}")).collect());
1034        let result = oversized.into_vec();
1035        assert_eq!(result.len(), 4);
1036        assert_eq!(result[0], "stop_0");
1037        assert_eq!(result[3], "stop_3");
1038    }
1039
1040    #[test]
1041    fn deserialize_all_optional_fields() {
1042        let json = r#"{
1043            "model": "copilot",
1044            "messages": [{"role": "user", "content": "hi"}],
1045            "temperature": 0.7,
1046            "max_tokens": 100,
1047            "top_p": 0.95,
1048            "stop": ["END"],
1049            "stream": true
1050        }"#;
1051        let req: ChatCompletionRequest = serde_json::from_str(json).expect("deserialize"); // Safe: test assertion
1052        assert_eq!(req.temperature, Some(0.7));
1053        assert_eq!(req.max_tokens, Some(100));
1054        assert_eq!(req.top_p, Some(0.95));
1055        assert!(req.stop.is_some());
1056        assert!(req.stream);
1057    }
1058
1059    #[test]
1060    fn serialize_models_response() {
1061        let resp = ModelsResponse {
1062            object: "list",
1063            data: vec![ModelObject {
1064                id: "copilot:gpt-4o".to_owned(),
1065                object: "model",
1066                owned_by: "copilot".to_owned(),
1067            }],
1068        };
1069        let json = serde_json::to_string(&resp).expect("serialize"); // Safe: test assertion
1070        assert!(json.contains("copilot:gpt-4o"));
1071    }
1072}