Skip to main content

agentd/intel/
openai.rs

1// SPDX-License-Identifier: Apache-2.0
2//! OpenAI-compatible `/chat/completions` adapter with native tool-calling.
3//! RFC 0006 §canonical-wire.
4//!
5//! Canonical because it covers vLLM / Ollama / LM Studio / most hosted
6//! gateways and gives the model first-class `tools` + `tool_calls`. This
7//! module is pure translation: neutral [`Request`] → OpenAI JSON, and OpenAI
8//! JSON → neutral [`Response`]. No I/O (that's `intel/client.rs`).
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 (RFC 0018 §5.4 discovery probe). The
18/// `/v1` 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 (RFC 0018 §5.4) as the **sibling** of
22/// the configured chat path: swap the trailing `…/chat/completions` segment for
23/// `…/models` so a non-default API root (e.g. a gateway mounted at `/proxy/v1`)
24/// keeps its prefix. Anything that isn't the canonical chat suffix falls back to
25/// the absolute `MODELS_PATH` — discovery is best-effort, never a hard failure.
26pub 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
33/// Parse an OpenAI-compatible `/v1/models` response body into the list of model
34/// `id`s (RFC 0018 §5.4). The shape is `{ "data": [ { "id": "…" }, … ] }`. Any
35/// missing/empty/non-array `data`, or a non-JSON body, yields an empty list —
36/// discovery degrades silently (it is never a failover-class failure, §5.4).
37pub 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
51/// The output-token-limit parameter name for `model`. OpenAI's reasoning models
52/// (`gpt-5*`, the `o1/o3/o4` series) require `max_completion_tokens` and REJECT
53/// the older `max_tokens`; everything else (gpt-4o/4.1, and self-hosted
54/// OpenAI-compatible servers that only implement the classic param) takes
55/// `max_tokens`. Choosing per model keeps both working.
56fn 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
70/// Build the request body (JSON bytes) and the HTTP headers for a chat
71/// completion. `token`, if present, becomes `Authorization: Bearer …`.
72pub 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                                    // OpenAI requires arguments as a JSON *string*.
127                                    "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                // OpenAI has no error flag on tool messages; prefix so the
138                // model still sees that this observation was an error.
139                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
146/// Parse an OpenAI `/chat/completions` response body into the neutral
147/// [`Response`]. Tolerant: missing usage → zero; unknown finish reason →
148/// [`StopReason::Other`]; tool-call arguments that aren't valid JSON are
149/// wrapped as `{"_raw": "…"}` rather than dropped.
150pub 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    // Surface an OpenAI-style error object clearly.
155    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        // A reasoning model (gpt-5*/o-series) must use max_completion_tokens.
248        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        // An older / self-hosted model keeps the classic max_tokens (gpt-x here).
254        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    // --- RFC 0018 §5.4 model discovery -------------------------------------
304
305    #[test]
306    fn models_path_is_sibling_of_chat_path() {
307        // Canonical: /v1/chat/completions → /v1/models.
308        assert_eq!(models_path("/v1/chat/completions"), "/v1/models");
309        // A gateway mounted at a non-default root keeps its prefix.
310        assert_eq!(
311            models_path("/proxy/v1/chat/completions"),
312            "/proxy/v1/models"
313        );
314        // Anything non-canonical falls back to the absolute path.
315        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        // Non-JSON → [].
330        assert!(parse_models(b"<html>404</html>").is_empty());
331        // JSON without a `data` array → [].
332        assert!(parse_models(br#"{"error":{"message":"nope"}}"#).is_empty());
333        // `data` present but entries without an `id` → [].
334        assert!(parse_models(br#"{"data":[{"object":"model"}]}"#).is_empty());
335        // Empty list → [].
336        assert!(parse_models(br#"{"data":[]}"#).is_empty());
337    }
338}