agent_framework_foundry_local/
convert.rs1use 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
31pub 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 body.insert("stream_options".into(), json!({ "include_usage": true }));
64 }
65 Value::Object(body)
66}
67
68pub 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 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 #[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 #[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 #[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 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 }