< Summary

Information
Class: NexusLabs.Needlr.Copilot.CopilotChatClient
Assembly: NexusLabs.Needlr.Copilot
File(s): /home/runner/work/needlr/needlr/src/NexusLabs.Needlr.Copilot/CopilotChatClient.cs
Line coverage
75%
Covered lines: 244
Uncovered lines: 79
Coverable lines: 323
Total lines: 689
Line coverage: 75.5%
Branch coverage
63%
Covered branches: 154
Total branches: 244
Branch coverage: 63.1%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)50%22100%
.ctor(...)66.66%66100%
get_Metadata()100%11100%
GetResponseAsync()50%44100%
GetStreamingResponseAsync()87.5%575694.54%
GetService(...)0%620%
Dispose()100%44100%
BuildRequest(...)53.84%272688.23%
MapMessages(...)75%242496.36%
MapTools(...)0%2040%
SendRequestAsync()58.33%151272.22%
GetRetryDelay(...)0%110100%
MapToChatResponse(...)77.77%363693.02%
MapToStreamingUpdate(...)45.23%3354245%
MapFinishReason(...)70%141066.66%
SerializeToolResult(...)37.5%22840%

File(s)

/home/runner/work/needlr/needlr/src/NexusLabs.Needlr.Copilot/CopilotChatClient.cs

#LineLine coverage
 1using System.Net.Http.Headers;
 2using System.Runtime.CompilerServices;
 3using System.Text;
 4using System.Text.Json;
 5
 6using Microsoft.Extensions.AI;
 7
 8namespace NexusLabs.Needlr.Copilot;
 9
 10/// <summary>
 11/// <see cref="IChatClient"/> implementation that calls the GitHub Copilot chat completions
 12/// API directly (raw model endpoint, no CLI agent harness). Only tools explicitly provided
 13/// via <see cref="ChatOptions.Tools"/> are sent; no built-in Copilot CLI tools are injected.
 14/// </summary>
 15/// <remarks>
 16/// <para>
 17/// Authentication follows a two-step flow: a GitHub OAuth token (discovered from
 18/// <c>apps.json</c>, environment variables, or an explicit value) is exchanged for a
 19/// short-lived Copilot API bearer token via the internal GitHub API endpoint.
 20/// </para>
 21/// <para>
 22/// Plug this into Needlr's agent framework via
 23/// <c>.UsingChatClient(new CopilotChatClient())</c> — no Copilot-specific
 24/// syringe extensions required.
 25/// </para>
 26/// </remarks>
 27/// <example>
 28/// <code>
 29/// // Minimal usage — auto-discovers token from Copilot CLI login:
 30/// IChatClient client = new CopilotChatClient();
 31///
 32/// // With explicit model and token:
 33/// IChatClient client = new CopilotChatClient(new CopilotChatClientOptions
 34/// {
 35///     DefaultModel = "gpt-5.4",
 36///     GitHubToken = "gho_xxx",
 37/// });
 38/// </code>
 39/// </example>
 40public sealed class CopilotChatClient : IChatClient
 41{
 42    private readonly ICopilotTokenProvider _tokenProvider;
 43    private readonly HttpClient _httpClient;
 44    private readonly CopilotChatClientOptions _options;
 45    private readonly bool _ownsHttpClient;
 46
 47    /// <summary>
 48    /// Creates a new <see cref="CopilotChatClient"/> with optional configuration and HTTP client.
 49    /// Token discovery uses <see cref="CopilotChatClientOptions.TokenSource"/>.
 50    /// </summary>
 51    /// <param name="options">Configuration options. Uses defaults when <c>null</c>.</param>
 52    /// <param name="httpClient">
 53    /// Optional HTTP client (shared with token provider). Created internally if <c>null</c>.
 54    /// Pass a pre-configured <see cref="HttpClient"/> to control timeout and other HTTP settings.
 55    /// The default <see cref="HttpClient.Timeout"/> (100 seconds) may be too short for long-running
 56    /// agent pipelines — consider increasing it for workloads with large context windows.
 57    /// </param>
 58    /// <example>
 59    /// <code>
 60    /// // Default timeout (100s) — suitable for short interactions
 61    /// var client = new CopilotChatClient(options);
 62    ///
 63    /// // Extended timeout for pipeline workloads with large context windows
 64    /// var httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
 65    /// var client = new CopilotChatClient(options, httpClient);
 66    /// </code>
 67    /// </example>
 68    public CopilotChatClient(CopilotChatClientOptions? options = null, HttpClient? httpClient = null)
 1269        : this(
 1270            new CopilotTokenProvider(options ?? new CopilotChatClientOptions(), httpClient),
 1271            options,
 1272            httpClient)
 73    {
 1274    }
 75
 76    /// <summary>
 77    /// Creates a new <see cref="CopilotChatClient"/> with a custom token provider.
 78    /// </summary>
 79    /// <param name="tokenProvider">Supplies Copilot API bearer tokens.</param>
 80    /// <param name="options">Configuration options. Uses defaults when <c>null</c>.</param>
 81    /// <param name="httpClient">
 82    /// Optional HTTP client. Created internally if <c>null</c>. Pass a pre-configured
 83    /// <see cref="HttpClient"/> to control <see cref="HttpClient.Timeout"/> and other
 84    /// HTTP settings for long-running agent pipelines.
 85    /// </param>
 1286    public CopilotChatClient(
 1287        ICopilotTokenProvider tokenProvider,
 1288        CopilotChatClientOptions? options = null,
 1289        HttpClient? httpClient = null)
 90    {
 1291        _tokenProvider = tokenProvider ?? throw new ArgumentNullException(nameof(tokenProvider));
 1292        _options = options ?? new CopilotChatClientOptions();
 1293        _ownsHttpClient = httpClient is null;
 1294        _httpClient = httpClient ?? new HttpClient();
 1295    }
 96
 97    /// <inheritdoc />
 198    public ChatClientMetadata Metadata => new("github-copilot");
 99
 100    /// <inheritdoc />
 101    public async Task<ChatResponse> GetResponseAsync(
 102        IEnumerable<ChatMessage> chatMessages,
 103        ChatOptions? options = null,
 104        CancellationToken cancellationToken = default)
 105    {
 6106        var messageList = chatMessages as IList<ChatMessage> ?? chatMessages.ToList();
 6107        var request = BuildRequest(messageList, options, stream: false);
 6108        using var httpResponse = await SendRequestAsync(request, cancellationToken).ConfigureAwait(false);
 109
 6110        var body = await httpResponse.Content
 6111            .ReadAsStringAsync(cancellationToken)
 6112            .ConfigureAwait(false);
 113
 6114        var response = JsonSerializer.Deserialize(body, CopilotJsonContext.Default.ChatCompletionResponse)
 6115            ?? throw new InvalidOperationException("Copilot API returned null response.");
 116
 6117        return MapToChatResponse(response, options);
 6118    }
 119
 120    /// <inheritdoc />
 121    public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
 122        IEnumerable<ChatMessage> chatMessages,
 123        ChatOptions? options = null,
 124        [EnumeratorCancellation] CancellationToken cancellationToken = default)
 125    {
 5126        var messageList = chatMessages as IList<ChatMessage> ?? chatMessages.ToList();
 5127        var request = BuildRequest(messageList, options, stream: true);
 5128        using var httpResponse = await SendRequestAsync(request, cancellationToken).ConfigureAwait(false);
 129
 5130        using var stream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
 5131        using var reader = new StreamReader(stream, Encoding.UTF8);
 132
 133        // Accumulate streaming tool call chunks by index. The OpenAI streaming
 134        // protocol sends tool calls incrementally: the first chunk for an index
 135        // carries the id and function name, subsequent chunks append argument
 136        // fragments. We buffer these and emit complete FunctionCallContent items
 137        // only on the final chunk (when finish_reason is "tool_calls" or the
 138        // stream ends).
 5139        var pendingToolCalls = new Dictionary<int, (string Id, string Name, StringBuilder Args)>();
 140
 141        while (true)
 142        {
 39143            cancellationToken.ThrowIfCancellationRequested();
 144
 39145            var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
 146
 39147            if (line is null)
 148            {
 149                break;
 150            }
 151
 39152            if (string.IsNullOrWhiteSpace(line) || line.StartsWith(':'))
 153            {
 154                continue;
 155            }
 156
 22157            if (!line.StartsWith("data: ", StringComparison.Ordinal))
 158            {
 159                continue;
 160            }
 161
 22162            var data = line["data: ".Length..];
 163
 22164            if (data is "[DONE]")
 165            {
 166                break;
 167            }
 168
 169            ChatCompletionChunk? chunk;
 170            try
 171            {
 17172                chunk = JsonSerializer.Deserialize(data, CopilotJsonContext.Default.ChatCompletionChunk);
 16173            }
 1174            catch (JsonException)
 175            {
 1176                continue;
 177            }
 178
 16179            if (chunk is null)
 180            {
 181                continue;
 182            }
 183
 184            // Accumulate tool call fragments
 16185            var choice = chunk.Choices.FirstOrDefault();
 16186            if (choice?.Delta?.ToolCalls is { Count: > 0 })
 187            {
 40188                foreach (var tc in choice.Delta.ToolCalls)
 189                {
 11190                    if (tc.Function is null) continue;
 191
 11192                    if (!pendingToolCalls.TryGetValue(tc.Index, out var pending))
 193                    {
 4194                        pending = (tc.Id ?? "", tc.Function.Name ?? "", new StringBuilder());
 4195                        pendingToolCalls[tc.Index] = pending;
 196                    }
 197
 11198                    if (!string.IsNullOrEmpty(tc.Function.Arguments))
 199                    {
 7200                        pending.Args.Append(tc.Function.Arguments);
 201                    }
 202
 203                    // Update with id/name if this chunk carries them
 11204                    if (!string.IsNullOrEmpty(tc.Id))
 205                    {
 4206                        pendingToolCalls[tc.Index] = pending with { Id = tc.Id };
 207                    }
 11208                    if (!string.IsNullOrEmpty(tc.Function.Name))
 209                    {
 4210                        pendingToolCalls[tc.Index] = pending with { Name = tc.Function.Name };
 211                    }
 212                }
 213            }
 214
 215            // Emit completed tool calls when finish_reason signals completion
 16216            if (choice?.FinishReason == "tool_calls" && pendingToolCalls.Count > 0)
 217            {
 3218                var update = new ChatResponseUpdate
 3219                {
 3220                    ModelId = chunk.Model,
 3221                    CreatedAt = chunk.Created > 0 ? DateTimeOffset.FromUnixTimeSeconds(chunk.Created) : null,
 3222                    FinishReason = ChatFinishReason.ToolCalls,
 3223                    Role = ChatRole.Assistant,
 3224                };
 225
 226                foreach (var (_, tc) in pendingToolCalls.OrderBy(kvp => kvp.Key))
 227                {
 4228                    IDictionary<string, object?>? args = null;
 4229                    var argsJson = tc.Args.ToString();
 4230                    if (!string.IsNullOrEmpty(argsJson))
 231                    {
 232                        try
 233                        {
 4234                            args = JsonSerializer.Deserialize<Dictionary<string, object?>>(
 4235                                argsJson, CopilotJsonContext.Default.Options);
 4236                        }
 0237                        catch (JsonException)
 238                        {
 0239                            args = new Dictionary<string, object?> { ["_raw"] = argsJson };
 0240                        }
 241                    }
 242
 4243                    update.Contents.Add(new FunctionCallContent(tc.Id, tc.Name, args));
 244                }
 245
 3246                pendingToolCalls.Clear();
 3247                yield return update;
 248                continue;
 249            }
 250
 251            // Emit non-tool-call updates normally (text content, usage, etc.)
 13252            var normalUpdate = MapToStreamingUpdate(chunk, skipToolCalls: true);
 13253            if (normalUpdate is not null)
 254            {
 13255                yield return normalUpdate;
 256            }
 257        }
 5258    }
 259
 260    /// <inheritdoc />
 261    public object? GetService(Type serviceType, object? key = null)
 262    {
 0263        if (serviceType == typeof(IChatClient))
 264        {
 0265            return this;
 266        }
 267
 0268        return null;
 269    }
 270
 271    /// <inheritdoc />
 272    public void Dispose()
 273    {
 12274        if (_ownsHttpClient)
 275        {
 1276            _httpClient.Dispose();
 277        }
 278
 12279        if (_tokenProvider is IDisposable disposable)
 280        {
 12281            disposable.Dispose();
 282        }
 12283    }
 284
 285    private ChatCompletionRequest BuildRequest(
 286        IList<ChatMessage> messages,
 287        ChatOptions? options,
 288        bool stream)
 289    {
 11290        var model = options?.ModelId ?? _options.DefaultModel;
 291
 11292        var request = new ChatCompletionRequest
 11293        {
 11294            Model = model,
 11295            Messages = MapMessages(messages),
 11296            Stream = stream,
 11297            Temperature = options?.Temperature,
 11298            TopP = options?.TopP,
 11299            MaxTokens = options?.MaxOutputTokens,
 11300            FrequencyPenalty = options?.FrequencyPenalty,
 11301            PresencePenalty = options?.PresencePenalty,
 11302        };
 303
 11304        if (options?.StopSequences is { Count: > 0 } stops)
 305        {
 0306            request.Stop = [.. stops];
 307        }
 308
 11309        if (options?.Tools is { Count: > 0 } tools)
 310        {
 0311            request.Tools = MapTools(tools);
 312        }
 313
 11314        return request;
 315    }
 316
 317    private static List<RequestMessage> MapMessages(IList<ChatMessage> messages)
 318    {
 11319        var result = new List<RequestMessage>(messages.Count);
 320
 52321        foreach (var msg in messages)
 322        {
 15323            var role = msg.Role.Value switch
 15324            {
 0325                "system" => "system",
 11326                "user" => "user",
 2327                "assistant" => "assistant",
 2328                "tool" => "tool",
 0329                _ => msg.Role.Value,
 15330            };
 331
 332            // Handle tool result messages — one RequestMessage per FunctionResultContent.
 333            // MAF may pack multiple tool results into a single ChatMessage when the model
 334            // made parallel tool calls. The Copilot API (OpenAI format) requires a separate
 335            // "tool" message for each tool_call_id.
 15336            var functionResults = msg.Contents.OfType<FunctionResultContent>()
 4337                .Where(fr => !string.IsNullOrEmpty(fr.CallId))
 15338                .ToList();
 15339            if (functionResults.Count > 0)
 340            {
 12341                foreach (var fr in functionResults)
 342                {
 4343                    result.Add(new RequestMessage
 4344                    {
 4345                        Role = role,
 4346                        Content = SerializeToolResult(fr.Result),
 4347                        ToolCallId = fr.CallId ?? "",
 4348                    });
 349                }
 350
 351                continue;
 352            }
 353
 354            // Handle assistant messages with tool calls
 13355            var functionCalls = msg.Contents.OfType<FunctionCallContent>()
 6356                .Where(fc => !string.IsNullOrEmpty(fc.Name))
 13357                .ToList();
 13358            if (functionCalls.Count > 0)
 359            {
 2360                var textContent = string.Join("", msg.Contents
 2361                    .OfType<TextContent>()
 2362                    .Select(t => t.Text));
 363
 2364                result.Add(new RequestMessage
 2365                {
 2366                    Role = role,
 2367                    Content = string.IsNullOrEmpty(textContent) ? null : textContent,
 4368                    ToolCalls = functionCalls.Select(fc => new RequestToolCall
 4369                    {
 4370                        Id = fc.CallId ?? "",
 4371                        Type = "function",
 4372                        Function = new RequestToolCallFunction
 4373                        {
 4374                            Name = fc.Name,
 4375                            Arguments = fc.Arguments is not null
 4376                                ? JsonSerializer.Serialize(fc.Arguments, CopilotJsonContext.Default.Options)
 4377                                : "{}",
 4378                        },
 4379                    }).ToList(),
 2380                });
 2381                continue;
 382            }
 383
 384            // Regular text message
 11385            var text = string.Join("", msg.Contents
 11386                .OfType<TextContent>()
 22387                .Select(t => t.Text));
 388
 11389            result.Add(new RequestMessage
 11390            {
 11391                Role = role,
 11392                Content = text,
 11393            });
 394        }
 395
 11396        return result;
 397    }
 398
 399    private static List<RequestTool> MapTools(IList<AITool> tools)
 400    {
 0401        var result = new List<RequestTool>(tools.Count);
 402
 0403        foreach (var tool in tools)
 404        {
 0405            if (tool is not AIFunction func)
 406            {
 407                continue;
 408            }
 409
 0410            var parameters = func.JsonSchema is { } schema
 0411                ? JsonSerializer.Deserialize<object>(schema.GetRawText())
 0412                : null;
 413
 0414            result.Add(new RequestTool
 0415            {
 0416                Type = "function",
 0417                Function = new RequestToolFunction
 0418                {
 0419                    Name = func.Name,
 0420                    Description = func.Description,
 0421                    Parameters = parameters,
 0422                },
 0423            });
 424        }
 425
 0426        return result;
 427    }
 428
 429    private async Task<HttpResponseMessage> SendRequestAsync(
 430        ChatCompletionRequest request,
 431        CancellationToken cancellationToken)
 432    {
 11433        var token = await _tokenProvider.GetTokenAsync(cancellationToken).ConfigureAwait(false);
 11434        var url = $"{_options.CopilotApiBaseUrl.TrimEnd('/')}/chat/completions";
 435
 11436        for (int attempt = 0; ; attempt++)
 437        {
 11438            var jsonBody = JsonSerializer.Serialize(
 11439                request, CopilotJsonContext.Default.ChatCompletionRequest);
 440
 11441            using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
 11442            {
 11443                Content = new StringContent(jsonBody, Encoding.UTF8, "application/json"),
 11444            };
 445
 11446            httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
 11447            httpRequest.Headers.Add("Accept", request.Stream ? "text/event-stream" : "application/json");
 11448            httpRequest.Headers.Add("Copilot-Integration-Id", _options.IntegrationId);
 11449            httpRequest.Headers.Add("Editor-Version", _options.EditorVersion);
 11450            httpRequest.Headers.Add("Editor-Plugin-Version", "needlr-copilot/1.0.0");
 11451            httpRequest.Headers.Add("X-GitHub-Api-Version", "2025-05-01");
 11452            httpRequest.Headers.Add("Openai-Intent", "conversation-agent");
 11453            httpRequest.Headers.Add("X-Interaction-Type", "conversation-agent");
 11454            httpRequest.Headers.Add("X-Initiator", "user");
 11455            httpRequest.Headers.UserAgent.ParseAdd(_options.IntegrationId);
 456
 11457            var httpResponse = await _httpClient.SendAsync(
 11458                httpRequest,
 11459                request.Stream ? HttpCompletionOption.ResponseHeadersRead : HttpCompletionOption.ResponseContentRead,
 11460                cancellationToken).ConfigureAwait(false);
 461
 11462            if (httpResponse.IsSuccessStatusCode)
 463            {
 11464                return httpResponse;
 465            }
 466
 0467            if (httpResponse.StatusCode == System.Net.HttpStatusCode.TooManyRequests
 0468                && attempt < _options.MaxRetries)
 469            {
 0470                var delay = GetRetryDelay(httpResponse, attempt);
 0471                httpResponse.Dispose();
 0472                await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
 0473                continue;
 474            }
 475
 0476            var errorBody = await httpResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
 0477            httpResponse.Dispose();
 0478            throw new HttpRequestException(
 0479                $"Copilot API request failed ({httpResponse.StatusCode}): {errorBody}");
 480        }
 11481    }
 482
 483    private TimeSpan GetRetryDelay(HttpResponseMessage response, int attempt)
 484    {
 0485        if (response.Headers.RetryAfter?.Delta is { } delta)
 486        {
 0487            return delta;
 488        }
 489
 0490        if (response.Headers.RetryAfter?.Date is { } date)
 491        {
 0492            var wait = date - DateTimeOffset.UtcNow;
 0493            if (wait > TimeSpan.Zero)
 494            {
 0495                return wait;
 496            }
 497        }
 498
 0499        var ms = _options.RetryBaseDelayMs * (1 << attempt);
 0500        return TimeSpan.FromMilliseconds(ms);
 501    }
 502
 503    private ChatResponse MapToChatResponse(ChatCompletionResponse response, ChatOptions? options)
 504    {
 6505        var messages = new List<ChatMessage>();
 506
 24507        foreach (var choice in response.Choices)
 508        {
 6509            var msg = choice.Message;
 6510            if (msg is null) continue;
 511
 6512            var chatMsg = new ChatMessage(
 6513                new ChatRole(msg.Role ?? "assistant"),
 6514                []);
 515
 6516            if (!string.IsNullOrEmpty(msg.Content))
 517            {
 5518                chatMsg.Contents.Add(new TextContent(msg.Content));
 519            }
 520
 6521            if (msg.ToolCalls is { Count: > 0 })
 522            {
 4523                foreach (var tc in msg.ToolCalls)
 524                {
 1525                    if (tc.Function is null) continue;
 526
 1527                    IDictionary<string, object?>? args = null;
 1528                    if (!string.IsNullOrEmpty(tc.Function.Arguments))
 529                    {
 530                        try
 531                        {
 1532                            args = JsonSerializer.Deserialize<Dictionary<string, object?>>(
 1533                                tc.Function.Arguments,
 1534                                CopilotJsonContext.Default.Options);
 1535                        }
 0536                        catch (JsonException)
 537                        {
 0538                            args = new Dictionary<string, object?> { ["_raw"] = tc.Function.Arguments };
 0539                        }
 540                    }
 541
 1542                    chatMsg.Contents.Add(new FunctionCallContent(
 1543                        tc.Id ?? "",
 1544                        tc.Function.Name ?? "",
 1545                        args));
 546                }
 547            }
 548
 6549            messages.Add(chatMsg);
 550        }
 551
 6552        var chatResponse = new ChatResponse(messages)
 6553        {
 6554            ModelId = response.Model ?? options?.ModelId ?? _options.DefaultModel,
 6555            FinishReason = MapFinishReason(response.Choices.FirstOrDefault()?.FinishReason),
 6556            CreatedAt = DateTimeOffset.FromUnixTimeSeconds(response.Created),
 6557        };
 558
 6559        if (response.Id is not null)
 560        {
 6561            chatResponse.AdditionalProperties ??= new AdditionalPropertiesDictionary();
 6562            chatResponse.AdditionalProperties["completion_id"] = response.Id;
 563        }
 564
 6565        if (response.Usage is { } usage)
 566        {
 2567            chatResponse.Usage = new UsageDetails
 2568            {
 2569                InputTokenCount = usage.PromptTokens,
 2570                OutputTokenCount = usage.CompletionTokens,
 2571                TotalTokenCount = usage.TotalTokens,
 2572            };
 573        }
 574
 6575        return chatResponse;
 576    }
 577
 578    private static ChatResponseUpdate? MapToStreamingUpdate(ChatCompletionChunk chunk, bool skipToolCalls = false)
 579    {
 13580        var choice = chunk.Choices.FirstOrDefault();
 13581        var delta = choice?.Delta;
 582
 13583        if (delta is null && chunk.Usage is null)
 584        {
 0585            return null;
 586        }
 587
 13588        var update = new ChatResponseUpdate
 13589        {
 13590            ModelId = chunk.Model,
 13591            CreatedAt = chunk.Created > 0 ? DateTimeOffset.FromUnixTimeSeconds(chunk.Created) : null,
 13592            FinishReason = MapFinishReason(choice?.FinishReason),
 13593            Role = delta?.Role is not null ? new ChatRole(delta.Role) : null,
 13594        };
 595
 13596        if (chunk.Id is not null)
 597        {
 13598            update.AdditionalProperties ??= new AdditionalPropertiesDictionary();
 13599            update.AdditionalProperties["completion_id"] = chunk.Id;
 600        }
 601
 13602        if (!string.IsNullOrEmpty(delta?.Content))
 603        {
 3604            update.Contents.Add(new TextContent(delta!.Content));
 605        }
 606
 13607        if (!skipToolCalls && delta?.ToolCalls is { Count: > 0 })
 608        {
 0609            foreach (var tc in delta.ToolCalls)
 610            {
 0611                if (tc.Function is null) continue;
 612
 0613                IDictionary<string, object?>? args = null;
 0614                if (!string.IsNullOrEmpty(tc.Function.Arguments))
 615                {
 616                    try
 617                    {
 0618                        args = JsonSerializer.Deserialize<Dictionary<string, object?>>(
 0619                            tc.Function.Arguments,
 0620                            CopilotJsonContext.Default.Options);
 0621                    }
 0622                    catch (JsonException)
 623                    {
 0624                        args = new Dictionary<string, object?> { ["_raw"] = tc.Function.Arguments };
 0625                    }
 626                }
 627
 0628                update.Contents.Add(new FunctionCallContent(
 0629                    tc.Id ?? "",
 0630                    tc.Function.Name ?? "",
 0631                    args));
 632            }
 633        }
 634
 13635        if (chunk.Usage is { } usage)
 636        {
 0637            update.Contents.Add(new UsageContent(new UsageDetails
 0638            {
 0639                InputTokenCount = usage.PromptTokens,
 0640                OutputTokenCount = usage.CompletionTokens,
 0641                TotalTokenCount = usage.TotalTokens,
 0642            }));
 643        }
 644
 13645        return update;
 646    }
 647
 19648    private static ChatFinishReason? MapFinishReason(string? reason) => reason switch
 19649    {
 7650        "stop" => ChatFinishReason.Stop,
 0651        "length" => ChatFinishReason.Length,
 1652        "tool_calls" => ChatFinishReason.ToolCalls,
 0653        "content_filter" => ChatFinishReason.ContentFilter,
 11654        null => null,
 0655        _ => new ChatFinishReason(reason),
 19656    };
 657
 658    /// <summary>
 659    /// Serializes a tool result for inclusion in a chat message to the API.
 660    /// <see cref="JsonElement"/> values are rendered to raw JSON text. Strings
 661    /// are returned as-is. All other types are JSON-serialized.
 662    /// </summary>
 663    private static string SerializeToolResult(object? result)
 664    {
 4665        if (result is null)
 666        {
 0667            return "";
 668        }
 669
 4670        if (result is JsonElement jsonElement)
 671        {
 0672            return jsonElement.GetRawText();
 673        }
 674
 4675        if (result is string s)
 676        {
 4677            return s;
 678        }
 679
 680        try
 681        {
 0682            return JsonSerializer.Serialize(result, result.GetType());
 683        }
 0684        catch (JsonException)
 685        {
 0686            return result.ToString() ?? "";
 687        }
 0688    }
 689}