1use 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)]
27 pub jev: Option<JevExt>,
28 #[serde(default)]
29 pub tools: Option<Vec<Value>>,
30 #[serde(default)]
31 pub tool_choice: Option<Value>,
32 #[serde(flatten, default)]
33 pub passthrough: Map<String, Value>,
34}
35
36#[derive(Debug, Clone, Deserialize, Serialize)]
37pub struct Message {
38 pub role: String,
39 #[serde(default, skip_serializing_if = "Value::is_null")]
40 pub content: Value, #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub tool_calls: Option<Vec<Value>>, #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub tool_call_id: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub name: Option<String>, }
48
49#[derive(Debug, Clone, Deserialize, Serialize)]
50pub struct ResponseFormat {
51 #[serde(rename = "type")]
52 pub kind: String, #[serde(default)]
54 pub json_schema: Option<Value>,
55}
56
57#[derive(Debug, Clone, Default, Deserialize, Serialize)]
61pub struct JevExt {
62 pub questions: Map<String, Value>,
64 #[serde(default)]
67 pub state: Option<String>,
68 #[serde(default)]
71 pub extract: Option<ExtractSpec>,
72}
73
74#[derive(Debug, Clone, Deserialize, Serialize)]
78pub struct ExtractCandidate {
79 pub key: String,
80 pub question: String,
82 pub text: String,
84}
85
86#[derive(Debug, Clone, Deserialize, Serialize)]
90pub struct ExtractSpec {
91 pub floor: f64,
93 pub candidates: Vec<ExtractCandidate>,
94 pub prompt: String,
96 pub response_schema: Value,
99}
100
101#[derive(Debug, Clone, Default, Deserialize, Serialize)]
102pub struct VertexExt {
103 #[serde(default)]
104 pub cached_content: Option<String>,
105 #[serde(default)]
106 pub media_uris: Option<Vec<String>>,
107 #[serde(default)]
108 pub response_schema: Option<Value>,
109 #[serde(default)]
113 pub thinking_config: Option<Value>,
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119
120 #[test]
121 fn parses_plain_openai_body() {
122 let body = serde_json::json!({
123 "model": "gemini-pro",
124 "messages": [{"role": "user", "content": "hi"}],
125 "temperature": 0.2
126 });
127 let req: ChatRequest = serde_json::from_value(body).unwrap();
128 assert_eq!(req.model, "gemini-pro");
129 assert_eq!(req.messages.len(), 1);
130 assert!(req.vertex.is_none());
131 assert!(req.passthrough.is_empty());
132 }
133
134 #[test]
135 fn captures_vertex_extension_and_passthrough() {
136 let body = serde_json::json!({
137 "model": "gemini-pro",
138 "messages": [{"role": "user", "content": "hi"}],
139 "top_k": 40,
140 "vertex": { "cached_content": "cachedContents/abc" }
141 });
142 let req: ChatRequest = serde_json::from_value(body).unwrap();
143 assert_eq!(
144 req.vertex.unwrap().cached_content.as_deref(),
145 Some("cachedContents/abc")
146 );
147 assert_eq!(req.passthrough.get("top_k"), Some(&serde_json::json!(40)));
148 }
149
150 #[test]
151 fn captures_jev_extension() {
152 let body = serde_json::json!({
153 "model": "ticket-triage",
154 "messages": [{"role": "user", "content": "hi"}],
155 "jev": {
156 "questions": {
157 "urgency": {"type": "noul", "instructions": "Is this urgent?"}
158 },
159 "state": "override"
160 }
161 });
162 let req: ChatRequest = serde_json::from_value(body).unwrap();
163 let jev = req.jev.unwrap();
164 assert!(jev.questions.contains_key("urgency"));
165 assert_eq!(jev.state.as_deref(), Some("override"));
166 }
167
168 #[test]
169 fn captures_vertex_thinking_config() {
170 let body = serde_json::json!({
171 "model": "gemini-3-pro",
172 "messages": [{"role": "user", "content": "hi"}],
173 "vertex": { "thinking_config": { "thinkingLevel": "low" } }
174 });
175 let req: ChatRequest = serde_json::from_value(body).unwrap();
176 assert_eq!(
177 req.vertex.unwrap().thinking_config,
178 Some(serde_json::json!({ "thinkingLevel": "low" }))
179 );
180 }
181
182 #[test]
183 fn parses_tools_and_tool_messages() {
184 let body = serde_json::json!({
185 "model": "gemini-pro",
186 "messages": [
187 {"role": "user", "content": "weather in SF?"},
188 {"role": "assistant", "content": null,
189 "tool_calls": [{"id": "call_0", "type": "function",
190 "function": {"name": "get_weather", "arguments": "{\"city\":\"SF\"}"}}]},
191 {"role": "tool", "tool_call_id": "call_0", "content": "21C"}
192 ],
193 "tools": [{"type": "function", "function": {"name": "get_weather",
194 "description": "Lookup", "parameters": {"type": "object"}}}],
195 "tool_choice": "auto"
196 });
197 let req: ChatRequest = serde_json::from_value(body).unwrap();
198 assert_eq!(req.tools.as_ref().unwrap().len(), 1);
199 assert_eq!(req.tool_choice, Some(serde_json::json!("auto")));
200 let asst = &req.messages[1];
201 assert!(asst.tool_calls.is_some());
202 let tool = &req.messages[2];
203 assert_eq!(tool.tool_call_id.as_deref(), Some("call_0"));
204 }
205
206 #[test]
207 fn captures_jev_extract_spec() {
208 let body = serde_json::json!({
209 "model": "verify-orgs",
210 "messages": [{"role": "user", "content": "hi"}],
211 "jev": {
212 "questions": {
213 "c0_match": {"type": "noul", "instructions": "Candidate 0 is the org's own site."}
214 },
215 "extract": {
216 "floor": 0.7,
217 "candidates": [
218 {"key": "c0", "question": "c0_match", "text": "excerpt 0"}
219 ],
220 "prompt": "Extract.\n\nCandidate {{key}}:\n{{text}}",
221 "response_schema": {"type": "object"}
222 }
223 }
224 });
225 let req: ChatRequest = serde_json::from_value(body).unwrap();
226 let extract = req.jev.unwrap().extract.unwrap();
227 assert_eq!(extract.floor, 0.7);
228 assert_eq!(extract.candidates.len(), 1);
229 assert_eq!(extract.candidates[0].key, "c0");
230 assert_eq!(extract.candidates[0].question, "c0_match");
231 assert_eq!(extract.candidates[0].text, "excerpt 0");
232 assert!(extract.prompt.contains("{{text}}"));
233 assert_eq!(
234 extract.response_schema,
235 serde_json::json!({"type": "object"})
236 );
237 }
238
239 #[test]
240 fn jev_block_without_extract_parses() {
241 let body = serde_json::json!({
242 "model": "m",
243 "messages": [{"role": "user", "content": "hi"}],
244 "jev": {"questions": {"q": {"type": "noul", "instructions": "x"}}}
245 });
246 let req: ChatRequest = serde_json::from_value(body).unwrap();
247 assert!(req.jev.unwrap().extract.is_none());
248 }
249}