Skip to main content

agent_framework_github_copilot/
convert.rs

1//! Conversion between framework types and GitHub Copilot's OpenAI-compatible
2//! `/chat/completions` wire format.
3//!
4//! Once authenticated with an exchanged Copilot API token, GitHub Copilot's
5//! chat endpoint speaks the *exact* same JSON shapes as OpenAI's Chat
6//! Completions API for everything this crate needs: `messages`,
7//! `tools`/`tool_choice`, the scalar sampling options, the non-streaming
8//! response envelope, `usage`, and the `chat.completion.chunk` SSE delta
9//! shape. So request-body assembly and response/usage parsing are reused
10//! verbatim from [`agent_framework_openai::convert`] rather than duplicated
11//! (mirrors how `agent-framework-ollama` and `agent-framework-foundry-local`
12//! reuse the same module, and for the same reason). Only the streaming-delta
13//! parser is reimplemented locally, kept small on purpose, so it can be
14//! unit-tested here over fixture JSON without going through a live
15//! `reqwest::Response`.
16//!
17//! This module has nothing to do with the GitHub OAuth-token-to-Copilot-token
18//! exchange (see the crate root for that) — it only converts chat
19//! request/response bodies, after authentication has already produced a
20//! usable Copilot bearer token.
21
22use 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
30/// Build the `/chat/completions` request body.
31///
32/// `model` is the effective model id (already resolved from
33/// `options.model` or the client default). Message/tool/option conversion is
34/// delegated to [`agent_framework_openai::convert`] since GitHub Copilot's
35/// chat endpoint accepts the identical shapes.
36pub 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        // Copilot's chat endpoint honors `stream_options.include_usage` the
61        // same way OpenAI does: the final chunk carries a top-level `usage`
62        // 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/// (Copilot's SSE chunks are byte-for-byte the same shape) — reimplemented
74/// locally, rather than reused, purely so it is directly unit-testable here
75/// 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(&[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    // endregion
209
210    // region: response / usage parsing (reused from agent-framework-openai,
211    // exercised here against Copilot-shaped fixtures)
212
213    #[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    // endregion
261
262    // region: streaming delta parsing
263
264    #[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        // Continuation chunk carries only the index, no id/name.
297        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    // endregion
342}