Skip to main content

agent_framework_foundry_local/
convert.rs

1//! Conversion between framework types and Foundry Local's OpenAI-compatible
2//! `/v1/chat/completions` wire format.
3//!
4//! Foundry Local's OpenAI-compatibility layer
5//! (<https://learn.microsoft.com/en-us/azure/ai-foundry/foundry-local/reference/reference-catalog-api#openai-compatibility>)
6//! speaks the *exact* same JSON shapes as OpenAI's Chat Completions API for
7//! everything this crate needs: `messages`, `tools`/`tool_choice`, the scalar
8//! sampling options, the non-streaming response envelope, `usage`, and the
9//! `chat.completion.chunk` SSE delta shape. So request-body assembly and
10//! response/usage parsing are reused verbatim from
11//! [`agent_framework_openai::convert`] rather than duplicated (mirrors how
12//! `agent-framework-ollama` reuses the same module, and for the same reason).
13//! Only the streaming-delta parser is reimplemented locally, kept small on
14//! purpose, so it can be unit-tested here over fixture JSON without going
15//! through a live `reqwest::Response`.
16//!
17//! Known gaps versus upstream OpenAI, reflecting real Foundry Local server
18//! behavior: no `refusal` field, no `completion_tokens_details` /
19//! `prompt_tokens_details` breakdown (harmless — `parse_usage` simply finds
20//! nothing there), and no `system_fingerprint`. None of these affect the
21//! fields this crate reads.
22
23use std::collections::HashMap;
24
25use agent_framework_core::types::{
26    ChatOptions, Content, FinishReason, FunctionArguments, FunctionCallContent, Message, Role,
27    TextContent, UsageContent,
28};
29use serde_json::{json, Map, Value};
30
31/// Build the `/v1/chat/completions` request body.
32///
33/// `model` is the effective model id (already resolved from
34/// `options.model` or the client default). Message/tool/option conversion is
35/// delegated to [`agent_framework_openai::convert`] since Foundry Local's
36/// OpenAI compatibility layer accepts the identical shapes.
37pub fn build_request(
38    messages: &[Message],
39    options: &ChatOptions,
40    model: &str,
41    stream: bool,
42) -> Value {
43    let mut body = Map::new();
44    body.insert("model".into(), json!(model));
45    body.insert(
46        "messages".into(),
47        json!(agent_framework_openai::convert::messages_to_openai(
48            messages
49        )),
50    );
51    agent_framework_openai::convert::apply_options(&mut body, options);
52    let (tools, tool_choice) = agent_framework_openai::convert::tools_to_openai(options);
53    if let Some(tools) = tools {
54        body.insert("tools".into(), tools);
55    }
56    if let Some(choice) = tool_choice {
57        body.insert("tool_choice".into(), choice);
58    }
59    if stream {
60        body.insert("stream".into(), json!(true));
61        // Foundry Local honors `stream_options.include_usage` the same way
62        // OpenAI does: the final chunk carries a top-level `usage` object.
63        body.insert("stream_options".into(), json!({ "include_usage": true }));
64    }
65    Value::Object(body)
66}
67
68/// Parse one streamed `chat.completion.chunk` value into an update, resolving
69/// tool-call ids from the index map (a streamed tool-call delta only carries
70/// its `id` on the first chunk; later chunks reference it by `index`).
71///
72/// A near-identical shape to `agent-framework-openai`'s private `parse_delta`
73/// (Foundry Local's SSE chunks are byte-for-byte the same shape) —
74/// reimplemented locally, rather than reused, purely so it is directly
75/// unit-testable here over fixture JSON without a live HTTP response.
76pub fn parse_delta(
77    value: &Value,
78    tool_ids: &mut HashMap<i64, String>,
79) -> Option<agent_framework_core::types::ChatResponseUpdate> {
80    let mut update = agent_framework_core::types::ChatResponseUpdate {
81        response_id: value.get("id").and_then(Value::as_str).map(String::from),
82        model: value.get("model").and_then(Value::as_str).map(String::from),
83        ..Default::default()
84    };
85
86    let mut contents: Vec<Content> = Vec::new();
87
88    if let Some(choice) = value
89        .get("choices")
90        .and_then(|c| c.as_array())
91        .and_then(|a| a.first())
92    {
93        if let Some(delta) = choice.get("delta") {
94            if let Some(r) = delta.get("role").and_then(Value::as_str) {
95                update.role = Some(Role::new(r));
96            }
97            if let Some(text) = delta.get("content").and_then(Value::as_str) {
98                if !text.is_empty() {
99                    contents.push(Content::Text(TextContent::new(text)));
100                }
101            }
102            if let Some(calls) = delta.get("tool_calls").and_then(Value::as_array) {
103                for call in calls {
104                    let index = call.get("index").and_then(Value::as_i64).unwrap_or(0);
105                    let chunk_id = call.get("id").and_then(Value::as_str).unwrap_or_default();
106                    let id = if chunk_id.is_empty() {
107                        tool_ids.get(&index).cloned().unwrap_or_default()
108                    } else {
109                        tool_ids.insert(index, chunk_id.to_string());
110                        chunk_id.to_string()
111                    };
112                    let func = call.get("function");
113                    let name = func
114                        .and_then(|f| f.get("name"))
115                        .and_then(Value::as_str)
116                        .unwrap_or_default()
117                        .to_string();
118                    let args = func
119                        .and_then(|f| f.get("arguments"))
120                        .and_then(Value::as_str)
121                        .unwrap_or_default()
122                        .to_string();
123                    contents.push(Content::FunctionCall(FunctionCallContent::new(
124                        id,
125                        name,
126                        Some(FunctionArguments::Raw(args)),
127                    )));
128                }
129            }
130        }
131        if let Some(fr) = choice.get("finish_reason").and_then(Value::as_str) {
132            update.finish_reason = Some(FinishReason::new(fr));
133        }
134    }
135
136    // The final chunk (with `stream_options.include_usage`) carries top-level
137    // `usage` and no choices.
138    if let Some(usage) = value.get("usage").filter(|u| u.is_object()) {
139        contents.push(Content::Usage(UsageContent {
140            details: agent_framework_openai::convert::parse_usage(usage),
141        }));
142    }
143
144    if update.role.is_none() {
145        update.role = Some(Role::assistant());
146    }
147    update.contents = contents;
148    Some(update)
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use agent_framework_core::tools::{ApprovalMode, ToolDefinition, ToolKind};
155    use agent_framework_core::types::ToolMode;
156
157    fn function_tool(name: &str) -> ToolDefinition {
158        ToolDefinition {
159            name: name.into(),
160            description: "desc".into(),
161            parameters: json!({ "type": "object", "properties": {} }),
162            kind: ToolKind::Function,
163            approval_mode: ApprovalMode::NeverRequire,
164            executor: None,
165        }
166    }
167
168    // region: request building
169
170    #[test]
171    fn build_request_includes_model_and_messages() {
172        let body = build_request(
173            &[Message::user("hi")],
174            &ChatOptions::new(),
175            "phi-3.5-mini",
176            false,
177        );
178        assert_eq!(body["model"], json!("phi-3.5-mini"));
179        assert_eq!(
180            body["messages"],
181            json!([{ "role": "user", "content": "hi" }])
182        );
183        assert!(body.get("stream").is_none());
184    }
185
186    #[test]
187    fn build_request_applies_temperature_and_max_tokens() {
188        let mut options = ChatOptions::new();
189        options.temperature = Some(0.4);
190        options.max_tokens = Some(256);
191        let body = build_request(&[Message::user("hi")], &options, "phi-3.5-mini", false);
192        assert_eq!(body["temperature"], json!(0.4_f32));
193        assert_eq!(body["max_tokens"], json!(256));
194    }
195
196    #[test]
197    fn build_request_sets_stream_and_include_usage() {
198        let body = build_request(
199            &[Message::user("hi")],
200            &ChatOptions::new(),
201            "phi-3.5-mini",
202            true,
203        );
204        assert_eq!(body["stream"], json!(true));
205        assert_eq!(body["stream_options"], json!({ "include_usage": true }));
206    }
207
208    #[test]
209    fn build_request_includes_tools_and_tool_choice() {
210        let mut options = ChatOptions::new();
211        options.tools = vec![function_tool("get_weather")];
212        options.tool_choice = Some(ToolMode::Auto);
213        let body = build_request(
214            &[Message::user("weather?")],
215            &options,
216            "phi-3.5-mini",
217            false,
218        );
219        assert_eq!(body["tools"][0]["function"]["name"], json!("get_weather"));
220        assert_eq!(body["tool_choice"], json!("auto"));
221    }
222
223    // endregion
224
225    // region: response / usage parsing (reused from agent-framework-openai,
226    // exercised here against Foundry Local-shaped fixtures)
227
228    #[test]
229    fn parses_foundry_local_chat_completion_response() {
230        let value = json!({
231            "id": "chatcmpl-123",
232            "model": "phi-3.5-mini",
233            "choices": [{
234                "message": { "role": "assistant", "content": "Hello there!" },
235                "finish_reason": "stop",
236            }],
237            "usage": { "prompt_tokens": 12, "completion_tokens": 4, "total_tokens": 16 },
238        });
239        let resp = agent_framework_openai::convert::parse_response(&value);
240        assert_eq!(resp.text(), "Hello there!");
241        assert_eq!(resp.response_id.as_deref(), Some("chatcmpl-123"));
242        assert_eq!(resp.finish_reason, Some(FinishReason::stop()));
243        let usage = resp.usage_details.unwrap();
244        assert_eq!(usage.input_token_count, Some(12));
245        assert_eq!(usage.output_token_count, Some(4));
246        assert_eq!(usage.total_token_count, Some(16));
247    }
248
249    #[test]
250    fn parses_foundry_local_tool_call_response() {
251        let value = json!({
252            "id": "chatcmpl-456",
253            "model": "phi-3.5-mini",
254            "choices": [{
255                "message": {
256                    "role": "assistant",
257                    "content": null,
258                    "tool_calls": [{
259                        "id": "call_1",
260                        "type": "function",
261                        "function": { "name": "get_weather", "arguments": "{\"city\":\"Paris\"}" },
262                    }],
263                },
264                "finish_reason": "tool_calls",
265            }],
266        });
267        let resp = agent_framework_openai::convert::parse_response(&value);
268        let calls = resp.function_calls();
269        assert_eq!(calls.len(), 1);
270        assert_eq!(calls[0].call_id, "call_1");
271        assert_eq!(calls[0].name, "get_weather");
272        assert_eq!(resp.finish_reason, Some(FinishReason::new("tool_calls")));
273    }
274
275    // endregion
276
277    // region: streaming delta parsing
278
279    #[test]
280    fn parse_delta_accumulates_text() {
281        let mut tool_ids = HashMap::new();
282        let value = json!({
283            "id": "chatcmpl-1",
284            "model": "phi-3.5-mini",
285            "choices": [{ "delta": { "role": "assistant", "content": "Hi" }, "finish_reason": null }],
286        });
287        let update = parse_delta(&value, &mut tool_ids).unwrap();
288        assert_eq!(update.response_id.as_deref(), Some("chatcmpl-1"));
289        assert_eq!(update.role, Some(Role::assistant()));
290        match &update.contents[0] {
291            Content::Text(t) => assert_eq!(t.text, "Hi"),
292            other => panic!("expected text content, got {other:?}"),
293        }
294    }
295
296    #[test]
297    fn parse_delta_resolves_tool_call_id_across_chunks() {
298        let mut tool_ids = HashMap::new();
299        let first = json!({
300            "choices": [{
301                "delta": { "tool_calls": [{ "index": 0, "id": "call_1", "function": { "name": "get_weather", "arguments": "{\"ci" } }] },
302            }],
303        });
304        let update1 = parse_delta(&first, &mut tool_ids).unwrap();
305        let Content::FunctionCall(fc1) = &update1.contents[0] else {
306            panic!("expected function call content");
307        };
308        assert_eq!(fc1.call_id, "call_1");
309        assert_eq!(fc1.name, "get_weather");
310
311        // Continuation chunk carries only the index, no id/name.
312        let second = json!({
313            "choices": [{
314                "delta": { "tool_calls": [{ "index": 0, "function": { "arguments": "ty\":\"Paris\"}" } }] },
315            }],
316        });
317        let update2 = parse_delta(&second, &mut tool_ids).unwrap();
318        let Content::FunctionCall(fc2) = &update2.contents[0] else {
319            panic!("expected function call content");
320        };
321        assert_eq!(fc2.call_id, "call_1");
322        assert_eq!(fc2.name, "");
323    }
324
325    #[test]
326    fn parse_delta_finish_reason_and_trailing_usage_chunk() {
327        let mut tool_ids = HashMap::new();
328        let value = json!({
329            "choices": [{ "delta": {}, "finish_reason": "stop" }],
330        });
331        let update = parse_delta(&value, &mut tool_ids).unwrap();
332        assert_eq!(update.finish_reason, Some(FinishReason::stop()));
333
334        let usage_only = json!({
335            "id": "chatcmpl-1",
336            "usage": { "prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8 },
337        });
338        let update = parse_delta(&usage_only, &mut tool_ids).unwrap();
339        match &update.contents[0] {
340            Content::Usage(u) => {
341                assert_eq!(u.details.input_token_count, Some(5));
342                assert_eq!(u.details.output_token_count, Some(3));
343            }
344            other => panic!("expected usage content, got {other:?}"),
345        }
346    }
347
348    #[test]
349    fn parse_delta_defaults_role_to_assistant_when_absent() {
350        let mut tool_ids = HashMap::new();
351        let value = json!({ "choices": [{ "delta": { "content": "x" } }] });
352        let update = parse_delta(&value, &mut tool_ids).unwrap();
353        assert_eq!(update.role, Some(Role::assistant()));
354    }
355
356    // endregion
357}