agent_framework_github_copilot/
convert.rs1use std::collections::HashMap;
23
24use agent_framework_core::types::{
25 ChatOptions, Content, FinishReason, FunctionArguments, FunctionCallContent, Message, Role,
26 TextContent, UsageContent,
27};
28use serde_json::{json, Map, Value};
29
30pub fn build_request(
37 messages: &[Message],
38 options: &ChatOptions,
39 model: &str,
40 stream: bool,
41) -> Value {
42 let mut body = Map::new();
43 body.insert("model".into(), json!(model));
44 body.insert(
45 "messages".into(),
46 json!(agent_framework_openai::convert::messages_to_openai(
47 messages
48 )),
49 );
50 agent_framework_openai::convert::apply_options(&mut body, options);
51 let (tools, tool_choice) = agent_framework_openai::convert::tools_to_openai(options);
52 if let Some(tools) = tools {
53 body.insert("tools".into(), tools);
54 }
55 if let Some(choice) = tool_choice {
56 body.insert("tool_choice".into(), choice);
57 }
58 if stream {
59 body.insert("stream".into(), json!(true));
60 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(&[Message::user("hi")], &ChatOptions::new(), "gpt-4o", false);
173 assert_eq!(body["model"], json!("gpt-4o"));
174 assert_eq!(
175 body["messages"],
176 json!([{ "role": "user", "content": "hi" }])
177 );
178 assert!(body.get("stream").is_none());
179 }
180
181 #[test]
182 fn build_request_applies_temperature_and_max_tokens() {
183 let mut options = ChatOptions::new();
184 options.temperature = Some(0.4);
185 options.max_tokens = Some(256);
186 let body = build_request(&[Message::user("hi")], &options, "gpt-4o", false);
187 assert_eq!(body["temperature"], json!(0.4_f32));
188 assert_eq!(body["max_tokens"], json!(256));
189 }
190
191 #[test]
192 fn build_request_sets_stream_and_include_usage() {
193 let body = build_request(&[Message::user("hi")], &ChatOptions::new(), "gpt-4o", true);
194 assert_eq!(body["stream"], json!(true));
195 assert_eq!(body["stream_options"], json!({ "include_usage": true }));
196 }
197
198 #[test]
199 fn build_request_includes_tools_and_tool_choice() {
200 let mut options = ChatOptions::new();
201 options.tools = vec![function_tool("get_weather")];
202 options.tool_choice = Some(ToolMode::Auto);
203 let body = build_request(&[Message::user("weather?")], &options, "gpt-4o", false);
204 assert_eq!(body["tools"][0]["function"]["name"], json!("get_weather"));
205 assert_eq!(body["tool_choice"], json!("auto"));
206 }
207
208 #[test]
214 fn parses_copilot_chat_completion_response() {
215 let value = json!({
216 "id": "chatcmpl-123",
217 "model": "gpt-4o",
218 "choices": [{
219 "message": { "role": "assistant", "content": "Hello there!" },
220 "finish_reason": "stop",
221 }],
222 "usage": { "prompt_tokens": 12, "completion_tokens": 4, "total_tokens": 16 },
223 });
224 let resp = agent_framework_openai::convert::parse_response(&value);
225 assert_eq!(resp.text(), "Hello there!");
226 assert_eq!(resp.response_id.as_deref(), Some("chatcmpl-123"));
227 assert_eq!(resp.finish_reason, Some(FinishReason::stop()));
228 let usage = resp.usage_details.unwrap();
229 assert_eq!(usage.input_token_count, Some(12));
230 assert_eq!(usage.output_token_count, Some(4));
231 assert_eq!(usage.total_token_count, Some(16));
232 }
233
234 #[test]
235 fn parses_copilot_tool_call_response() {
236 let value = json!({
237 "id": "chatcmpl-456",
238 "model": "gpt-4o",
239 "choices": [{
240 "message": {
241 "role": "assistant",
242 "content": null,
243 "tool_calls": [{
244 "id": "call_1",
245 "type": "function",
246 "function": { "name": "get_weather", "arguments": "{\"city\":\"Paris\"}" },
247 }],
248 },
249 "finish_reason": "tool_calls",
250 }],
251 });
252 let resp = agent_framework_openai::convert::parse_response(&value);
253 let calls = resp.function_calls();
254 assert_eq!(calls.len(), 1);
255 assert_eq!(calls[0].call_id, "call_1");
256 assert_eq!(calls[0].name, "get_weather");
257 assert_eq!(resp.finish_reason, Some(FinishReason::new("tool_calls")));
258 }
259
260 #[test]
265 fn parse_delta_accumulates_text() {
266 let mut tool_ids = HashMap::new();
267 let value = json!({
268 "id": "chatcmpl-1",
269 "model": "gpt-4o",
270 "choices": [{ "delta": { "role": "assistant", "content": "Hi" }, "finish_reason": null }],
271 });
272 let update = parse_delta(&value, &mut tool_ids).unwrap();
273 assert_eq!(update.response_id.as_deref(), Some("chatcmpl-1"));
274 assert_eq!(update.role, Some(Role::assistant()));
275 match &update.contents[0] {
276 Content::Text(t) => assert_eq!(t.text, "Hi"),
277 other => panic!("expected text content, got {other:?}"),
278 }
279 }
280
281 #[test]
282 fn parse_delta_resolves_tool_call_id_across_chunks() {
283 let mut tool_ids = HashMap::new();
284 let first = json!({
285 "choices": [{
286 "delta": { "tool_calls": [{ "index": 0, "id": "call_1", "function": { "name": "get_weather", "arguments": "{\"ci" } }] },
287 }],
288 });
289 let update1 = parse_delta(&first, &mut tool_ids).unwrap();
290 let Content::FunctionCall(fc1) = &update1.contents[0] else {
291 panic!("expected function call content");
292 };
293 assert_eq!(fc1.call_id, "call_1");
294 assert_eq!(fc1.name, "get_weather");
295
296 let second = json!({
298 "choices": [{
299 "delta": { "tool_calls": [{ "index": 0, "function": { "arguments": "ty\":\"Paris\"}" } }] },
300 }],
301 });
302 let update2 = parse_delta(&second, &mut tool_ids).unwrap();
303 let Content::FunctionCall(fc2) = &update2.contents[0] else {
304 panic!("expected function call content");
305 };
306 assert_eq!(fc2.call_id, "call_1");
307 assert_eq!(fc2.name, "");
308 }
309
310 #[test]
311 fn parse_delta_finish_reason_and_trailing_usage_chunk() {
312 let mut tool_ids = HashMap::new();
313 let value = json!({
314 "choices": [{ "delta": {}, "finish_reason": "stop" }],
315 });
316 let update = parse_delta(&value, &mut tool_ids).unwrap();
317 assert_eq!(update.finish_reason, Some(FinishReason::stop()));
318
319 let usage_only = json!({
320 "id": "chatcmpl-1",
321 "usage": { "prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8 },
322 });
323 let update = parse_delta(&usage_only, &mut tool_ids).unwrap();
324 match &update.contents[0] {
325 Content::Usage(u) => {
326 assert_eq!(u.details.input_token_count, Some(5));
327 assert_eq!(u.details.output_token_count, Some(3));
328 }
329 other => panic!("expected usage content, got {other:?}"),
330 }
331 }
332
333 #[test]
334 fn parse_delta_defaults_role_to_assistant_when_absent() {
335 let mut tool_ids = HashMap::new();
336 let value = json!({ "choices": [{ "delta": { "content": "x" } }] });
337 let update = parse_delta(&value, &mut tool_ids).unwrap();
338 assert_eq!(update.role, Some(Role::assistant()));
339 }
340
341 }