Skip to content

OpenAI Responses API streaming responses are missing MessageID on function calls. #7479

@MaciejWarchalowski

Description

@MaciejWarchalowski

Description

When using OpenAI ResponsesClient based IChatClient, the ChatMessage with FunctionCallContent is missing MessageId. With similar configuration, the completions based IChatClient returns the MessageId as expected.

Reproduction Steps

Example with AzureOpenAIClient.

Running these 3 examples shows difference between implementations. (Make sure to backfill the Endpoint and API key).

  • dotnet demo.cs --client responses "What is the weather in London right now? Use the available tool." uses responses API and the result has no MessageID`
  • dotnet demo.cs --client chat "What is the weather in London right now? Use the available tool." uses completions API and the result has MessageID (as expected)
  • dotnet demo.cs --client responses "Hello" uses the responses API without function call, and the result has MessageID as expected.
// ============================================================
// To run:
//   dotnet demo.cs --client responses "What is the weather in London right now? Use the available tool."
//   dotnet demo.cs --client chat "What is the weather in London right now? Use the available tool."
//   dotnet demo.cs --client responses "Hello"
//
// ============================================================

// ── Package Installs ──────────────────────────────────────────────────────────

#:package Azure.AI.OpenAI@2.9.0-beta.1
#:package Microsoft.Extensions.AI@10.5.0
#:package Microsoft.Extensions.AI.OpenAI@10.5.0
#:package System.CommandLine@2.0.7

#pragma warning disable OPENAI001

using System.CommandLine;
using System.ComponentModel;
using System.ClientModel;
using System.Runtime.InteropServices;
using Azure.AI.OpenAI;
using Microsoft.Extensions.AI;
using OpenAI.Responses;

// ── Configuration ─────────────────────────────────────────────────────────────

const string Endpoint = "";
const string APIKey = "";
const string Deployment = "gpt-5-mini"; // must support reasoning + function calling

// ── Argument parsing ──────────────────────────────────────────────────────────

var clientOption = new Option<string>("--client", [])
{
    Description = "Client to use: 'completions' or 'responses'",
    DefaultValueFactory = _ => "completions",
};
clientOption.AcceptOnlyFromAmong("completions", "responses");

var messageArg = new Argument<string[]>("message")
{
    Description = "Message to send to the model",
    Arity = ArgumentArity.OneOrMore,
};

var rootCommand = new RootCommand("Azure OpenAI responses bug repro") { clientOption, messageArg };

rootCommand.SetAction(async (parseResult, cancellationToken) =>
{
    var clientType = parseResult.GetValue(clientOption) ?? "completions";
    var messageTokens = parseResult.GetValue(messageArg)!;

    // ── Client setup ─────────────────────────────────────────────────────────

    AzureOpenAIClient azureClient = new(
        new Uri(Endpoint),
        new ApiKeyCredential(APIKey));

    IChatClient chatClient = clientType == "responses"
        ? azureClient.GetResponsesClient().AsIChatClient(Deployment)
            .AsBuilder()
            .ConfigureOptions(x => x.RawRepresentationFactory = _ =>
                 new CreateResponseOptions() { StoredOutputEnabled = false })
            .Build()
        : azureClient.GetChatClient(Deployment).AsIChatClient();

    // ── Tool definition ───────────────────────────────────────────────────────
    // A simple weather tool. The prompt below is written to force the model to
    // both reason AND call this tool in the same response, which triggers the bug.

    var getWeatherTool = AIFunctionFactory.Create(
        ([Description("The city to get weather for")] string city)
            => $"It is currently 18°C and cloudy in {city}.",
        "get_weather",
        "Returns current weather conditions for a city.");

    // No UseFunctionInvocation() – we want to inspect the raw function call before
    // anything is auto-invoked.
    var options = new ChatOptions
    {
        Tools = [getWeatherTool],
    };

    // ── Debug: System info ────────────────────────────────────────────────────

    Console.WriteLine("═══ Environment ══════════════════════════════════════════════════");
    Console.WriteLine($"  Runtime   : {RuntimeInformation.FrameworkDescription}");
    Console.WriteLine($"  OS        : {RuntimeInformation.OSDescription}");
    Console.WriteLine($"  OS Arch   : {RuntimeInformation.OSArchitecture}");
    Console.WriteLine($"  Proc Arch : {RuntimeInformation.ProcessArchitecture}");
    Console.WriteLine($"  Deployment: {Deployment}");
    Console.WriteLine($"  Client    : {clientType}");
    Console.WriteLine();

    var messages = new List<ChatMessage>
    {
        new(ChatRole.User, string.Join(" ", messageTokens))
    };

    // ── Step 1: Collect the raw ChatResponseUpdate stream ─────────────────────
    // Each update is printed as it arrives.
    // Both the reasoning deltas and the function-call delta are unlabelled.

    Console.WriteLine("═══ Raw ChatResponseUpdate stream ═══════════════════════════════");
    Console.WriteLine($"  {"MessageId",-30}  Contents");
    Console.WriteLine($"  {"─────────",-30}  ────────");

    var updates = new List<ChatResponseUpdate>();

    await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options))
    {
        updates.Add(update);

        if (update.Contents.Count == 0)
        {
            Console.WriteLine($"  {(update.MessageId ?? "(null)"),-30}  [No content]");
            continue;
        }

        var contentSummary = string.Join(" + ", update.Contents.Select(DescribeContent));
        Console.WriteLine($"  {(update.MessageId ?? "(null)"),-30}  {contentSummary}");
    }

    // ── Step 2: Reconstruct via ToChatResponse() ──────────────────────────────

    Console.WriteLine();
    Console.WriteLine("═══ Reconstructed ChatResponse via ToChatResponse() ═════════════");

    ChatResponse response = updates.ToChatResponse();
    Console.WriteLine($"Total messages : {response.Messages.Count}");

    for (int i = 0; i < response.Messages.Count; i++)
    {
        ChatMessage msg = response.Messages[i];
        Console.WriteLine();
        Console.WriteLine($"  ┌─ Message[{i}]");
        Console.WriteLine($"  │  MessageId : {msg.MessageId ?? "(null)  ← ⚠ BUG?"}");
        Console.WriteLine($"  │  Role      : {msg.Role}");

        foreach (var content in msg.Contents)
        {
            switch (content)
            {
                case TextReasoningContent r:
                    Console.WriteLine($"  │  [TextReasoningContent] \"{Truncate(r.Text, 72)}\"");
                    break;

                case FunctionCallContent f:
                    Console.WriteLine($"  │  [FunctionCallContent]");
                    Console.WriteLine($"  │    Name   : {f.Name}");
                    Console.WriteLine($"  │    CallId : {f.CallId}");
                    break;

                case TextContent t:
                    Console.WriteLine($"  │  [TextContent] \"{Truncate(t.Text, 72)}\"");
                    break;

                default:
                    Console.WriteLine($"  │  [{content.GetType().Name}]");
                    break;
            }
        }

        Console.WriteLine("  └────────────────────────────────────────────────────────────");
    }

    return 0;
});

#pragma warning restore OPENAI001

return await rootCommand.Parse(args).InvokeAsync();

// ── Helpers ───────────────────────────────────────────────────────────────────

static string DescribeContent(AIContent c) => c switch
{
    TextReasoningContent r => $"Reasoning(\"{Truncate(r.Text, 45)}\")",
    FunctionCallContent f => $"FunctionCall(name={f.Name}, callId={f.CallId})",
    TextContent t => $"Text(\"{Truncate(t.Text, 45)}\")",
    _ => c.GetType().Name,
};

static string Truncate(string? s, int max) =>
    s is null ? "" : s.Length <= max ? s : s[..max] + "…";

Expected behavior

Expecting the result to have MessageId on the ChatMessage just like I would with completions API.

 $ dotnet demo.cs --client completions "What is the weather in London right now? Use the available tool."
═══ Environment ══════════════════════════════════════════════════
  Runtime   : .NET 10.0.0-rc.2.25502.107
  OS        : Ubuntu 24.04.3 LTS
  OS Arch   : Arm64
  Proc Arch : Arm64
  Deployment: gpt-5-mini
  Client    : completions

═══ Raw ChatResponseUpdate stream ═══════════════════════════════
  MessageId                       Contents
  ─────────                       ────────
                                  [No content]
  chatcmpl-DXayfSKsyyICsLlL3NGZW5CFsmJMH  [No content]
  chatcmpl-DXayfSKsyyICsLlL3NGZW5CFsmJMH  [No content]
  chatcmpl-DXayfSKsyyICsLlL3NGZW5CFsmJMH  [No content]
  chatcmpl-DXayfSKsyyICsLlL3NGZW5CFsmJMH  [No content]
  chatcmpl-DXayfSKsyyICsLlL3NGZW5CFsmJMH  [No content]
  chatcmpl-DXayfSKsyyICsLlL3NGZW5CFsmJMH  [No content]
  chatcmpl-DXayfSKsyyICsLlL3NGZW5CFsmJMH  [No content]
  chatcmpl-DXayfSKsyyICsLlL3NGZW5CFsmJMH  UsageContent
                                  FunctionCall(name=get_weather, callId=call_8V2HGOVzRkFhVcrV1piGhQdQ)

═══ Reconstructed ChatResponse via ToChatResponse() ═════════════
Total messages : 1

  ┌─ Message[0]
  │  MessageId : chatcmpl-DXayfSKsyyICsLlL3NGZW5CFsmJMH
  │  Role      : assistant
  │  [FunctionCallContent]
  │    Name   : get_weather
  │    CallId : call_8V2HGOVzRkFhVcrV1piGhQdQ
  └────────────────────────────────────────────────────────────

Actual behavior

No MessageId in the result.

 $ dotnet demo.cs --client responses "What is the weather in London right now? Use the available tool."
═══ Environment ══════════════════════════════════════════════════
  Runtime   : .NET 10.0.0-rc.2.25502.107
  OS        : Ubuntu 24.04.3 LTS
  OS Arch   : Arm64
  Proc Arch : Arm64
  Deployment: gpt-5-mini
  Client    : responses

═══ Raw ChatResponseUpdate stream ═══════════════════════════════
  MessageId                       Contents
  ─────────                       ────────
  (null)                          [No content]
  (null)                          [No content]
  (null)                          [No content]
  (null)                          [No content]
  (null)                          [No content]
  (null)                          [No content]
  (null)                          [No content]
  (null)                          [No content]
  (null)                          [No content]
  (null)                          [No content]
  (null)                          [No content]
  (null)                          FunctionCall(name=get_weather, callId=call_V7VpNFBxbUisZjPlPoInnpVT)
  (null)                          UsageContent

═══ Reconstructed ChatResponse via ToChatResponse() ═════════════
Total messages : 1

  ┌─ Message[0]
  │  MessageId : (null)  ← ⚠ BUG?
  │  Role      : assistant
  │  [FunctionCallContent]
  │    Name   : get_weather
  │    CallId : call_V7VpNFBxbUisZjPlPoInnpVT
  └────────────────────────────────────────────────────────────

Regression?

Not sure.

Known Workarounds

Temporarily experimenting with middleware based workaround (for the streaming portion):

    private static async IAsyncEnumerable<ChatResponseUpdate> ResponsesStreamingMiddleware(
        IEnumerable<ChatMessage> msgs,
        Microsoft.Extensions.AI.ChatOptions? options,
        IChatClient innerClient,
        [EnumeratorCancellation] CancellationToken ct = default)
    {
        await foreach (var update in innerClient.GetStreamingResponseAsync(msgs, options, ct).WithCancellation(ct).ConfigureAwait(false))
        {
#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
            yield return update switch
            {
                { RawRepresentation: StreamingResponseReasoningSummaryTextDeltaUpdate { ItemId: string itemId } } => WithMessageId(update, itemId), // Text delta
                { RawRepresentation: StreamingResponseOutputItemDoneUpdate { Item: ReasoningResponseItem item } } => WithMessageId(update, item.Id), // Reasoning item done
                { RawRepresentation: StreamingResponseOutputItemAddedUpdate { Item: FunctionCallResponseItem item } } => WithMessageId(update, item.Id),
                { RawRepresentation: StreamingResponseOutputItemDoneUpdate { Item: FunctionCallResponseItem item } } => WithMessageId(update, item.Id),
                _ => update
            };
#pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
        }
    }

    private static ChatResponseUpdate WithMessageId(ChatResponseUpdate update, string messageId)
    {
        if (string.IsNullOrEmpty(update.MessageId))
        {
            update = update.Clone();
            update.MessageId = messageId;
        }
        return update;
    }

Configuration

Runtime : .NET 10.0.0-rc.2.25502.107
OS : Ubuntu 24.04.3 LTS
OS Arch : Arm64
Proc Arch : Arm64
Deployment: gpt-5-mini
Microsoft.Extensions.AI@10.5.0
Microsoft.Extensions.AI.OpenAI@10.5.0

Other information

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    area-aiMicrosoft.Extensions.AI librariesbugThis issue describes a behavior which is not expected - a bug.untriaged

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions