Skip to main content

agentd/intel/
openai.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! OpenAI-compatible `/chat/completions` adapter with native tool-calling.
3//!
4//! This is the default dialect because it covers vLLM, Ollama, LM Studio and
5//! most hosted gateways, and because it gives the model first-class `tools` and
6//! `tool_calls` instead of prompt-parsed actions. The module is pure
7//! translation — neutral [`Request`] → OpenAI JSON, OpenAI JSON → neutral
8//! [`Response`] — and performs no I/O; `intel/client.rs` owns the socket.
9
10use crate::wire::intel::{Message, Request, Response, StopReason, ToolCall, Usage};
11use serde_json::{Map, Value, json};
12
13/// The default endpoint path when the intelligence URL carries no explicit
14/// path (`https://host[:port]` with path `/`).
15pub const DEFAULT_PATH: &str = "/v1/chat/completions";
16
17/// The OpenAI-compatible model-list path used by the discovery probe. The `/v1`
18/// API root has a sibling `/models` next to `/chat/completions`.
19pub const MODELS_PATH: &str = "/v1/models";
20
21/// Derive the model-discovery `GET` path as the **sibling** of the configured
22/// chat path: swap the trailing `…/chat/completions` segment for `…/models` so
23/// a non-default API root (e.g. a gateway mounted at `/proxy/v1`) keeps its
24/// prefix. Anything that isn't the canonical chat suffix falls back to the
25/// absolute `MODELS_PATH` — discovery is best-effort, never a hard failure, so
26/// guessing a plausible path beats refusing to probe.
27pub 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
34/// Parse an OpenAI-compatible `/v1/models` response body into the list of model
35/// `id`s. The expected shape is `{ "data": [ { "id": "…" }, … ] }`. A missing,
36/// empty or non-array `data`, or a body that is not JSON at all, yields an empty
37/// list: discovery degrades silently and never counts as a failover-class
38/// failure, so a provider without the endpoint must not mark itself unhealthy.
39pub 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
53/// The output-token-limit parameter name for `model`. OpenAI's reasoning models
54/// (`gpt-5*`, the `o1/o3/o4` series) require `max_completion_tokens` and REJECT
55/// the older `max_tokens`; everything else (gpt-4o/4.1, and self-hosted
56/// OpenAI-compatible servers that only implement the classic param) takes
57/// `max_tokens`. Choosing per model keeps both working.
58fn 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
72/// Build the request body (JSON bytes) and the HTTP headers for a chat
73/// completion. `token`, if present, becomes `Authorization: Bearer …`.
74pub 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                                    // OpenAI requires arguments as a JSON *string*.
129                                    "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                // OpenAI has no error flag on tool messages; prefix so the
140                // model still sees that this observation was an error.
141                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
148/// Parse an OpenAI `/chat/completions` response body into the neutral
149/// [`Response`]. Tolerant: missing usage → zero; unknown finish reason →
150/// [`StopReason::Other`]; tool-call arguments that aren't valid JSON are
151/// wrapped as `{"_raw": "…"}` rather than dropped.
152pub 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    // Surface an OpenAI-style error object clearly.
157    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        // A reasoning model (gpt-5*/o-series) must use max_completion_tokens.
250        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        // An older / self-hosted model keeps the classic max_tokens (gpt-x here).
256        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    // --- model discovery ---------------------------------------------------
306
307    #[test]
308    fn models_path_is_sibling_of_chat_path() {
309        // Canonical: /v1/chat/completions → /v1/models.
310        assert_eq!(models_path("/v1/chat/completions"), "/v1/models");
311        // A gateway mounted at a non-default root keeps its prefix.
312        assert_eq!(
313            models_path("/proxy/v1/chat/completions"),
314            "/proxy/v1/models"
315        );
316        // Anything non-canonical falls back to the absolute path.
317        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        // Non-JSON → [].
332        assert!(parse_models(b"<html>404</html>").is_empty());
333        // JSON without a `data` array → [].
334        assert!(parse_models(br#"{"error":{"message":"nope"}}"#).is_empty());
335        // `data` present but entries without an `id` → [].
336        assert!(parse_models(br#"{"data":[{"object":"model"}]}"#).is_empty());
337        // Empty list → [].
338        assert!(parse_models(br#"{"data":[]}"#).is_empty());
339    }
340}