dotnet / dotnet/extensions

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

Open
#7,479 5 comments 0 reactions 0 assignees View on GitHub
area-ai
Dominant language
C#
Stars
3.2k
Forks
894
Avg merge
1d 12h
Merged PRs (30d)
23

Description

### 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.

```c#
// ============================================================
// 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("--client", [])
{
Description = "Client to use: 'completions' or 'responses'",
DefaultValueFactory = _ => "completions",
};
clientOption.AcceptOnlyFromAmong("completions", "responses");

var messageArg = new Argument("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
{
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();

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.
```bash
$ 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.
```bash
$ 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):

```c#
private static async IAsyncEnumerable ResponsesStreamingMiddleware(
IEnumerable 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_

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.