Skip to content

Commit ef7448d

Browse files
authored
Surface OpenAI-compatible reasoning_content as TextReasoningContent (#7295)
1 parent d7930d4 commit ef7448d

File tree

2 files changed

+127
-0
lines changed

2 files changed

+127
-0
lines changed

src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,13 @@ internal static async IAsyncEnumerable<ChatResponseUpdate> FromOpenAIStreamingCh
374374
ConvertContentParts(update.ContentUpdate, responseUpdate.Contents);
375375
}
376376

377+
// Check for reasoning content from OpenAI-compatible endpoints (e.g. DeepSeek, vLLM, OpenRouter)
378+
// that surface it via non-standard fields in the response JSON.
379+
if (TryGetReasoningDelta(update, out string? reasoningText))
380+
{
381+
responseUpdate.Contents.Add(new TextReasoningContent(reasoningText));
382+
}
383+
377384
if (update.OutputAudioUpdate is { } audioUpdate)
378385
{
379386
responseUpdate.Contents.Add(new DataContent(audioUpdate.AudioBytesUpdate.ToMemory(), GetOutputAudioMimeType(options))
@@ -493,6 +500,13 @@ internal static ChatResponse FromOpenAIChatCompletion(ChatCompletion openAICompl
493500
}
494501
}
495502

503+
// Check for reasoning content from OpenAI-compatible endpoints (e.g. DeepSeek, vLLM, OpenRouter)
504+
// that surface it via non-standard fields in the response JSON.
505+
if (TryGetReasoningMessage(openAICompletion, out string? reasoningText))
506+
{
507+
returnMessage.Contents.Add(new TextReasoningContent(reasoningText));
508+
}
509+
496510
// Output audio is handled separately from message content parts.
497511
if (openAICompletion.OutputAudio is ChatOutputAudio audio)
498512
{
@@ -822,6 +836,16 @@ private sealed class FunctionCallInfo
822836
public StringBuilder? Arguments;
823837
}
824838

839+
#pragma warning disable SCME0001 // JsonPatch is experimental
840+
/// <summary>Tries to extract reasoning text from a streaming chat completion update's Patch.</summary>
841+
private static bool TryGetReasoningDelta(StreamingChatCompletionUpdate update, [NotNullWhen(true)] out string? reasoningText)
842+
=> update.Patch.TryGetValue("$.choices[0].delta.reasoning_content"u8, out reasoningText) && reasoningText is not null;
843+
844+
/// <summary>Tries to extract reasoning text from a non-streaming chat completion's Patch.</summary>
845+
private static bool TryGetReasoningMessage(ChatCompletion completion, [NotNullWhen(true)] out string? reasoningText)
846+
=> completion.Patch.TryGetValue("$.choices[0].message.reasoning_content"u8, out reasoningText) && reasoningText is not null;
847+
#pragma warning restore SCME0001
848+
825849
private const string InvalidAuthorNamePattern = @"[^a-zA-Z0-9_]+";
826850
#if NET
827851
[GeneratedRegex(InvalidAuthorNamePattern)]

test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1907,4 +1907,107 @@ public async Task ReasoningOptions_None_ProducesNoReasoningEffortInJson()
19071907
Reasoning = new ReasoningOptions { Effort = ReasoningEffort.None }
19081908
}));
19091909
}
1910+
1911+
[Fact]
1912+
public async Task ReasoningContent_NonStreaming_SurfacedAsTextReasoningContent()
1913+
{
1914+
const string Input = """
1915+
{
1916+
"messages":[{"role":"user","content":"hello"}],
1917+
"model":"gpt-oss-120b"
1918+
}
1919+
""";
1920+
1921+
const string Output = """
1922+
{
1923+
"id": "c48a440c7dd64389b7fbe908e006ba3d",
1924+
"object": "chat.completion",
1925+
"created": 1770959477,
1926+
"model": "gpt-oss-120b",
1927+
"choices": [
1928+
{
1929+
"index": 0,
1930+
"message": {
1931+
"role": "assistant",
1932+
"content": "9.8 is larger.",
1933+
"reasoning_content": "We just compare decimals: 9.11 vs 9.8. 9.8 > 9.11. Answer briefly."
1934+
},
1935+
"finish_reason": "stop"
1936+
}
1937+
],
1938+
"usage": {
1939+
"prompt_tokens": 84,
1940+
"completion_tokens": 44,
1941+
"total_tokens": 128
1942+
}
1943+
}
1944+
""";
1945+
1946+
using VerbatimHttpHandler handler = new(Input, Output);
1947+
using HttpClient httpClient = new(handler);
1948+
using IChatClient client = CreateChatClient(httpClient, "gpt-oss-120b");
1949+
1950+
var response = await client.GetResponseAsync("hello");
1951+
Assert.NotNull(response);
1952+
1953+
var message = response.Messages.Single();
1954+
var reasoning = message.Contents.OfType<TextReasoningContent>().Single();
1955+
Assert.Equal("We just compare decimals: 9.11 vs 9.8. 9.8 > 9.11. Answer briefly.", reasoning.Text);
1956+
1957+
var text = message.Contents.OfType<TextContent>().Single();
1958+
Assert.Equal("9.8 is larger.", text.Text);
1959+
}
1960+
1961+
[Fact]
1962+
public async Task ReasoningContent_Streaming_SurfacedAsTextReasoningContent()
1963+
{
1964+
// Streaming format captured from Azure gpt-oss-120b. Reasoning chunks have reasoning_content with content:null,
1965+
// then content chunks have content with reasoning_content:null.
1966+
const string Input = """
1967+
{
1968+
"messages":[{"role":"user","content":"hello"}],
1969+
"model":"gpt-oss-120b",
1970+
"stream":true,
1971+
"stream_options":{"include_usage":true}
1972+
}
1973+
""";
1974+
1975+
const string Output = """
1976+
data: {"id":"381fb75e8a1f451f8a579c9da104b739","object":"chat.completion.chunk","created":1770959485,"model":"gpt-oss-120b","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning_content":null},"finish_reason":null}]}
1977+
1978+
data: {"id":"381fb75e8a1f451f8a579c9da104b739","object":"chat.completion.chunk","created":1770959485,"model":"gpt-oss-120b","choices":[{"index":0,"delta":{"content":null,"reasoning_content":"User asks"},"finish_reason":null}]}
1979+
1980+
data: {"id":"381fb75e8a1f451f8a579c9da104b739","object":"chat.completion.chunk","created":1770959485,"model":"gpt-oss-120b","choices":[{"index":0,"delta":{"content":null,"reasoning_content":": which"},"finish_reason":null}]}
1981+
1982+
data: {"id":"381fb75e8a1f451f8a579c9da104b739","object":"chat.completion.chunk","created":1770959485,"model":"gpt-oss-120b","choices":[{"index":0,"delta":{"content":null,"reasoning_content":" is larger."},"finish_reason":null}]}
1983+
1984+
data: {"id":"381fb75e8a1f451f8a579c9da104b739","object":"chat.completion.chunk","created":1770959485,"model":"gpt-oss-120b","choices":[{"index":0,"delta":{"content":"9","reasoning_content":null},"finish_reason":null}]}
1985+
1986+
data: {"id":"381fb75e8a1f451f8a579c9da104b739","object":"chat.completion.chunk","created":1770959485,"model":"gpt-oss-120b","choices":[{"index":0,"delta":{"content":".8 is larger.","reasoning_content":null},"finish_reason":null}]}
1987+
1988+
data: {"id":"381fb75e8a1f451f8a579c9da104b739","object":"chat.completion.chunk","created":1770959485,"model":"gpt-oss-120b","choices":[{"index":0,"delta":{"content":null,"reasoning_content":null},"finish_reason":"stop"}]}
1989+
1990+
data: {"id":"381fb75e8a1f451f8a579c9da104b739","object":"chat.completion.chunk","created":1770959485,"model":"gpt-oss-120b","choices":[],"usage":{"completion_tokens":46,"prompt_tokens":84,"total_tokens":130}}
1991+
1992+
data: [DONE]
1993+
1994+
""";
1995+
1996+
using VerbatimHttpHandler handler = new(Input, Output);
1997+
using HttpClient httpClient = new(handler);
1998+
using IChatClient client = CreateChatClient(httpClient, "gpt-oss-120b");
1999+
2000+
List<ChatResponseUpdate> updates = [];
2001+
await foreach (var update in client.GetStreamingResponseAsync("hello"))
2002+
{
2003+
updates.Add(update);
2004+
}
2005+
2006+
// Verify reasoning content was captured from the reasoning_content deltas
2007+
string reasoningText = string.Concat(updates.SelectMany(u => u.Contents).OfType<TextReasoningContent>().Select(r => r.Text));
2008+
Assert.Equal("User asks: which is larger.", reasoningText);
2009+
2010+
// Verify regular content was also captured from the content deltas
2011+
Assert.Equal("9.8 is larger.", string.Concat(updates.Select(u => u.Text)));
2012+
}
19102013
}

0 commit comments

Comments
 (0)