1use crate::wire::intel::{Message, Request, Response, StopReason, ToolCall, Usage};
11use serde_json::{Map, Value, json};
12
13pub const DEFAULT_PATH: &str = "/v1/chat/completions";
16
17pub const MODELS_PATH: &str = "/v1/models";
20
21pub fn models_path(chat_path: &str) -> String {
28 match chat_path.strip_suffix("/chat/completions") {
29 Some(prefix) => format!("{prefix}/models"),
30 None => MODELS_PATH.to_string(),
31 }
32}
33
34pub fn parse_models(body: &[u8]) -> Vec<String> {
40 let Ok(v) = serde_json::from_slice::<Value>(body) else {
41 return Vec::new();
42 };
43 let Some(arr) = v.get("data").and_then(Value::as_array) else {
44 return Vec::new();
45 };
46 arr.iter()
47 .filter_map(|m| m.get("id").and_then(Value::as_str))
48 .filter(|s| !s.is_empty())
49 .map(str::to_string)
50 .collect()
51}
52
53fn max_tokens_key(model: &str) -> &'static str {
59 let m = model.to_ascii_lowercase();
60 if m.starts_with("gpt-5")
61 || m.starts_with("chatgpt-5")
62 || m.starts_with("o1")
63 || m.starts_with("o3")
64 || m.starts_with("o4")
65 {
66 "max_completion_tokens"
67 } else {
68 "max_tokens"
69 }
70}
71
72pub fn build_request(req: &Request, token: Option<&str>) -> (Vec<u8>, Vec<(String, String)>) {
75 let mut body = Map::new();
76 body.insert("model".into(), json!(req.model));
77 body.insert(max_tokens_key(&req.model).into(), json!(req.max_tokens));
78 if let Some(t) = req.temperature {
79 body.insert("temperature".into(), json!(t));
80 }
81 body.insert("messages".into(), json!(messages_to_openai(&req.messages)));
82 if !req.tools.is_empty() {
83 let tools: Vec<Value> = req
84 .tools
85 .iter()
86 .map(|t| {
87 json!({
88 "type": "function",
89 "function": {
90 "name": t.name,
91 "description": t.description,
92 "parameters": t.input_schema,
93 }
94 })
95 })
96 .collect();
97 body.insert("tools".into(), json!(tools));
98 body.insert("tool_choice".into(), json!("auto"));
99 }
100
101 let bytes = serde_json::to_vec(&Value::Object(body)).unwrap_or_default();
102 let mut headers = vec![("content-type".to_string(), "application/json".to_string())];
103 if let Some(tok) = token {
104 headers.push(("authorization".to_string(), format!("Bearer {tok}")));
105 }
106 (bytes, headers)
107}
108
109fn messages_to_openai(messages: &[Message]) -> Vec<Value> {
110 messages
111 .iter()
112 .map(|m| match m {
113 Message::System(s) => json!({"role": "system", "content": s}),
114 Message::User(s) => json!({"role": "user", "content": s}),
115 Message::Assistant { text, tool_calls } => {
116 let mut obj = Map::new();
117 obj.insert("role".into(), json!("assistant"));
118 obj.insert("content".into(), json!(text));
119 if !tool_calls.is_empty() {
120 let calls: Vec<Value> = tool_calls
121 .iter()
122 .map(|tc| {
123 json!({
124 "id": tc.id,
125 "type": "function",
126 "function": {
127 "name": tc.name,
128 "arguments": serde_json::to_string(&tc.arguments).unwrap_or_else(|_| "{}".into()),
130 }
131 })
132 })
133 .collect();
134 obj.insert("tool_calls".into(), json!(calls));
135 }
136 Value::Object(obj)
137 }
138 Message::ToolResult { id, content, is_error } => {
139 let body = if *is_error { format!("ERROR: {content}") } else { content.clone() };
142 json!({"role": "tool", "tool_call_id": id, "content": body})
143 }
144 })
145 .collect()
146}
147
148pub fn parse_response(body: &[u8]) -> Result<Response, String> {
153 let v: Value =
154 serde_json::from_slice(body).map_err(|e| format!("intel: bad JSON response: {e}"))?;
155
156 if let Some(err) = v.get("error") {
158 let msg = err
159 .get("message")
160 .and_then(Value::as_str)
161 .unwrap_or("unknown");
162 return Err(format!("intel: provider error: {msg}"));
163 }
164
165 let choice = v
166 .get("choices")
167 .and_then(|c| c.get(0))
168 .ok_or_else(|| "intel: response has no choices".to_string())?;
169 let message = choice.get("message").unwrap_or(&Value::Null);
170
171 let text = message
172 .get("content")
173 .and_then(Value::as_str)
174 .filter(|s| !s.is_empty())
175 .map(str::to_string);
176
177 let mut tool_calls = Vec::new();
178 if let Some(calls) = message.get("tool_calls").and_then(Value::as_array) {
179 for c in calls {
180 let id = c
181 .get("id")
182 .and_then(Value::as_str)
183 .unwrap_or("")
184 .to_string();
185 let func = c.get("function").unwrap_or(&Value::Null);
186 let name = func
187 .get("name")
188 .and_then(Value::as_str)
189 .unwrap_or("")
190 .to_string();
191 let raw_args = func
192 .get("arguments")
193 .and_then(Value::as_str)
194 .unwrap_or("{}");
195 let arguments =
196 serde_json::from_str(raw_args).unwrap_or_else(|_| json!({ "_raw": raw_args }));
197 tool_calls.push(ToolCall {
198 id,
199 name,
200 arguments,
201 });
202 }
203 }
204
205 let stop_reason = match choice.get("finish_reason").and_then(Value::as_str) {
206 Some("stop") => StopReason::EndTurn,
207 Some("tool_calls") => StopReason::ToolUse,
208 Some("length") => StopReason::MaxTokens,
209 _ => StopReason::Other,
210 };
211
212 let usage = v.get("usage").map(|u| Usage {
213 input_tokens: u.get("prompt_tokens").and_then(Value::as_u64).unwrap_or(0),
214 output_tokens: u
215 .get("completion_tokens")
216 .and_then(Value::as_u64)
217 .unwrap_or(0),
218 });
219
220 Ok(Response {
221 text,
222 tool_calls,
223 stop_reason,
224 usage: usage.unwrap_or_default(),
225 })
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231 use crate::wire::intel::ToolDef;
232
233 fn req() -> Request {
234 Request {
235 model: "gpt-x".into(),
236 messages: vec![Message::system("be terse"), Message::user("hi")],
237 tools: vec![ToolDef {
238 name: "read_file".into(),
239 description: "read a file".into(),
240 input_schema: json!({"type": "object"}),
241 }],
242 max_tokens: 256,
243 temperature: Some(0.0),
244 }
245 }
246
247 #[test]
248 fn reasoning_models_switch_the_token_limit_param() {
249 let mut r = req();
251 r.model = "gpt-5.1".into();
252 let v: Value = serde_json::from_slice(&build_request(&r, None).0).unwrap();
253 assert_eq!(v["max_completion_tokens"], 256);
254 assert!(v.get("max_tokens").is_none(), "{v}");
255 let v: Value = serde_json::from_slice(&build_request(&req(), None).0).unwrap();
257 assert_eq!(v["max_tokens"], 256);
258 assert!(v.get("max_completion_tokens").is_none(), "{v}");
259 }
260
261 #[test]
262 fn build_includes_tools_and_auth() {
263 let (body, headers) = build_request(&req(), Some("sk-test"));
264 let v: Value = serde_json::from_slice(&body).unwrap();
265 assert_eq!(v["model"], "gpt-x");
266 assert_eq!(v["tools"][0]["function"]["name"], "read_file");
267 assert_eq!(v["tool_choice"], "auto");
268 assert!(
269 headers
270 .iter()
271 .any(|(k, val)| k == "authorization" && val == "Bearer sk-test")
272 );
273 }
274
275 #[test]
276 fn parse_final_text() {
277 let body = br#"{"choices":[{"message":{"content":"done"},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":2}}"#;
278 let r = parse_response(body).unwrap();
279 assert_eq!(r.text.as_deref(), Some("done"));
280 assert_eq!(r.stop_reason, StopReason::EndTurn);
281 assert_eq!(r.usage.total(), 12);
282 assert!(!r.wants_tools());
283 }
284
285 #[test]
286 fn parse_tool_call() {
287 let body = br#"{"choices":[{"message":{"content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"/x\"}"}}]},"finish_reason":"tool_calls"}]}"#;
288 let r = parse_response(body).unwrap();
289 assert!(r.wants_tools());
290 assert_eq!(r.tool_calls[0].name, "read_file");
291 assert_eq!(r.tool_calls[0].arguments["path"], "/x");
292 assert_eq!(r.stop_reason, StopReason::ToolUse);
293 }
294
295 #[test]
296 fn parse_provider_error() {
297 let body = br#"{"error":{"message":"invalid api key"}}"#;
298 assert!(
299 parse_response(body)
300 .unwrap_err()
301 .contains("invalid api key")
302 );
303 }
304
305 #[test]
308 fn models_path_is_sibling_of_chat_path() {
309 assert_eq!(models_path("/v1/chat/completions"), "/v1/models");
311 assert_eq!(
313 models_path("/proxy/v1/chat/completions"),
314 "/proxy/v1/models"
315 );
316 assert_eq!(models_path("/weird/endpoint"), "/v1/models");
318 }
319
320 #[test]
321 fn parse_models_reads_data_ids() {
322 let body = br#"{"object":"list","data":[{"id":"claude-opus-4","object":"model"},{"id":"claude-haiku-4"}]}"#;
323 assert_eq!(
324 parse_models(body),
325 vec!["claude-opus-4".to_string(), "claude-haiku-4".to_string()]
326 );
327 }
328
329 #[test]
330 fn parse_models_degrades_to_empty_on_bad_or_missing_data() {
331 assert!(parse_models(b"<html>404</html>").is_empty());
333 assert!(parse_models(br#"{"error":{"message":"nope"}}"#).is_empty());
335 assert!(parse_models(br#"{"data":[{"object":"model"}]}"#).is_empty());
337 assert!(parse_models(br#"{"data":[]}"#).is_empty());
339 }
340}