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    /// TypeSafe System One (Jev) extension block. Present (with non-empty
23    /// `questions`) routes the request to the route's `typesafe` legs; on
24    /// retryable Jev failure the remaining legs answer as a normal chat
25    /// completion.
26    #[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, // string, array of parts, or null on a tool-call turn
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub tool_calls: Option<Vec<Value>>, // assistant turn: [{id,type,function:{name,arguments}}]
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub tool_call_id: Option<String>, // role:"tool" result correlation
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub name: Option<String>, // optional name (tool result or system message)
47}
48
49#[derive(Debug, Clone, Deserialize, Serialize)]
50pub struct ResponseFormat {
51    #[serde(rename = "type")]
52    pub kind: String, // "text" | "json_object" | "json_schema"
53    #[serde(default)]
54    pub json_schema: Option<Value>,
55}
56
57/// TypeSafe System One (Jev) extension block on a chat request: typed
58/// questions evaluated against a state. The questions map is forwarded to Jev
59/// verbatim (name → `{type, instructions, criteria?}`).
60#[derive(Debug, Clone, Default, Deserialize, Serialize)]
61pub struct JevExt {
62    /// name → question definition. Non-empty routes the request to the Jev lane.
63    pub questions: Map<String, Value>,
64    /// Optional state override. Default: the request's `messages` serialized
65    /// to JSON. State and questions share Jev's ~32k-token budget.
66    #[serde(default)]
67    pub state: Option<String>,
68    /// Optional hybrid extraction spec: judge `candidates` with `noul`
69    /// questions, then extract per survivor on the route's chat legs.
70    #[serde(default)]
71    pub extract: Option<ExtractSpec>,
72}
73
74/// One extraction candidate: the `text` gated by a `noul` question. When the
75/// question's answer meets the extract floor, `text` is substituted into the
76/// extraction prompt under `key`.
77#[derive(Debug, Clone, Deserialize, Serialize)]
78pub struct ExtractCandidate {
79    pub key: String,
80    /// Name of a `noul` question in `jev.questions` that gates this candidate.
81    pub question: String,
82    /// Candidate content fed to the extraction prompt as `{{text}}`.
83    pub text: String,
84}
85
86/// Hybrid extraction spec (optional part of the `jev` extension block):
87/// judge candidates with Jev, then run a schema-pinned chat extraction per
88/// survivor. See `docs/superpowers/specs/2026-09-22-jev-hybrid-extraction-design.md`.
89#[derive(Debug, Clone, Deserialize, Serialize)]
90pub struct ExtractSpec {
91    /// Minimum `noul` value (0..1] for a candidate to be extracted.
92    pub floor: f64,
93    pub candidates: Vec<ExtractCandidate>,
94    /// Extraction system-message template; must contain `{{text}}`.
95    pub prompt: String,
96    /// Output schema for the extraction call (response_format on standard
97    /// legs, vertex.response_schema on the native Vertex lane).
98    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    /// Raw Vertex `generationConfig.thinkingConfig` passthrough (e.g.
110    /// `{"thinkingLevel":"low"}` for Gemini 3, or `{"thinkingBudget":N}` for
111    /// Gemini 2.5). Threaded verbatim so the gateway stays model-agnostic.
112    #[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}