Skip to content

fix(openrouter): merge fragmented reasoning_details in streaming#36401

Open
Xi Zhang (X-iZhang) wants to merge 4 commits intolangchain-ai:masterfrom
X-iZhang:fix/openrouter-reasoning-details-streaming
Open

fix(openrouter): merge fragmented reasoning_details in streaming#36401
Xi Zhang (X-iZhang) wants to merge 4 commits intolangchain-ai:masterfrom
X-iZhang:fix/openrouter-reasoning-details-streaming

Conversation

@X-iZhang
Copy link
Copy Markdown

Description

Fixes #36400

During streaming, AIMessageChunk.__add__ list-concatenates reasoning_details in additional_kwargs, fragmenting a single entry into many. When _convert_message_to_dict() serializes conversation history back to the OpenRouter API for the next turn, these fragmented entries cause BadRequestResponseError.

Changes

  • Add _merge_reasoning_details() helper that merges consecutive entries sharing the same type and index (streaming fragments) while preserving distinct entries (legitimate non-streaming data)
  • Metadata from later fragments (e.g. signature) is preserved in the merged result
  • Entries without index are never merged (safe for non-streaming responses)
  • Call _merge_reasoning_details() in _convert_message_to_dict() before serializing reasoning_details

Why merge instead of drop?

Non-streaming users (invoke()) rely on reasoning_details for structured metadata (type, signature, format, index). Dropping it entirely would be a regression. This approach fixes streaming while preserving non-streaming functionality, similar to langchain-openai's _implode_reasoning_blocks().

Test plan

  • Fragmented entries (same type + same index) are merged into one
  • Distinct entries (different index) are preserved separately
  • Entries without index are never merged
  • Metadata from later fragments (e.g. signature) is preserved
  • Single-entry lists pass through unchanged
  • Round-trip (dict → message → dict) works correctly
  • All 210 unit tests pass

…i-turn

During streaming, AIMessageChunk.__add__ list-concatenates reasoning_details
in additional_kwargs, fragmenting a single entry into many. When
_convert_message_to_dict() serializes conversation history back to the
OpenRouter API, these fragments cause BadRequestResponseError.

Add _merge_reasoning_details() that merges consecutive entries sharing the
same type and index (streaming fragments) while preserving distinct entries
(legitimate non-streaming data). Metadata from later fragments (e.g.
signature) is preserved in the merged result.

Closes langchain-ai#36400
@github-actions github-actions Bot added fix For PRs that implement a fix integration PR made that is related to a provider partner package integration openrouter `langchain-openrouter` package issues & PRs size: S 50-199 LOC labels Mar 31, 2026
@github-actions

This comment has been minimized.

@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented Mar 31, 2026

Merging this PR will not alter performance

✅ 1 untouched benchmark
⏩ 39 skipped benchmarks1


Comparing X-iZhang:fix/openrouter-reasoning-details-streaming (963f361) with master (3b4cd75)2

Open in CodSpeed

Footnotes

  1. 39 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on master (453c4d8) during the generation of this report, so 3b4cd75 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@github-actions github-actions Bot added size: M 200-499 LOC and removed size: S 50-199 LOC labels Mar 31, 2026
@mdrxy Mason Daugherty (mdrxy) changed the title fix(openrouter): merge fragmented reasoning_details in streaming multi-turn fix(openrouter): merge fragmented reasoning_details in streaming Apr 4, 2026
Comment on lines +1163 to +1165
text_key = (
"text" if "text" in entry else "content" if "content" in entry else None
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

i think this can be refactored, too much nested if else, i know this could be very pythonic but not very much readable, ur call?

Comment on lines +1154 to +1195
while i < len(details):
entry = details[i]
if not isinstance(entry, dict):
merged.append(entry)
i += 1
continue

entry_type = entry.get("type", "")
entry_index = entry.get("index")
text_key = (
"text" if "text" in entry else "content" if "content" in entry else None
)

# Only merge if entry has both a text field and an index.
# Without index we cannot distinguish fragments from distinct entries.
if text_key is None or entry_index is None:
merged.append(entry)
i += 1
continue

# Merge consecutive fragments (same type + same index)
texts = [entry.get(text_key, "")]
base = {k: v for k, v in entry.items() if k != text_key}
i += 1
while i < len(details):
nxt = details[i]
if (
isinstance(nxt, dict)
and nxt.get("type") == entry_type
and nxt.get("index") == entry_index
):
texts.append(nxt.get(text_key, "") or "")
# Preserve metadata from later fragments (e.g. signature)
base.update(
{k: v for k, v in nxt.items() if k != text_key and v is not None}
)
i += 1
else:
break

base[text_key] = "".join(texts)
merged.append(base)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

wow this is such a long while loop, i was wondering where this while loop ends

Comment on lines +1178 to +1190
while i < len(details):
nxt = details[i]
if (
isinstance(nxt, dict)
and nxt.get("type") == entry_type
and nxt.get("index") == entry_index
):
texts.append(nxt.get(text_key, "") or "")
# Preserve metadata from later fragments (e.g. signature)
base.update(
{k: v for k, v in nxt.items() if k != text_key and v is not None}
)
i += 1
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

can this be decomposed into another function or so otherwise with 2 while loops, i already lost focus to understand what exactly is it doing. ur cal?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

.

@X-iZhang
Copy link
Copy Markdown
Author

Hi Mason Daugherty (@mdrxy), just wanted to check if this is on your radar. Happy to make any changes if needed. Thanks!

@mdrxy
Copy link
Copy Markdown
Member

Thank, hoping to review soon

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

external fix For PRs that implement a fix integration PR made that is related to a provider partner package integration new-contributor openrouter `langchain-openrouter` package issues & PRs size: M 200-499 LOC

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(openrouter): streaming reasoning_details fragmentation causes multi-turn BadRequestResponseError

3 participants