Skip to main content

synapse/routing/
request.rs

1//! OpenAI-compatible chat request body + native extension block.
2
3use serde::{Deserialize, Serialize};
4use serde_json::{Map, Value};
5
6#[derive(Debug, Clone, Deserialize, Serialize)]
7pub struct ChatRequest {
8    pub model: String,
9    pub messages: Vec<Message>,
10    #[serde(default)]
11    pub temperature: Option<f32>,
12    #[serde(default)]
13    pub max_tokens: Option<u32>,
14    #[serde(default)]
15    pub stream: Option<bool>,
16    #[serde(default)]
17    pub response_format: Option<ResponseFormat>,
18    #[serde(default)]
19    pub routing_strategy: Option<String>,
20    #[serde(default)]
21    pub vertex: Option<VertexExt>,
22    #[serde(default)]
23    pub tools: Option<Vec<Value>>,
24    #[serde(default)]
25    pub tool_choice: Option<Value>,
26    #[serde(flatten, default)]
27    pub passthrough: Map<String, Value>,
28}
29
30#[derive(Debug, Clone, Deserialize, Serialize)]
31pub struct Message {
32    pub role: String,
33    #[serde(default, skip_serializing_if = "Value::is_null")]
34    pub content: Value, // string, array of parts, or null on a tool-call turn
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub tool_calls: Option<Vec<Value>>, // assistant turn: [{id,type,function:{name,arguments}}]
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub tool_call_id: Option<String>, // role:"tool" result correlation
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub name: Option<String>, // optional name (tool result or system message)
41}
42
43#[derive(Debug, Clone, Deserialize, Serialize)]
44pub struct ResponseFormat {
45    #[serde(rename = "type")]
46    pub kind: String, // "text" | "json_object" | "json_schema"
47    #[serde(default)]
48    pub json_schema: Option<Value>,
49}
50
51#[derive(Debug, Clone, Default, Deserialize, Serialize)]
52pub struct VertexExt {
53    #[serde(default)]
54    pub cached_content: Option<String>,
55    #[serde(default)]
56    pub media_uris: Option<Vec<String>>,
57    #[serde(default)]
58    pub response_schema: Option<Value>,
59    /// Raw Vertex `generationConfig.thinkingConfig` passthrough (e.g.
60    /// `{"thinkingLevel":"low"}` for Gemini 3, or `{"thinkingBudget":N}` for
61    /// Gemini 2.5). Threaded verbatim so the gateway stays model-agnostic.
62    #[serde(default)]
63    pub thinking_config: Option<Value>,
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    #[test]
71    fn parses_plain_openai_body() {
72        let body = serde_json::json!({
73            "model": "gemini-pro",
74            "messages": [{"role": "user", "content": "hi"}],
75            "temperature": 0.2
76        });
77        let req: ChatRequest = serde_json::from_value(body).unwrap();
78        assert_eq!(req.model, "gemini-pro");
79        assert_eq!(req.messages.len(), 1);
80        assert!(req.vertex.is_none());
81        assert!(req.passthrough.is_empty());
82    }
83
84    #[test]
85    fn captures_vertex_extension_and_passthrough() {
86        let body = serde_json::json!({
87            "model": "gemini-pro",
88            "messages": [{"role": "user", "content": "hi"}],
89            "top_k": 40,
90            "vertex": { "cached_content": "cachedContents/abc" }
91        });
92        let req: ChatRequest = serde_json::from_value(body).unwrap();
93        assert_eq!(
94            req.vertex.unwrap().cached_content.as_deref(),
95            Some("cachedContents/abc")
96        );
97        assert_eq!(req.passthrough.get("top_k"), Some(&serde_json::json!(40)));
98    }
99
100    #[test]
101    fn captures_vertex_thinking_config() {
102        let body = serde_json::json!({
103            "model": "gemini-3-pro",
104            "messages": [{"role": "user", "content": "hi"}],
105            "vertex": { "thinking_config": { "thinkingLevel": "low" } }
106        });
107        let req: ChatRequest = serde_json::from_value(body).unwrap();
108        assert_eq!(
109            req.vertex.unwrap().thinking_config,
110            Some(serde_json::json!({ "thinkingLevel": "low" }))
111        );
112    }
113
114    #[test]
115    fn parses_tools_and_tool_messages() {
116        let body = serde_json::json!({
117            "model": "gemini-pro",
118            "messages": [
119                {"role": "user", "content": "weather in SF?"},
120                {"role": "assistant", "content": null,
121                 "tool_calls": [{"id": "call_0", "type": "function",
122                    "function": {"name": "get_weather", "arguments": "{\"city\":\"SF\"}"}}]},
123                {"role": "tool", "tool_call_id": "call_0", "content": "21C"}
124            ],
125            "tools": [{"type": "function", "function": {"name": "get_weather",
126                "description": "Lookup", "parameters": {"type": "object"}}}],
127            "tool_choice": "auto"
128        });
129        let req: ChatRequest = serde_json::from_value(body).unwrap();
130        assert_eq!(req.tools.as_ref().unwrap().len(), 1);
131        assert_eq!(req.tool_choice, Some(serde_json::json!("auto")));
132        let asst = &req.messages[1];
133        assert!(asst.tool_calls.is_some());
134        let tool = &req.messages[2];
135        assert_eq!(tool.tool_call_id.as_deref(), Some("call_0"));
136    }
137}