Skip to content

fix(openai): keep Chat Completions reasoning on its assistant message - #1669

Merged
seratch merged 3 commits into
openai:mainfrom
thegoodengineer:fix/chat-completions-reasoning-message
Aug 15, 2026
Merged

seratch merged 3 commits into
openai:mainfrom
thegoodengineer:fix/chat-completions-reasoning-message

Conversation

@thegoodengineer

@thegoodengineer thegoodengineer commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Chat Completions responses that carry reasoning do not round trip. Providers that return reasoning on the assistant message get it recorded as a standalone reasoning item placed before the assistant message item, and on replay the converter turns that one provider message back into two assistant messages, with the reasoning on the one that has no content.

Captured live against OpenRouter and deepseek/deepseek-r1 on @openai/[email protected]. Provider response, choices[0].message:

{
  "role": "assistant",
  "content": "The capital of France is **Paris**.",
  "refusal": null,
  "reasoning": "Okay, the user asked a straightforward question: [truncated]",
  "reasoning_details": [ { "type": "reasoning.text", "text": "[truncated]", "format": "unknown", "index": 0 } ]
}

Second turn request from the same run:

[
  { "role": "user", "content": "What is the capital of France?" },
  { "role": "assistant", "content": null, "reasoning": "[truncated]" },
  { "role": "assistant", "content": [ { "type": "text", "text": "The capital of France is **Paris**." } ] },
  { "role": "user", "content": "And Germany?" }
]

It compounds, because a reasoning item is added per turn. Assistant messages in each outgoing request over five turns:

turn | assistant msgs | contentless (content: null, no tool_calls)
  1  |       0        |      0
  2  |       2        |      1
  3  |       4        |      2
  4  |       6        |      3
  5  |       8        |      4

So a reasoning model on the Chat Completions path currently replays twice the assistant messages it needs, growing with conversation length, and the caller pays input tokens for the empty ones. The same applies to the streaming path, which produces the same history items.

OpenRouter documents passing reasoning back on the assistant message, via message.reasoning or message.reasoning_details, to preserve reasoning continuity across calls: https://openrouter.ai/docs/guides/best-practices/reasoning-tokens

This is not an OpenAI Chat Completions field, and the SDK already targets it deliberately on both sides. hasReasoningContent() in openaiChatCompletionsModel.ts reads message.reasoning, the streaming converter accumulates delta.reasoning, and ChatCompletionAssistantMessageWithReasoning exists in the converter so it can be written back out. The read path and the write path just do not line up.

Fix

When the pending assistant message was created only by a reasoning item, hand its reasoning to the assistant message that immediately follows instead of flushing it as a separate turn:

[
  { "role": "user", "content": "What is the capital of France?" },
  {
    "role": "assistant",
    "content": [ { "type": "text", "text": "The capital of France is **Paris**." } ],
    "reasoning": "[truncated]"
  },
  { "role": "user", "content": "And Germany?" }
]

19 lines in one function. No public API change.

Behavior that is deliberately unchanged, and now covered by tests:

  • Reasoning followed by a tool call still shares a single assistant message, which is how tool call turns already replayed.
  • A trailing reasoning item with no following assistant message is still emitted on its own.
  • Reasoning is never merged into a following user or system message.
  • providerData.reasoning on the assistant message still wins, since provider data is spread last.
  • Only the reasoning item directly before an assistant message is merged into it, so a later assistant message is unaffected.

A reasoning item with empty rawContent no longer produces an assistant message with neither content nor reasoning.

Out of scope here, happy to follow up if wanted: reasoning_details is not carried onto the assistant message at all, and the nested copy of reasoning inside the text content part comes from the existing providerData passthrough in extractAllAssistantContent.

Test plan

Six cases in packages/agents-openai/test/openaiChatCompletionsConverter.test.ts. Four fail on main and pass with the change; two are guards that pass in both states so the unchanged paths stay pinned:

  • keeps reasoning on the assistant message it belongs to
  • drops an empty reasoning item instead of emitting an empty assistant turn
  • does not steal reasoning from a previous assistant message
  • does not accumulate contentless assistant messages across turns
  • keeps reasoning with the tool call message when a tool call follows (guard)
  • does not attach reasoning to a following user message (guard)

Commands run locally on Windows:

  • pnpm build (0 errors)
  • pnpm test for packages/agents-openai: 21 files, 665 tests passing
  • full pnpm test: the only failures are pre-existing on this platform and unrelated to this change (the sandbox suites that require POSIX path separators, and the RunState fixture hash check that trips on CRLF checkouts)
  • pnpm lint
  • pnpm -r build-check

Issue number

N/A

Checks

  • I've added new tests (if relevant)
  • I've added/updated the relevant documentation
  • I've run pnpm test and pnpm test:examples
    • (If you made a major change) I've run pnpm test:integration (see details)
  • I've run .agents/skills/code-change-verification/scripts/run.sh
  • I've confirmed all verification steps pass
  • If using Codex, I've run /review before submitting this PR
  • I've added a changeset using pnpm changeset to indicate my changes

Chat Completions providers that return reasoning on the assistant
message are converted into a reasoning item followed by the assistant
message item. Replaying that history emitted a separate assistant
message with no content and no tool calls, and detached the reasoning
from the message it described.

Hand a reasoning-only pending assistant message to the assistant
message that immediately follows it instead.
@changeset-bot

changeset-bot Bot commented Aug 14, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 65b550b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@openai/agents-openai Patch
@openai/agents Patch
@openai/agents-core Patch
@openai/agents-realtime Patch
@openai/agents-extensions Patch
realtime-react-native Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@seratch

seratch commented Aug 14, 2026

Copy link
Copy Markdown
Member

The converter behavior is internally plausible, but we need evidence that this exact Chat Completions-compatible path occurs with a real provider before adding permanent replay behavior.

Could you share the provider and model, the raw first-turn assistant message containing reasoning, the exact second-turn request produced by the current SDK, and the provider error or incorrect observable result? Please also confirm that the same reproduction fails on the latest released SDK version. OpenAI’s Chat Completions API does not define an assistant reasoning message field, and the existing Anthropic reports used a different AI SDK adapter path, so those do not establish the need for this converter change.

With a real-provider reproduction, we can reconsider this as a narrow round-trip fix.

The reasoning-only assistant message was added once per reasoning turn,
so a five turn conversation replayed eight assistant messages where four
were enough. Pin the count and the reasoning placement across turns.
@thegoodengineer

Copy link
Copy Markdown
Contributor Author

Fair ask. One correction first: I wrote that providers reject the payload. I had no basis for that and I withdraw it. No provider error is involved. Here is the live capture instead, and I have rewritten the description around it.

Provider: OpenRouter, deepseek/deepseek-r1, on @openai/[email protected].

Raw first-turn assistant message, off the wire:

{
  "role": "assistant",
  "content": "The capital of France is **Paris**.",
  "refusal": null,
  "reasoning": "Okay, the user asked a straightforward question: [truncated]",
  "reasoning_details": [ { "type": "reasoning.text", "text": "[truncated]", "format": "unknown", "index": 0 } ]
}

Second turn request from the same run:

[
  { "role": "user", "content": "What is the capital of France?" },
  { "role": "assistant", "content": null, "reasoning": "[truncated]" },
  { "role": "assistant", "content": [ { "type": "text", "text": "The capital of France is **Paris**." } ] },
  { "role": "user", "content": "And Germany?" }
]

The observable result. No error, OpenRouter answered normally both turns. What is wrong is that one provider message replays as two, the reasoning sits on the message with content: null, and it compounds, since a reasoning item is added per turn. Assistant messages per outgoing request over five turns:

turn | assistant msgs | contentless (content: null, no tool_calls)
  1  |       0        |      0
  2  |       2        |      1
  3  |       4        |      2
  4  |       6        |      3
  5  |       8        |      4

With the patch: 0, 1, 2, 3, 4 and never a contentless one. Same on the streaming path, which produces the same history items. So today a reasoning model on Chat Completions replays twice the assistant messages it needs, growing with conversation length, and the caller pays input tokens on the empty half.

OpenRouter documents passing reasoning back on the assistant message, message.reasoning or message.reasoning_details, for reasoning continuity across calls: https://openrouter.ai/docs/guides/best-practices/reasoning-tokens

You are right that this is not an OpenAI field. The SDK already targets it deliberately on both sides though. hasReasoningContent() reads message.reasoning, the streaming converter accumulates delta.reasoning, and ChatCompletionAssistantMessageWithReasoning exists so it can be written back. The read and write paths just do not line up, which is all this PR changes: 19 lines in one function, no public API change, six tests, four of which fail on main.

Repro if you want to rerun it, needs an OpenRouter key and nothing else:

import OpenAI from 'openai';
import { Agent, run, MemorySession, setTracingDisabled, OpenAIChatCompletionsModel } from '@openai/agents';

setTracingDisabled(true);

const client = new OpenAI({
  apiKey: process.env.OPENROUTER_API_KEY,
  baseURL: 'https://openrouter.ai/api/v1',
});

const requests = [];
const responses = [];
const create = client.chat.completions.create.bind(client.chat.completions);
client.chat.completions.create = async (body, options) => {
  requests.push(structuredClone(body));
  const response = await create(body, options);
  responses.push(structuredClone(response));
  return response;
};

const agent = new Agent({
  name: 'Assistant',
  instructions: 'Answer in one short sentence.',
  model: new OpenAIChatCompletionsModel(client, 'deepseek/deepseek-r1'),
  modelSettings: { providerData: { include_reasoning: true }, maxTokens: 1200 },
});
const session = new MemorySession();

await run(agent, 'What is the capital of France?', { session });
await run(agent, 'And Germany?', { session });

console.log(JSON.stringify(responses[0].choices[0].message, null, 2));
console.log(JSON.stringify(requests[1].messages, null, 2));

I kept the change narrow on purpose since that is what you asked for. reasoning_details is visible in the trace above and is not carried onto the assistant message at all today. Say the word if you want that in this PR and I will add it, otherwise I will leave it as a separate follow up.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 65b550bbf6

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

An unknown item was pushed straight into the message list while an
assistant message was still pending, so it landed out of history order
and let a preceding reasoning item attach to an assistant message on the
far side of it. Flush the pending assistant message first.

Raised by Codex review on openai#1669.
@seratch seratch modified the milestones: 0.15.x, 0.16.x Aug 15, 2026

@seratch seratch left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed this works better with various model providers on openrouter

@seratch
seratch enabled auto-merge (squash) August 15, 2026 02:32
@seratch
seratch merged commit 70a766e into openai:main Aug 15, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants