Skip to main content

venice_e2ee_proxy/openai/
chat.rs

1//! OpenAI chat request parsing and Venice E2EE request construction.
2
3use serde::{Deserialize, Serialize, de::DeserializeOwned};
4use serde_json::{Map, Value};
5use thiserror::Error;
6
7use crate::e2ee::{E2eeCodec, E2eeCodecError};
8use crate::util::json_kind;
9
10/// Normalized OpenAI chat-completion request accepted by the proxy.
11#[derive(Debug, Clone, PartialEq)]
12pub struct ChatCompletionRequest {
13    pub model: String,
14    pub messages: Vec<NormalizedChatMessage>,
15    pub stream: bool,
16    pub stream_options: OpenAiStreamOptions,
17    pub venice_parameters: VeniceParameters,
18    pub passthrough: OpenAiPassthroughFields,
19    pub reasoning: Option<ReasoningOptions>,
20    pub reasoning_effort: Option<String>,
21    pub tools: Vec<ChatToolDefinition>,
22    pub tool_choice: ChatToolChoice,
23    pub parallel_tool_calls: Option<bool>,
24}
25
26impl ChatCompletionRequest {
27    /// Parses a raw JSON request body into the normalized chat request shape.
28    pub fn parse(value: &Value) -> Result<Self, ChatRequestError> {
29        let object = value
30            .as_object()
31            .ok_or_else(|| ChatRequestError::invalid("request body must be a JSON object"))?;
32        reject_unknown_fields(
33            object,
34            &[
35                "model",
36                "messages",
37                "stream",
38                "stream_options",
39                "temperature",
40                "top_p",
41                "max_tokens",
42                "max_completion_tokens",
43                "stop",
44                "reasoning",
45                "reasoning_effort",
46                "tools",
47                "tool_choice",
48                "parallel_tool_calls",
49                "metadata",
50                "venice_parameters",
51            ],
52            "request",
53        )?;
54
55        validate_ignored_client_only_fields(object)?;
56
57        let model = required_non_empty_string(object, "model")?.to_owned();
58        let messages_value = object
59            .get("messages")
60            .ok_or(ChatRequestError::MissingField { field: "messages" })?;
61        let messages = normalize_messages(messages_value)?;
62        let stream = optional_bool(object, "stream")?.unwrap_or(false);
63        let stream_options = OpenAiStreamOptions::parse(object.get("stream_options"))?;
64        let venice_parameters = VeniceParameters::parse(object.get("venice_parameters"))?;
65        let passthrough = OpenAiPassthroughFields::parse(object)?;
66        let reasoning = ReasoningOptions::parse(object.get("reasoning"))?;
67        let reasoning_effort = optional_non_empty_string(object, "reasoning_effort")?;
68        validate_reasoning_effort_consistency(reasoning.as_ref(), reasoning_effort.as_deref())?;
69        let tools = parse_tools(object.get("tools"))?;
70        validate_tools(&tools)?;
71        let tool_choice = parse_tool_choice(object.get("tool_choice"))?;
72        validate_tool_choice(&tool_choice)?;
73        let parallel_tool_calls = optional_bool(object, "parallel_tool_calls")?;
74
75        Ok(Self {
76            model,
77            messages,
78            stream,
79            stream_options,
80            venice_parameters,
81            passthrough,
82            reasoning,
83            reasoning_effort,
84            tools,
85            tool_choice,
86            parallel_tool_calls,
87        })
88    }
89
90    /// Encrypts request messages and builds the Venice upstream request.
91    pub fn to_venice_e2ee_request(
92        &self,
93        codec: &E2eeCodec,
94        model_public_key_hex: &str,
95    ) -> Result<PreparedVeniceChatRequest, ChatConstructionError> {
96        self.to_venice_e2ee_request_with_messages(codec, model_public_key_hex, &[], &[])
97    }
98
99    /// Encrypts request messages with extra prefix/suffix messages for controller prompts or retries.
100    pub fn to_venice_e2ee_request_with_messages(
101        &self,
102        codec: &E2eeCodec,
103        model_public_key_hex: &str,
104        prefix_messages: &[NormalizedChatMessage],
105        suffix_messages: &[NormalizedChatMessage],
106    ) -> Result<PreparedVeniceChatRequest, ChatConstructionError> {
107        let encrypted_messages = prefix_messages
108            .iter()
109            .chain(self.messages.iter())
110            .chain(suffix_messages.iter())
111            .map(|message| {
112                let content = codec
113                    .encrypt_content(&message.content, model_public_key_hex)
114                    .map_err(ChatConstructionError::E2ee)?
115                    .into_hex();
116                Ok(NormalizedChatMessage::new(message.role.clone(), content))
117            })
118            .collect::<Result<Vec<_>, ChatConstructionError>>()?;
119
120        Ok(PreparedVeniceChatRequest {
121            client_stream: self.stream,
122            upstream: VeniceE2eeChatRequest {
123                model: self.model.clone(),
124                messages: encrypted_messages,
125                stream: true,
126                stream_options: VeniceStreamOptions {
127                    include_usage: self.stream_options.include_usage.unwrap_or(true),
128                },
129                venice_parameters: self.venice_parameters.clone(),
130                temperature: self.passthrough.temperature.clone(),
131                top_p: self.passthrough.top_p.clone(),
132                max_tokens: self.passthrough.max_tokens,
133                max_completion_tokens: self.passthrough.max_completion_tokens,
134                stop: self.passthrough.stop.clone(),
135                reasoning: self.reasoning.clone(),
136                reasoning_effort: self.reasoning_effort.clone(),
137            },
138        })
139    }
140}
141
142/// Chat message normalized to a role and plaintext content string before E2EE encryption.
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144pub struct NormalizedChatMessage {
145    pub role: String,
146    pub content: String,
147}
148
149impl NormalizedChatMessage {
150    /// Builds a normalized chat message from role and content values.
151    pub fn new(role: impl Into<String>, content: impl Into<String>) -> Self {
152        Self {
153            role: role.into(),
154            content: content.into(),
155        }
156    }
157}
158
159/// Supported OpenAI tool definition passed by a chat-completion request.
160#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
161#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
162pub enum ChatToolDefinition {
163    Function {
164        function: ChatToolFunctionDefinition,
165    },
166}
167
168impl ChatToolDefinition {
169    /// Returns the function definition for a supported tool.
170    pub fn function(&self) -> &ChatToolFunctionDefinition {
171        match self {
172            Self::Function { function } => function,
173        }
174    }
175
176    /// Returns the function tool name.
177    pub fn name(&self) -> &str {
178        &self.function().name
179    }
180
181    /// Returns the function parameters JSON schema when one was provided.
182    pub fn parameters_schema(&self) -> Option<&Map<String, Value>> {
183        self.function().parameters.as_ref().map(JsonSchema::as_map)
184    }
185}
186
187/// Function tool metadata from an OpenAI `tools` entry.
188#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
189#[serde(deny_unknown_fields)]
190pub struct ChatToolFunctionDefinition {
191    pub name: String,
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub description: Option<String>,
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub parameters: Option<JsonSchema>,
196}
197
198/// JSON schema object stored for tool argument validation and prompt rendering.
199#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
200#[serde(transparent)]
201pub struct JsonSchema(Map<String, Value>);
202
203impl JsonSchema {
204    /// Returns the schema as a JSON object map.
205    pub fn as_map(&self) -> &Map<String, Value> {
206        &self.0
207    }
208}
209
210/// Normalized OpenAI tool-choice setting for a chat request.
211#[derive(Debug, Clone, Default, PartialEq, Eq)]
212pub enum ChatToolChoice {
213    #[default]
214    Auto,
215    None,
216    Required,
217    Function {
218        name: String,
219    },
220}
221
222impl<'de> Deserialize<'de> for ChatToolChoice {
223    /// Deserializes OpenAI string or object tool-choice values into a normalized choice.
224    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
225    where
226        D: serde::Deserializer<'de>,
227    {
228        ChatToolChoiceWire::deserialize(deserializer).map(Self::from)
229    }
230}
231
232impl From<ChatToolChoiceWire> for ChatToolChoice {
233    /// Converts accepted wire shapes into the normalized tool-choice enum.
234    fn from(value: ChatToolChoiceWire) -> Self {
235        match value {
236            ChatToolChoiceWire::Mode(ChatToolChoiceMode::Auto) => Self::Auto,
237            ChatToolChoiceWire::Mode(ChatToolChoiceMode::None) => Self::None,
238            ChatToolChoiceWire::Mode(ChatToolChoiceMode::Required) => Self::Required,
239            ChatToolChoiceWire::Object(ChatToolChoiceObject::Function { function }) => {
240                Self::Function {
241                    name: function.name,
242                }
243            }
244        }
245    }
246}
247
248/// Wire representation for OpenAI `tool_choice` string or object values.
249#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
250#[serde(untagged)]
251enum ChatToolChoiceWire {
252    Mode(ChatToolChoiceMode),
253    Object(ChatToolChoiceObject),
254}
255
256/// String-mode variants accepted for OpenAI `tool_choice`.
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
258#[serde(rename_all = "snake_case")]
259enum ChatToolChoiceMode {
260    Auto,
261    None,
262    Required,
263}
264
265/// Object-form OpenAI `tool_choice` value selecting a specific function.
266#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
267#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
268enum ChatToolChoiceObject {
269    Function { function: ChatToolChoiceFunction },
270}
271
272/// Function selector payload inside object-form `tool_choice`.
273#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
274#[serde(deny_unknown_fields)]
275struct ChatToolChoiceFunction {
276    name: String,
277}
278
279/// OpenAI stream options accepted by the chat route.
280#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
281#[serde(deny_unknown_fields)]
282pub struct OpenAiStreamOptions {
283    #[serde(default, deserialize_with = "deserialize_optional_bool_reject_null")]
284    pub include_usage: Option<bool>,
285}
286
287/// Reasoning options accepted by the proxy and forwarded to Venice.
288#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
289#[serde(deny_unknown_fields)]
290pub struct ReasoningOptions {
291    #[serde(
292        default,
293        deserialize_with = "deserialize_optional_bool_reject_null",
294        skip_serializing_if = "Option::is_none"
295    )]
296    pub enabled: Option<bool>,
297    #[serde(
298        default,
299        deserialize_with = "deserialize_optional_non_empty_string_reject_null",
300        skip_serializing_if = "Option::is_none"
301    )]
302    pub effort: Option<String>,
303}
304
305impl ReasoningOptions {
306    /// Parses optional `reasoning` settings from a raw request field.
307    fn parse(value: Option<&Value>) -> Result<Option<Self>, ChatRequestError> {
308        match value {
309            None => Ok(None),
310            Some(value) => deserialize_typed_value("reasoning", value).map(Some),
311        }
312    }
313}
314
315impl OpenAiStreamOptions {
316    /// Parses optional `stream_options`, returning defaults when absent.
317    fn parse(value: Option<&Value>) -> Result<Self, ChatRequestError> {
318        let Some(value) = value else {
319            return Ok(Self::default());
320        };
321        deserialize_typed_value("stream_options", value)
322    }
323}
324
325/// Venice parameters allowed for encrypted upstream chat requests.
326#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
327pub struct VeniceParameters {
328    pub enable_e2ee: bool,
329    pub include_venice_system_prompt: bool,
330    pub strip_thinking_response: bool,
331    pub disable_thinking: bool,
332    pub enable_web_search: String,
333}
334
335impl Default for VeniceParameters {
336    /// Returns safe Venice parameter defaults for E2EE chat requests.
337    fn default() -> Self {
338        Self {
339            enable_e2ee: true,
340            include_venice_system_prompt: false,
341            strip_thinking_response: false,
342            disable_thinking: false,
343            enable_web_search: "off".to_owned(),
344        }
345    }
346}
347
348impl VeniceParameters {
349    /// Parses optional `venice_parameters` and rejects settings incompatible with E2EE.
350    fn parse(value: Option<&Value>) -> Result<Self, ChatRequestError> {
351        let Some(value) = value else {
352            return Ok(Self::default());
353        };
354        let raw: RawVeniceParameters = deserialize_typed_value("venice_parameters", value)?;
355        let enable_e2ee = raw.enable_e2ee.unwrap_or(true);
356
357        if !enable_e2ee {
358            return Err(ChatRequestError::UnsupportedVeniceParameter {
359                field: "venice_parameters.enable_e2ee",
360                message: "Venice E2EE must remain enabled for encrypted proxy requests".to_owned(),
361            });
362        }
363
364        let include_venice_system_prompt = raw.include_venice_system_prompt.unwrap_or(false);
365
366        if include_venice_system_prompt {
367            return Err(ChatRequestError::UnsupportedVeniceParameter {
368                field: "venice_parameters.include_venice_system_prompt",
369                message: "Venice system prompt injection is disabled for E2EE requests".to_owned(),
370            });
371        }
372
373        let strip_thinking_response = raw.strip_thinking_response.unwrap_or(false);
374        let disable_thinking = raw.disable_thinking.unwrap_or(false);
375
376        let enable_web_search = match raw.enable_web_search {
377            None => "off".to_owned(),
378            Some(RawVeniceWebSearch::String(value)) if value == "off" => "off".to_owned(),
379            Some(RawVeniceWebSearch::Bool(false)) => "off".to_owned(),
380            Some(RawVeniceWebSearch::String(value)) => {
381                return Err(ChatRequestError::UnsupportedVeniceParameter {
382                    field: "venice_parameters.enable_web_search",
383                    message: format!(
384                        "Venice web search is out of scope for E2EE requests; expected \"off\", got {value:?}"
385                    ),
386                });
387            }
388            Some(RawVeniceWebSearch::Bool(true)) => {
389                return Err(ChatRequestError::UnsupportedVeniceParameter {
390                    field: "venice_parameters.enable_web_search",
391                    message: "Venice web search is out of scope for E2EE requests".to_owned(),
392                });
393            }
394        };
395
396        Ok(Self {
397            enable_e2ee,
398            include_venice_system_prompt,
399            strip_thinking_response,
400            disable_thinking,
401            enable_web_search,
402        })
403    }
404}
405
406/// OpenAI scalar fields preserved and forwarded to the Venice request.
407#[derive(Debug, Clone, Default, PartialEq)]
408pub struct OpenAiPassthroughFields {
409    pub temperature: Option<Value>,
410    pub top_p: Option<Value>,
411    pub max_tokens: Option<u64>,
412    pub max_completion_tokens: Option<u64>,
413    pub stop: Option<StopSequence>,
414}
415
416impl OpenAiPassthroughFields {
417    /// Parses pass-through generation fields from the normalized request object.
418    fn parse(object: &Map<String, Value>) -> Result<Self, ChatRequestError> {
419        Ok(Self {
420            temperature: optional_number(object, "temperature")?,
421            top_p: optional_number(object, "top_p")?,
422            max_tokens: optional_u64(object, "max_tokens")?,
423            max_completion_tokens: optional_u64(object, "max_completion_tokens")?,
424            stop: optional_stop(object)?,
425        })
426    }
427}
428
429/// Prepared Venice upstream request plus the original client streaming preference.
430#[derive(Debug, Clone, PartialEq, Eq)]
431pub struct PreparedVeniceChatRequest {
432    pub client_stream: bool,
433    pub upstream: VeniceE2eeChatRequest,
434}
435
436/// Venice chat request payload containing encrypted messages and forwarded options.
437#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
438pub struct VeniceE2eeChatRequest {
439    pub model: String,
440    pub messages: Vec<NormalizedChatMessage>,
441    pub stream: bool,
442    pub stream_options: VeniceStreamOptions,
443    pub venice_parameters: VeniceParameters,
444    #[serde(skip_serializing_if = "Option::is_none")]
445    pub temperature: Option<Value>,
446    #[serde(skip_serializing_if = "Option::is_none")]
447    pub top_p: Option<Value>,
448    #[serde(skip_serializing_if = "Option::is_none")]
449    pub max_tokens: Option<u64>,
450    #[serde(skip_serializing_if = "Option::is_none")]
451    pub max_completion_tokens: Option<u64>,
452    #[serde(skip_serializing_if = "Option::is_none")]
453    pub stop: Option<StopSequence>,
454    #[serde(skip_serializing_if = "Option::is_none")]
455    pub reasoning: Option<ReasoningOptions>,
456    #[serde(skip_serializing_if = "Option::is_none")]
457    pub reasoning_effort: Option<String>,
458}
459
460/// OpenAI stop sequence value accepted as a string or list of strings.
461#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
462#[serde(untagged)]
463pub enum StopSequence {
464    String(String),
465    Strings(Vec<String>),
466}
467
468/// Venice stream options derived from OpenAI stream options.
469#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
470pub struct VeniceStreamOptions {
471    pub include_usage: bool,
472}
473
474/// Raw nullable Venice parameters accepted before policy validation.
475#[derive(Debug, Clone, Default, Deserialize)]
476#[serde(deny_unknown_fields)]
477struct RawVeniceParameters {
478    #[serde(default, deserialize_with = "deserialize_optional_bool_reject_null")]
479    enable_e2ee: Option<bool>,
480    #[serde(default, deserialize_with = "deserialize_optional_bool_reject_null")]
481    include_venice_system_prompt: Option<bool>,
482    #[serde(default, deserialize_with = "deserialize_optional_bool_reject_null")]
483    strip_thinking_response: Option<bool>,
484    #[serde(default, deserialize_with = "deserialize_optional_bool_reject_null")]
485    disable_thinking: Option<bool>,
486    #[serde(
487        default,
488        deserialize_with = "deserialize_optional_web_search_reject_null"
489    )]
490    enable_web_search: Option<RawVeniceWebSearch>,
491}
492
493/// Raw Venice web-search setting accepted as string or boolean before validation.
494#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
495#[serde(untagged)]
496enum RawVeniceWebSearch {
497    String(String),
498    Bool(bool),
499}
500
501/// Raw assistant tool-call history object accepted from OpenAI messages.
502#[derive(Debug, Clone, Deserialize)]
503#[serde(deny_unknown_fields)]
504struct RawAssistantToolCall {
505    id: String,
506    #[serde(rename = "type")]
507    kind: String,
508    function: RawAssistantToolFunction,
509}
510
511/// Raw assistant function-call payload accepted from OpenAI tool-call history.
512#[derive(Debug, Clone, Deserialize)]
513#[serde(deny_unknown_fields)]
514struct RawAssistantToolFunction {
515    name: String,
516    arguments: String,
517}
518
519/// Raw text content part accepted from OpenAI multi-part message content.
520#[derive(Debug, Clone, Deserialize)]
521#[serde(deny_unknown_fields)]
522struct RawTextContentPart {
523    // Deserialized only to enforce `"type": "text"`; never read afterwards.
524    #[serde(rename = "type")]
525    _kind: TextContentPartType,
526    text: String,
527}
528
529/// Supported content part type for text-only OpenAI message content arrays.
530#[derive(Debug, Clone, Deserialize)]
531enum TextContentPartType {
532    #[serde(rename = "text")]
533    Text,
534}
535
536/// Errors returned while parsing or validating an OpenAI chat request.
537#[derive(Debug, Error)]
538pub enum ChatRequestError {
539    #[error("missing required field {field}")]
540    MissingField { field: &'static str },
541    #[error("invalid request: {message}")]
542    InvalidRequest { message: String },
543    #[error("invalid field {field}: {message}")]
544    InvalidField {
545        field: &'static str,
546        message: String,
547    },
548    #[error("unsupported request field {field}")]
549    UnsupportedField { field: String },
550    #[error("unsupported message role {role:?}")]
551    UnsupportedMessageRole { role: String },
552    #[error("unsupported message content at {path}: {message}")]
553    UnsupportedMessageContent { path: String, message: String },
554    #[error("invalid assistant tool-call history: {message}")]
555    InvalidToolCallHistory { message: String },
556    #[error("unsupported Venice parameter {field}: {message}")]
557    UnsupportedVeniceParameter {
558        field: &'static str,
559        message: String,
560    },
561}
562
563impl ChatRequestError {
564    /// Returns the proxy error code exposed for this chat request error.
565    pub fn api_error_code(&self) -> &'static str {
566        match self {
567            Self::MissingField { .. } | Self::InvalidRequest { .. } | Self::InvalidField { .. } => {
568                "invalid_request"
569            }
570            Self::UnsupportedField { .. } => "unsupported_request_field",
571            Self::UnsupportedMessageRole { .. } => "unsupported_message_role",
572            Self::UnsupportedMessageContent { .. } => "unsupported_message_content",
573            Self::InvalidToolCallHistory { .. } => "invalid_tool_call_history",
574            Self::UnsupportedVeniceParameter { .. } => "unsupported_venice_parameter",
575        }
576    }
577
578    /// Creates a generic invalid-request error with the supplied message.
579    pub(crate) fn invalid(message: impl Into<String>) -> Self {
580        Self::InvalidRequest {
581            message: message.into(),
582        }
583    }
584
585    /// Creates an invalid-field error for a named request field.
586    pub(crate) fn invalid_field(field: &'static str, message: impl Into<String>) -> Self {
587        Self::InvalidField {
588            field,
589            message: message.into(),
590        }
591    }
592
593    /// Creates an unsupported-content error for a message-content JSON path.
594    pub(crate) fn unsupported_content(path: impl Into<String>, message: impl Into<String>) -> Self {
595        Self::UnsupportedMessageContent {
596            path: path.into(),
597            message: message.into(),
598        }
599    }
600
601    /// Creates an invalid tool-call history error with the supplied message.
602    pub(crate) fn invalid_tool_history(message: impl Into<String>) -> Self {
603        Self::InvalidToolCallHistory {
604            message: message.into(),
605        }
606    }
607}
608
609/// Errors returned while constructing the encrypted Venice chat request.
610#[derive(Debug, Error)]
611pub enum ChatConstructionError {
612    #[error(transparent)]
613    E2ee(#[from] E2eeCodecError),
614}
615
616impl ChatConstructionError {
617    /// Returns the proxy error code exposed for this construction error.
618    pub fn api_error_code(&self) -> &'static str {
619        match self {
620            Self::E2ee(_) => "e2ee_request_encryption_failed",
621        }
622    }
623}
624
625/// Normalizes the `messages` field into supported role/content strings.
626fn normalize_messages(value: &Value) -> Result<Vec<NormalizedChatMessage>, ChatRequestError> {
627    let messages = value
628        .as_array()
629        .ok_or_else(|| ChatRequestError::invalid_field("messages", "messages must be an array"))?;
630    if messages.is_empty() {
631        return Err(ChatRequestError::invalid_field(
632            "messages",
633            "messages must include at least one message",
634        ));
635    }
636
637    messages
638        .iter()
639        .enumerate()
640        .map(|(index, value)| normalize_message(index, value))
641        .collect()
642}
643
644/// Normalizes one OpenAI message at the supplied array index.
645fn normalize_message(
646    index: usize,
647    value: &Value,
648) -> Result<NormalizedChatMessage, ChatRequestError> {
649    let object = value.as_object().ok_or_else(|| {
650        ChatRequestError::invalid_field("messages", format!("message {index} must be an object"))
651    })?;
652    let role = required_non_empty_string(object, "role")?;
653
654    match role {
655        "system" | "developer" | "user" => {
656            reject_unknown_fields(object, &["role", "content"], "message")?;
657            let content = required_content_text(
658                object.get("content"),
659                &format!("messages[{index}].content"),
660            )?;
661            Ok(NormalizedChatMessage::new(role, content))
662        }
663        "assistant" => normalize_assistant_message(index, object),
664        "tool" => normalize_tool_result_message(index, object),
665        other => Err(ChatRequestError::UnsupportedMessageRole {
666            role: other.to_owned(),
667        }),
668    }
669}
670
671/// Normalizes assistant messages, including supported prior tool-call history.
672fn normalize_assistant_message(
673    index: usize,
674    object: &Map<String, Value>,
675) -> Result<NormalizedChatMessage, ChatRequestError> {
676    reject_unknown_fields(
677        object,
678        &["role", "content", "tool_calls"],
679        "assistant message",
680    )?;
681    let content = optional_content_text(
682        object.get("content"),
683        &format!("messages[{index}].content"),
684        true,
685    )?;
686    let tool_calls = normalize_assistant_tool_calls(object.get("tool_calls"))?;
687
688    if content.as_deref().unwrap_or_default().is_empty() && tool_calls.is_none() {
689        return Err(ChatRequestError::invalid_field(
690            "messages",
691            "assistant messages must include string content or a supported tool_calls history entry",
692        ));
693    }
694
695    let normalized_content = match (content, tool_calls) {
696        (Some(content), Some(tool_calls)) if !content.is_empty() => {
697            format!("{content}\n\n{tool_calls}")
698        }
699        (Some(content), _) => content,
700        (None, Some(tool_calls)) => tool_calls,
701        (None, None) => unreachable!("empty assistant messages are rejected above"),
702    };
703
704    Ok(NormalizedChatMessage::new("assistant", normalized_content))
705}
706
707/// Normalizes OpenAI tool-result messages into user-visible context text.
708fn normalize_tool_result_message(
709    index: usize,
710    object: &Map<String, Value>,
711) -> Result<NormalizedChatMessage, ChatRequestError> {
712    reject_unknown_fields(object, &["role", "tool_call_id", "content"], "tool message")?;
713    let tool_call_id = required_non_empty_string(object, "tool_call_id")?;
714    let content =
715        required_content_text(object.get("content"), &format!("messages[{index}].content"))?;
716    let normalized = format!(
717        "<tool_result id=\"{}\">\n{}\n</tool_result>\n\nUse the tool result above to continue the answer.",
718        xml_escape_attr(tool_call_id),
719        content,
720    );
721
722    // Venice E2EE compatibility: present prior tool output as user-visible context.
723    Ok(NormalizedChatMessage::new("user", normalized))
724}
725
726/// Renders supported assistant tool-call history into model-visible text.
727fn normalize_assistant_tool_calls(
728    value: Option<&Value>,
729) -> Result<Option<String>, ChatRequestError> {
730    let Some(value) = value else {
731        return Ok(None);
732    };
733
734    if !value.is_array() {
735        return Err(ChatRequestError::invalid_tool_history(
736            "assistant tool_calls must be an array",
737        ));
738    }
739    let tool_calls: Vec<RawAssistantToolCall> = serde_json::from_value(value.clone()).map_err(
740        |source| {
741            ChatRequestError::invalid_tool_history(format!(
742                "assistant tool_calls must be an array of supported function tool call objects: {source}"
743            ))
744        },
745    )?;
746
747    if tool_calls.is_empty() {
748        return Err(ChatRequestError::invalid_tool_history(
749            "assistant tool_calls must not be empty when provided",
750        ));
751    }
752
753    let rendered = tool_calls
754        .iter()
755        .map(render_assistant_tool_call)
756        .collect::<Result<Vec<String>, ChatRequestError>>()?;
757
758    Ok(Some(rendered.join("\n")))
759}
760
761/// Renders one prior assistant function call into the proxy's textual history format.
762fn render_assistant_tool_call(
763    tool_call: &RawAssistantToolCall,
764) -> Result<String, ChatRequestError> {
765    let id = non_empty_typed_string(&tool_call.id, "tool_call.id")?;
766
767    if tool_call.kind != "function" {
768        return Err(ChatRequestError::invalid_tool_history(format!(
769            "only function tool calls are supported, got {:?}",
770            tool_call.kind
771        )));
772    }
773
774    let name = non_empty_typed_string(&tool_call.function.name, "tool_call.function.name")?;
775    let arguments = non_empty_typed_string(
776        &tool_call.function.arguments,
777        "tool_call.function.arguments",
778    )?;
779    let parsed_arguments: Value = serde_json::from_str(arguments).map_err(|source| {
780        ChatRequestError::invalid_tool_history(format!(
781            "tool_call.function.arguments must be valid JSON: {source}"
782        ))
783    })?;
784    let canonical_arguments = serde_json::to_string(&parsed_arguments).map_err(|source| {
785        ChatRequestError::invalid_tool_history(format!(
786            "tool_call.function.arguments could not be serialized as JSON: {source}"
787        ))
788    })?;
789
790    Ok(format!(
791        "<previous_tool_call id=\"{}\" name=\"{}\">\n{}\n</previous_tool_call>",
792        xml_escape_attr(id),
793        xml_escape_attr(name),
794        canonical_arguments,
795    ))
796}
797
798/// Reads required message content as normalized text from a raw JSON value.
799fn required_content_text(value: Option<&Value>, path: &str) -> Result<String, ChatRequestError> {
800    optional_content_text(value, path, false)?.ok_or_else(|| {
801        ChatRequestError::unsupported_content(path, "content is required and must not be null")
802    })
803}
804
805/// Reads optional message content as normalized text, honoring the supplied null policy.
806fn optional_content_text(
807    value: Option<&Value>,
808    path: &str,
809    allow_null: bool,
810) -> Result<Option<String>, ChatRequestError> {
811    match value {
812        Some(Value::String(content)) => Ok(Some(content.clone())),
813        Some(Value::Null) if allow_null => Ok(None),
814        Some(Value::Null) => Err(ChatRequestError::unsupported_content(
815            path,
816            "null content is only supported for assistant messages with tool_calls",
817        )),
818        Some(Value::Array(parts)) => normalize_text_parts(parts, path).map(Some),
819        Some(other) => Err(ChatRequestError::unsupported_content(
820            path,
821            format!(
822                "expected a string or text-only content parts array, got {}",
823                json_kind(other)
824            ),
825        )),
826        None if allow_null => Ok(None),
827        None => Err(ChatRequestError::unsupported_content(
828            path,
829            "content is required",
830        )),
831    }
832}
833
834/// Concatenates a text-only OpenAI content-parts array into one content string.
835fn normalize_text_parts(parts: &[Value], path: &str) -> Result<String, ChatRequestError> {
836    if parts.is_empty() {
837        return Err(ChatRequestError::unsupported_content(
838            path,
839            "content parts array must not be empty",
840        ));
841    }
842
843    let mut text = String::new();
844    for (index, part) in parts.iter().enumerate() {
845        let part: RawTextContentPart = serde_json::from_value(part.clone()).map_err(|source| {
846            ChatRequestError::unsupported_content(
847                format!("{path}[{index}]"),
848                format!("text content part must match {{type:\"text\", text:string}}: {source}"),
849            )
850        })?;
851        text.push_str(&part.text);
852    }
853    Ok(text)
854}
855
856/// Parses optional OpenAI `tools` into supported function tool definitions.
857fn parse_tools(value: Option<&Value>) -> Result<Vec<ChatToolDefinition>, ChatRequestError> {
858    match value {
859        None => Ok(Vec::new()),
860        Some(value) => deserialize_typed_value("tools", value),
861    }
862}
863
864/// Parses optional OpenAI `tool_choice`, defaulting to automatic behavior when absent or null.
865fn parse_tool_choice(value: Option<&Value>) -> Result<ChatToolChoice, ChatRequestError> {
866    let Some(value) = value else {
867        return Ok(ChatToolChoice::default());
868    };
869    deserialize_typed_value::<Option<ChatToolChoice>>("tool_choice", value)
870        .map(|choice| choice.unwrap_or_default())
871}
872
873/// Validates normalized tool definitions that require cross-field checks.
874fn validate_tools(tools: &[ChatToolDefinition]) -> Result<(), ChatRequestError> {
875    if tools.iter().any(|tool| tool.name().trim().is_empty()) {
876        return Err(ChatRequestError::invalid_field(
877            "tools",
878            "function tool names must not be empty",
879        ));
880    }
881    Ok(())
882}
883
884/// Validates the normalized tool-choice value.
885fn validate_tool_choice(tool_choice: &ChatToolChoice) -> Result<(), ChatRequestError> {
886    if let ChatToolChoice::Function { name } = tool_choice
887        && name.trim().is_empty()
888    {
889        return Err(ChatRequestError::invalid_field(
890            "tool_choice",
891            "function tool_choice name must not be empty",
892        ));
893    }
894    Ok(())
895}
896
897/// Validates that flat and nested reasoning effort values agree when both are provided.
898fn validate_reasoning_effort_consistency(
899    reasoning: Option<&ReasoningOptions>,
900    reasoning_effort: Option<&str>,
901) -> Result<(), ChatRequestError> {
902    let Some(nested_effort) = reasoning.and_then(|reasoning| reasoning.effort.as_deref()) else {
903        return Ok(());
904    };
905    let Some(flat_effort) = reasoning_effort else {
906        return Ok(());
907    };
908    if nested_effort != flat_effort {
909        return Err(ChatRequestError::invalid_field(
910            "reasoning_effort",
911            "must match reasoning.effort when both are provided",
912        ));
913    }
914    Ok(())
915}
916
917/// Validates client-only fields that are accepted but not forwarded upstream.
918fn validate_ignored_client_only_fields(
919    object: &Map<String, Value>,
920) -> Result<(), ChatRequestError> {
921    if let Some(metadata) = object.get("metadata")
922        && !(metadata.is_object() || metadata.is_null())
923    {
924        return Err(ChatRequestError::invalid_field(
925            "metadata",
926            "metadata must be an object when provided",
927        ));
928    }
929    Ok(())
930}
931
932/// Validates that a typed string field contains non-whitespace text.
933fn non_empty_typed_string<'a>(
934    value: &'a str,
935    field: &'static str,
936) -> Result<&'a str, ChatRequestError> {
937    if value.trim().is_empty() {
938        return Err(ChatRequestError::invalid_tool_history(format!(
939            "{field} must not be empty"
940        )));
941    }
942    Ok(value)
943}
944
945/// Reads a required non-empty string field from a JSON object.
946fn required_non_empty_string<'a>(
947    object: &'a Map<String, Value>,
948    field: &'static str,
949) -> Result<&'a str, ChatRequestError> {
950    let value = object
951        .get(field)
952        .ok_or(ChatRequestError::MissingField { field })?;
953    let string = value.as_str().ok_or_else(|| {
954        ChatRequestError::invalid_field(field, format!("expected string, got {}", json_kind(value)))
955    })?;
956    if string.trim().is_empty() {
957        return Err(ChatRequestError::invalid_field(field, "must not be empty"));
958    }
959    Ok(string)
960}
961
962/// Reads an optional boolean field from a JSON object.
963fn optional_bool(
964    object: &Map<String, Value>,
965    field: &'static str,
966) -> Result<Option<bool>, ChatRequestError> {
967    object
968        .get(field)
969        .map(|value| {
970            value.as_bool().ok_or_else(|| {
971                ChatRequestError::invalid_field(
972                    field,
973                    format!("expected boolean, got {}", json_kind(value)),
974                )
975            })
976        })
977        .transpose()
978}
979
980/// Reads an optional non-empty string field from a JSON object.
981fn optional_non_empty_string(
982    object: &Map<String, Value>,
983    field: &'static str,
984) -> Result<Option<String>, ChatRequestError> {
985    match object.get(field) {
986        None | Some(Value::Null) => Ok(None),
987        Some(Value::String(value)) if value.trim().is_empty() => {
988            Err(ChatRequestError::invalid_field(field, "must not be empty"))
989        }
990        Some(Value::String(value)) => Ok(Some(value.clone())),
991        Some(value) => Err(ChatRequestError::invalid_field(
992            field,
993            format!("expected string, got {}", json_kind(value)),
994        )),
995    }
996}
997
998/// Reads an optional JSON number field and preserves its original JSON number representation.
999fn optional_number(
1000    object: &Map<String, Value>,
1001    field: &'static str,
1002) -> Result<Option<Value>, ChatRequestError> {
1003    match object.get(field) {
1004        None | Some(Value::Null) => Ok(None),
1005        Some(value) => {
1006            let number = deserialize_typed_value::<serde_json::Number>(field, value)?;
1007            Ok(Some(Value::Number(number)))
1008        }
1009    }
1010}
1011
1012/// Reads an optional unsigned integer field from a JSON object.
1013fn optional_u64(
1014    object: &Map<String, Value>,
1015    field: &'static str,
1016) -> Result<Option<u64>, ChatRequestError> {
1017    match object.get(field) {
1018        None | Some(Value::Null) => Ok(None),
1019        Some(value) => deserialize_typed_value(field, value).map(Some),
1020    }
1021}
1022
1023/// Reads the optional OpenAI `stop` field as a supported stop-sequence shape.
1024fn optional_stop(object: &Map<String, Value>) -> Result<Option<StopSequence>, ChatRequestError> {
1025    match object.get("stop") {
1026        None | Some(Value::Null) => Ok(None),
1027        Some(value) => deserialize_typed_value("stop", value).map(Some),
1028    }
1029}
1030
1031/// Deserializes a raw JSON value into a typed request field and reports field-scoped errors.
1032fn deserialize_typed_value<T>(field: &'static str, value: &Value) -> Result<T, ChatRequestError>
1033where
1034    T: DeserializeOwned,
1035{
1036    serde_json::from_value(value.clone()).map_err(|source| {
1037        ChatRequestError::invalid_field(
1038            field,
1039            format!(
1040                "expected supported shape, got {}: {source}",
1041                json_kind(value)
1042            ),
1043        )
1044    })
1045}
1046
1047/// Deserializes an optional boolean field while rejecting explicit null values.
1048fn deserialize_optional_bool_reject_null<'de, D>(deserializer: D) -> Result<Option<bool>, D::Error>
1049where
1050    D: serde::Deserializer<'de>,
1051{
1052    let value = Value::deserialize(deserializer)?;
1053    match value {
1054        Value::Bool(value) => Ok(Some(value)),
1055        other => Err(serde::de::Error::custom(format!(
1056            "expected boolean, got {}",
1057            json_kind(&other)
1058        ))),
1059    }
1060}
1061
1062/// Deserializes an optional non-empty string field while rejecting explicit null values.
1063fn deserialize_optional_non_empty_string_reject_null<'de, D>(
1064    deserializer: D,
1065) -> Result<Option<String>, D::Error>
1066where
1067    D: serde::Deserializer<'de>,
1068{
1069    let value = Value::deserialize(deserializer)?;
1070    match value {
1071        Value::String(value) if value.trim().is_empty() => {
1072            Err(serde::de::Error::custom("must not be empty"))
1073        }
1074        Value::String(value) => Ok(Some(value)),
1075        other => Err(serde::de::Error::custom(format!(
1076            "expected string, got {}",
1077            json_kind(&other)
1078        ))),
1079    }
1080}
1081
1082/// Deserializes an optional Venice web-search value while rejecting explicit null values.
1083fn deserialize_optional_web_search_reject_null<'de, D>(
1084    deserializer: D,
1085) -> Result<Option<RawVeniceWebSearch>, D::Error>
1086where
1087    D: serde::Deserializer<'de>,
1088{
1089    let value = Value::deserialize(deserializer)?;
1090    match value {
1091        Value::String(value) => Ok(Some(RawVeniceWebSearch::String(value))),
1092        Value::Bool(value) => Ok(Some(RawVeniceWebSearch::Bool(value))),
1093        other => Err(serde::de::Error::custom(format!(
1094            "expected string or boolean, got {}",
1095            json_kind(&other)
1096        ))),
1097    }
1098}
1099
1100/// Rejects the first object key that is not present in the supplied allowlist.
1101fn reject_unknown_fields(
1102    object: &Map<String, Value>,
1103    allowed: &[&str],
1104    _context: &str,
1105) -> Result<(), ChatRequestError> {
1106    if let Some(field) = object
1107        .keys()
1108        .find(|field| !allowed.contains(&field.as_str()))
1109    {
1110        return Err(ChatRequestError::UnsupportedField {
1111            field: field.clone(),
1112        });
1113    }
1114    Ok(())
1115}
1116
1117/// Escapes text for use inside generated XML attribute values.
1118fn xml_escape_attr(value: &str) -> String {
1119    let mut escaped = String::new();
1120    for ch in value.chars() {
1121        match ch {
1122            '&' => escaped.push_str("&amp;"),
1123            '"' => escaped.push_str("&quot;"),
1124            '<' => escaped.push_str("&lt;"),
1125            '>' => escaped.push_str("&gt;"),
1126            _ => escaped.push(ch),
1127        }
1128    }
1129    escaped
1130}
1131
1132#[cfg(test)]
1133mod tests {
1134    use super::*;
1135    use k256::{SecretKey, elliptic_curve::sec1::ToEncodedPoint};
1136    use serde_json::json;
1137
1138    fn parse(value: Value) -> ChatCompletionRequest {
1139        ChatCompletionRequest::parse(&value).expect("request should parse")
1140    }
1141
1142    fn model_public_key_hex(secret_key: &SecretKey) -> String {
1143        let public_key = secret_key.public_key();
1144        hex::encode(public_key.to_encoded_point(false).as_bytes())
1145    }
1146
1147    #[test]
1148    fn normalizes_system_user_and_assistant_text_messages() {
1149        let request = parse(json!({
1150            "model": "e2ee-test",
1151            "messages": [
1152                {"role": "system", "content": "You are concise."},
1153                {"role": "user", "content": [{"type":"text", "text":"Hello"}]},
1154                {"role": "assistant", "content": "Hi"}
1155            ]
1156        }));
1157
1158        assert_eq!(
1159            request.messages,
1160            vec![
1161                NormalizedChatMessage::new("system", "You are concise."),
1162                NormalizedChatMessage::new("user", "Hello"),
1163                NormalizedChatMessage::new("assistant", "Hi"),
1164            ]
1165        );
1166    }
1167
1168    #[test]
1169    fn normalizes_assistant_tool_call_history() {
1170        let request = parse(json!({
1171            "model": "e2ee-test",
1172            "messages": [
1173                {
1174                    "role": "assistant",
1175                    "content": null,
1176                    "tool_calls": [{
1177                        "id": "call_abc",
1178                        "type": "function",
1179                        "function": {
1180                            "name": "search_web",
1181                            "arguments": "{\"query\":\"Venice E2EE\"}"
1182                        }
1183                    }]
1184                }
1185            ]
1186        }));
1187
1188        assert_eq!(
1189            request.messages[0],
1190            NormalizedChatMessage::new(
1191                "assistant",
1192                "<previous_tool_call id=\"call_abc\" name=\"search_web\">\n{\"query\":\"Venice E2EE\"}\n</previous_tool_call>",
1193            )
1194        );
1195    }
1196
1197    #[test]
1198    fn normalizes_parallel_assistant_tool_call_history() {
1199        let request = parse(json!({
1200            "model": "e2ee-test",
1201            "messages": [
1202                {
1203                    "role": "assistant",
1204                    "content": null,
1205                    "tool_calls": [
1206                        {
1207                            "id": "call_one",
1208                            "type": "function",
1209                            "function": {
1210                                "name": "search_web",
1211                                "arguments": "{\"query\":\"Venice E2EE\"}"
1212                            }
1213                        },
1214                        {
1215                            "id": "call_two",
1216                            "type": "function",
1217                            "function": {
1218                                "name": "get_weather",
1219                                "arguments": "{\"city\":\"Venice\"}"
1220                            }
1221                        }
1222                    ]
1223                }
1224            ]
1225        }));
1226
1227        assert_eq!(
1228            request.messages[0],
1229            NormalizedChatMessage::new(
1230                "assistant",
1231                "<previous_tool_call id=\"call_one\" name=\"search_web\">\n{\"query\":\"Venice E2EE\"}\n</previous_tool_call>\n<previous_tool_call id=\"call_two\" name=\"get_weather\">\n{\"city\":\"Venice\"}\n</previous_tool_call>",
1232            )
1233        );
1234    }
1235
1236    #[test]
1237    fn normalizes_tool_result_messages_as_user_context() {
1238        let request = parse(json!({
1239            "model": "e2ee-test",
1240            "messages": [
1241                {"role": "tool", "tool_call_id": "call_abc", "content": "result text"}
1242            ]
1243        }));
1244
1245        assert_eq!(
1246            request.messages[0],
1247            NormalizedChatMessage::new(
1248                "user",
1249                "<tool_result id=\"call_abc\">\nresult text\n</tool_result>\n\nUse the tool result above to continue the answer.",
1250            )
1251        );
1252    }
1253
1254    #[test]
1255    fn parses_tools_into_typed_function_envelopes() {
1256        let request = parse(json!({
1257            "model": "e2ee-test",
1258            "messages": [{"role":"user", "content":"hi"}],
1259            "tools": [{
1260                "type": "function",
1261                "function": {
1262                    "name": "search_web",
1263                    "description": "Search the web",
1264                    "parameters": {
1265                        "type": "object",
1266                        "properties": {"query": {"type": "string"}},
1267                        "required": ["query"]
1268                    }
1269                }
1270            }]
1271        }));
1272
1273        assert_eq!(request.tools.len(), 1);
1274        let tool = &request.tools[0];
1275        let function = tool.function();
1276        assert_eq!(tool.name(), "search_web");
1277        assert_eq!(function.description.as_deref(), Some("Search the web"));
1278        assert_eq!(
1279            tool.parameters_schema()
1280                .and_then(|schema| schema.get("required")),
1281            Some(&json!(["query"]))
1282        );
1283        assert_eq!(
1284            serde_json::to_value(tool).expect("tool should serialize"),
1285            json!({
1286                "type": "function",
1287                "function": {
1288                    "name": "search_web",
1289                    "description": "Search the web",
1290                    "parameters": {
1291                        "type": "object",
1292                        "properties": {"query": {"type": "string"}},
1293                        "required": ["query"]
1294                    }
1295                }
1296            })
1297        );
1298    }
1299
1300    #[test]
1301    fn parses_tool_choice_into_typed_shapes() {
1302        let required = parse(json!({
1303            "model": "e2ee-test",
1304            "messages": [{"role":"user", "content":"hi"}],
1305            "tool_choice": "required"
1306        }));
1307        assert_eq!(required.tool_choice, ChatToolChoice::Required);
1308
1309        let specific = parse(json!({
1310            "model": "e2ee-test",
1311            "messages": [{"role":"user", "content":"hi"}],
1312            "tool_choice": {"type":"function", "function":{"name":"search_web"}}
1313        }));
1314        assert_eq!(
1315            specific.tool_choice,
1316            ChatToolChoice::Function {
1317                name: "search_web".to_owned()
1318            }
1319        );
1320
1321        let null_choice = parse(json!({
1322            "model": "e2ee-test",
1323            "messages": [{"role":"user", "content":"hi"}],
1324            "tool_choice": null
1325        }));
1326        assert_eq!(null_choice.tool_choice, ChatToolChoice::Auto);
1327    }
1328
1329    #[test]
1330    fn rejects_invalid_tool_and_tool_choice_shapes() {
1331        for body in [
1332            json!({
1333                "model": "e2ee-test",
1334                "messages": [{"role":"user", "content":"hi"}],
1335                "tools": ["not an object"]
1336            }),
1337            json!({
1338                "model": "e2ee-test",
1339                "messages": [{"role":"user", "content":"hi"}],
1340                "tools": [{"type":"web_search", "function":{"name":"search_web"}}]
1341            }),
1342            json!({
1343                "model": "e2ee-test",
1344                "messages": [{"role":"user", "content":"hi"}],
1345                "tools": [{"type":"function", "function":{"name":"search_web", "description": 42}}]
1346            }),
1347            json!({
1348                "model": "e2ee-test",
1349                "messages": [{"role":"user", "content":"hi"}],
1350                "tools": [{"type":"function", "function":{"name":"search_web", "parameters": []}}]
1351            }),
1352            json!({
1353                "model": "e2ee-test",
1354                "messages": [{"role":"user", "content":"hi"}],
1355                "tools": [{"type":"function", "function":{}}]
1356            }),
1357            json!({
1358                "model": "e2ee-test",
1359                "messages": [{"role":"user", "content":"hi"}],
1360                "tools": [{"type":"function", "function":{"name":""}}]
1361            }),
1362            json!({
1363                "model": "e2ee-test",
1364                "messages": [{"role":"user", "content":"hi"}],
1365                "tools": [{"type":"function", "function":{"name":"search_web", "extra": true}}]
1366            }),
1367            json!({
1368                "model": "e2ee-test",
1369                "messages": [{"role":"user", "content":"hi"}],
1370                "tool_choice": 42
1371            }),
1372            json!({
1373                "model": "e2ee-test",
1374                "messages": [{"role":"user", "content":"hi"}],
1375                "tool_choice": "always"
1376            }),
1377            json!({
1378                "model": "e2ee-test",
1379                "messages": [{"role":"user", "content":"hi"}],
1380                "tool_choice": {"type":"web_search", "function":{"name":"search_web"}}
1381            }),
1382            json!({
1383                "model": "e2ee-test",
1384                "messages": [{"role":"user", "content":"hi"}],
1385                "tool_choice": {"type":"function", "function":{"name":""}}
1386            }),
1387        ] {
1388            let error = ChatCompletionRequest::parse(&body)
1389                .expect_err("invalid tool shape should be rejected");
1390            assert_eq!(error.api_error_code(), "invalid_request");
1391        }
1392    }
1393
1394    #[test]
1395    fn rejects_unsupported_roles_and_content_shapes() {
1396        let role_error = ChatCompletionRequest::parse(&json!({
1397            "model": "e2ee-test",
1398            "messages": [{"role":"function", "content":"legacy"}]
1399        }))
1400        .expect_err("legacy function role should be rejected");
1401        assert_eq!(role_error.api_error_code(), "unsupported_message_role");
1402
1403        let content_error = ChatCompletionRequest::parse(&json!({
1404            "model": "e2ee-test",
1405            "messages": [{"role":"user", "content":[{"type":"image_url", "image_url":{"url":"x"}}]}]
1406        }))
1407        .expect_err("image content should be rejected");
1408        assert_eq!(
1409            content_error.api_error_code(),
1410            "unsupported_message_content"
1411        );
1412
1413        let assistant_error = ChatCompletionRequest::parse(&json!({
1414            "model": "e2ee-test",
1415            "messages": [{"role":"assistant", "content": null}]
1416        }))
1417        .expect_err("assistant null content without tool call should be rejected");
1418        assert_eq!(assistant_error.api_error_code(), "invalid_request");
1419    }
1420
1421    #[test]
1422    fn rejects_unsupported_top_level_fields_and_unsafe_venice_parameters() {
1423        let field_error = ChatCompletionRequest::parse(&json!({
1424            "model": "e2ee-test",
1425            "messages": [{"role":"user", "content":"hi"}],
1426            "file_ids": ["file_1"]
1427        }))
1428        .expect_err("unsupported top-level field should be rejected");
1429        assert_eq!(field_error.api_error_code(), "unsupported_request_field");
1430
1431        let web_search_error = ChatCompletionRequest::parse(&json!({
1432            "model": "e2ee-test",
1433            "messages": [{"role":"user", "content":"hi"}],
1434            "venice_parameters": {"enable_web_search": "on"}
1435        }))
1436        .expect_err("web search should be rejected for E2EE");
1437        assert_eq!(
1438            web_search_error.api_error_code(),
1439            "unsupported_venice_parameter"
1440        );
1441
1442        let mismatched_reasoning_effort = ChatCompletionRequest::parse(&json!({
1443            "model": "e2ee-test",
1444            "messages": [{"role":"user", "content":"hi"}],
1445            "reasoning": {"effort": "low"},
1446            "reasoning_effort": "high"
1447        }))
1448        .expect_err("mismatched reasoning efforts should be rejected");
1449        assert_eq!(
1450            mismatched_reasoning_effort.api_error_code(),
1451            "invalid_request"
1452        );
1453    }
1454
1455    #[test]
1456    fn rejects_null_or_invalid_typed_subfields_without_silent_option_coercion() {
1457        let stream_options_null = ChatCompletionRequest::parse(&json!({
1458            "model": "e2ee-test",
1459            "messages": [{"role":"user", "content":"hi"}],
1460            "stream_options": null
1461        }))
1462        .expect_err("stream_options null should be rejected");
1463        assert_eq!(stream_options_null.api_error_code(), "invalid_request");
1464
1465        let include_usage_null = ChatCompletionRequest::parse(&json!({
1466            "model": "e2ee-test",
1467            "messages": [{"role":"user", "content":"hi"}],
1468            "stream_options": {"include_usage": null}
1469        }))
1470        .expect_err("stream_options.include_usage null should be rejected");
1471        assert_eq!(include_usage_null.api_error_code(), "invalid_request");
1472
1473        let venice_params_null = ChatCompletionRequest::parse(&json!({
1474            "model": "e2ee-test",
1475            "messages": [{"role":"user", "content":"hi"}],
1476            "venice_parameters": null
1477        }))
1478        .expect_err("venice_parameters null should be rejected");
1479        assert_eq!(venice_params_null.api_error_code(), "invalid_request");
1480
1481        let invalid_stop = ChatCompletionRequest::parse(&json!({
1482            "model": "e2ee-test",
1483            "messages": [{"role":"user", "content":"hi"}],
1484            "stop": ["ok", 42]
1485        }))
1486        .expect_err("mixed stop array should be rejected");
1487        assert_eq!(invalid_stop.api_error_code(), "invalid_request");
1488    }
1489
1490    #[test]
1491    fn serde_layer_rejects_unknown_nested_fields_and_wrong_types() {
1492        let stream_options_unknown = ChatCompletionRequest::parse(&json!({
1493            "model": "e2ee-test",
1494            "messages": [{"role":"user", "content":"hi"}],
1495            "stream_options": {"include_usage": true, "extra": 1}
1496        }))
1497        .expect_err("unknown stream_options field should be rejected");
1498        assert_eq!(stream_options_unknown.api_error_code(), "invalid_request");
1499        assert!(
1500            stream_options_unknown
1501                .to_string()
1502                .contains("unknown field `extra`"),
1503            "unexpected message: {stream_options_unknown}"
1504        );
1505
1506        let include_usage_string = ChatCompletionRequest::parse(&json!({
1507            "model": "e2ee-test",
1508            "messages": [{"role":"user", "content":"hi"}],
1509            "stream_options": {"include_usage": "yes"}
1510        }))
1511        .expect_err("non-boolean include_usage should be rejected");
1512        assert_eq!(include_usage_string.api_error_code(), "invalid_request");
1513        assert!(
1514            include_usage_string
1515                .to_string()
1516                .contains("expected boolean, got string"),
1517            "unexpected message: {include_usage_string}"
1518        );
1519
1520        let venice_unknown = ChatCompletionRequest::parse(&json!({
1521            "model": "e2ee-test",
1522            "messages": [{"role":"user", "content":"hi"}],
1523            "venice_parameters": {"unknown_param": true}
1524        }))
1525        .expect_err("unknown venice_parameters field should be rejected");
1526        assert_eq!(venice_unknown.api_error_code(), "invalid_request");
1527        assert!(
1528            venice_unknown
1529                .to_string()
1530                .contains("unknown field `unknown_param`"),
1531            "unexpected message: {venice_unknown}"
1532        );
1533
1534        let enable_e2ee_string = ChatCompletionRequest::parse(&json!({
1535            "model": "e2ee-test",
1536            "messages": [{"role":"user", "content":"hi"}],
1537            "venice_parameters": {"enable_e2ee": "yes"}
1538        }))
1539        .expect_err("non-boolean enable_e2ee should be rejected");
1540        assert_eq!(enable_e2ee_string.api_error_code(), "invalid_request");
1541        assert!(
1542            enable_e2ee_string
1543                .to_string()
1544                .contains("expected boolean, got string"),
1545            "unexpected message: {enable_e2ee_string}"
1546        );
1547
1548        let web_search_number = ChatCompletionRequest::parse(&json!({
1549            "model": "e2ee-test",
1550            "messages": [{"role":"user", "content":"hi"}],
1551            "venice_parameters": {"enable_web_search": 42}
1552        }))
1553        .expect_err("non-string/boolean enable_web_search should be rejected");
1554        assert_eq!(web_search_number.api_error_code(), "invalid_request");
1555        assert!(
1556            web_search_number
1557                .to_string()
1558                .contains("expected string or boolean, got number"),
1559            "unexpected message: {web_search_number}"
1560        );
1561
1562        let null_enable_e2ee = ChatCompletionRequest::parse(&json!({
1563            "model": "e2ee-test",
1564            "messages": [{"role":"user", "content":"hi"}],
1565            "venice_parameters": {"enable_e2ee": null}
1566        }))
1567        .expect_err("null enable_e2ee should be rejected");
1568        assert_eq!(null_enable_e2ee.api_error_code(), "invalid_request");
1569
1570        let content_part_unknown = ChatCompletionRequest::parse(&json!({
1571            "model": "e2ee-test",
1572            "messages": [{"role":"user", "content":[{"type":"text", "text":"hi", "extra":1}]}]
1573        }))
1574        .expect_err("unknown content part field should be rejected");
1575        assert_eq!(
1576            content_part_unknown.api_error_code(),
1577            "unsupported_message_content"
1578        );
1579        assert!(
1580            content_part_unknown
1581                .to_string()
1582                .contains("unknown field `extra`"),
1583            "unexpected message: {content_part_unknown}"
1584        );
1585
1586        let content_part_non_object = ChatCompletionRequest::parse(&json!({
1587            "model": "e2ee-test",
1588            "messages": [{"role":"user", "content":["plain string part"]}]
1589        }))
1590        .expect_err("non-object content part should be rejected");
1591        assert_eq!(
1592            content_part_non_object.api_error_code(),
1593            "unsupported_message_content"
1594        );
1595    }
1596
1597    #[test]
1598    fn parses_and_forwards_reasoning_controls() {
1599        let model_key = SecretKey::random(&mut rand_core::OsRng);
1600        let model_public_key = model_public_key_hex(&model_key);
1601        let codec = E2eeCodec::default();
1602        let request = parse(json!({
1603            "model": "e2ee-test",
1604            "messages": [{"role":"user", "content":"hi"}],
1605            "reasoning": {"enabled": true, "effort": "high"},
1606            "reasoning_effort": "high",
1607            "venice_parameters": {
1608                "strip_thinking_response": true,
1609                "disable_thinking": false
1610            }
1611        }));
1612
1613        let reasoning = request.reasoning.as_ref().expect("reasoning should parse");
1614        assert_eq!(reasoning.enabled, Some(true));
1615        assert_eq!(reasoning.effort.as_deref(), Some("high"));
1616        assert_eq!(request.reasoning_effort.as_deref(), Some("high"));
1617        assert!(request.venice_parameters.strip_thinking_response);
1618        assert!(!request.venice_parameters.disable_thinking);
1619
1620        let prepared = request
1621            .to_venice_e2ee_request(&codec, &model_public_key)
1622            .expect("request should encrypt");
1623        assert_eq!(prepared.upstream.reasoning, request.reasoning);
1624        assert_eq!(prepared.upstream.reasoning_effort.as_deref(), Some("high"));
1625        assert!(prepared.upstream.venice_parameters.strip_thinking_response);
1626        assert!(!prepared.upstream.venice_parameters.disable_thinking);
1627
1628        let upstream =
1629            serde_json::to_value(&prepared.upstream).expect("upstream request should serialize");
1630        assert_eq!(upstream["reasoning"]["enabled"], true);
1631        assert_eq!(upstream["reasoning"]["effort"], "high");
1632        assert_eq!(upstream["reasoning_effort"], "high");
1633        assert_eq!(
1634            upstream["venice_parameters"]["strip_thinking_response"],
1635            true
1636        );
1637        assert_eq!(upstream["venice_parameters"]["disable_thinking"], false);
1638    }
1639
1640    #[test]
1641    fn constructs_encrypted_request_for_non_streaming_mode() {
1642        let model_key = SecretKey::random(&mut rand_core::OsRng);
1643        let model_public_key = model_public_key_hex(&model_key);
1644        let codec = E2eeCodec::default();
1645        let request = parse(json!({
1646            "model": "e2ee-test",
1647            "messages": [{"role":"user", "content":"hi"}],
1648            "stream": false,
1649            "temperature": 0.2,
1650            "max_tokens": 64,
1651            "stop": ["END"],
1652            "venice_parameters": {
1653                "include_venice_system_prompt": false,
1654                "enable_web_search": "off"
1655            }
1656        }));
1657
1658        let prepared = request
1659            .to_venice_e2ee_request(&codec, &model_public_key)
1660            .expect("request should encrypt");
1661
1662        assert!(!prepared.client_stream);
1663        assert!(prepared.upstream.stream);
1664        assert!(prepared.upstream.stream_options.include_usage);
1665        assert_eq!(prepared.upstream.temperature, Some(json!(0.2)));
1666        assert_eq!(prepared.upstream.max_tokens, Some(64));
1667        assert_eq!(
1668            prepared.upstream.stop,
1669            Some(StopSequence::Strings(vec!["END".to_owned()]))
1670        );
1671        assert_eq!(
1672            prepared.upstream.venice_parameters,
1673            VeniceParameters::default()
1674        );
1675
1676        assert_eq!(prepared.upstream.messages.len(), request.messages.len());
1677        assert_eq!(prepared.upstream.messages[0].role, "user");
1678        assert_ne!(
1679            prepared.upstream.messages[0].content,
1680            request.messages[0].content
1681        );
1682        let payload =
1683            crate::e2ee::EncryptedPayload::from_hex(&prepared.upstream.messages[0].content)
1684                .expect("message content should be encrypted hex");
1685        let plaintext = codec
1686            .decrypt_content(&payload, &model_key)
1687            .expect("test model key should decrypt message content");
1688        assert_eq!(plaintext, request.messages[0].content);
1689    }
1690
1691    #[test]
1692    fn constructs_encrypted_request_for_streaming_mode_and_usage_option() {
1693        let model_key = SecretKey::random(&mut rand_core::OsRng);
1694        let model_public_key = model_public_key_hex(&model_key);
1695        let codec = E2eeCodec::default();
1696        let request = parse(json!({
1697            "model": "e2ee-test",
1698            "messages": [{"role":"user", "content":"hi"}],
1699            "stream": true,
1700            "stream_options": {"include_usage": false}
1701        }));
1702
1703        let prepared = request
1704            .to_venice_e2ee_request(&codec, &model_public_key)
1705            .expect("request should encrypt");
1706
1707        assert!(prepared.client_stream);
1708        assert!(prepared.upstream.stream);
1709        assert!(!prepared.upstream.stream_options.include_usage);
1710    }
1711
1712    #[test]
1713    fn constructs_encrypted_request_with_tool_controller_and_retry_prompt() {
1714        let model_key = SecretKey::random(&mut rand_core::OsRng);
1715        let model_public_key = model_public_key_hex(&model_key);
1716        let codec = E2eeCodec::default();
1717        let request = parse(json!({
1718            "model": "e2ee-test",
1719            "messages": [{"role":"user", "content":"hi"}],
1720            "tools": [{"type":"function", "function":{"name":"search_web", "parameters":{"type":"object"}}}],
1721            "tool_choice": "required"
1722        }));
1723        let controller = NormalizedChatMessage::new("system", "controller prompt");
1724        let correction = NormalizedChatMessage::new("system", "retry prompt");
1725
1726        let prepared = request
1727            .to_venice_e2ee_request_with_messages(
1728                &codec,
1729                &model_public_key,
1730                std::slice::from_ref(&controller),
1731                std::slice::from_ref(&correction),
1732            )
1733            .expect("request should encrypt");
1734
1735        assert_eq!(prepared.upstream.messages.len(), 3);
1736        assert_eq!(prepared.upstream.messages[0].role, "system");
1737        assert_eq!(prepared.upstream.messages[1].role, "user");
1738        assert_eq!(prepared.upstream.messages[2].role, "system");
1739
1740        let decrypted = prepared
1741            .upstream
1742            .messages
1743            .iter()
1744            .map(|message| {
1745                let payload = crate::e2ee::EncryptedPayload::from_hex(&message.content)
1746                    .expect("message content should be encrypted hex");
1747                codec
1748                    .decrypt_content(&payload, &model_key)
1749                    .expect("test model key should decrypt message content")
1750            })
1751            .collect::<Vec<_>>();
1752        assert_eq!(decrypted, vec!["controller prompt", "hi", "retry prompt"]);
1753        assert!(
1754            !serde_json::to_value(&prepared.upstream)
1755                .expect("upstream request should serialize")
1756                .as_object()
1757                .expect("upstream request should be object")
1758                .contains_key("tools")
1759        );
1760    }
1761}