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