diff --git a/libs/core/langchain_core/language_models/_compat_bridge.py b/libs/core/langchain_core/language_models/_compat_bridge.py index 1118bdcd599..505db37f6bc 100644 --- a/libs/core/langchain_core/language_models/_compat_bridge.py +++ b/libs/core/langchain_core/language_models/_compat_bridge.py @@ -258,6 +258,17 @@ def _accumulate(state: CompatBlock | None, delta: CompatBlock) -> CompatBlock: state["text"] = state.get("text", "") + delta.get("text", "") elif btype == "reasoning" and dtype == "reasoning": state["reasoning"] = state.get("reasoning", "") + delta.get("reasoning", "") + # Providers may ship non-text fields on later deltas. Claude's + # `signature_delta` arrives after the reasoning text, surfaced + # as `extras.signature`; merging (not replacing) keeps earlier + # keys intact. + for key, value in delta.items(): + if key in ("type", "reasoning") or value is None: + continue + if key == "extras" and isinstance(value, dict): + state["extras"] = {**(state.get("extras") or {}), **value} + else: + state[key] = value elif btype in ("tool_call_chunk", "server_tool_call_chunk") and dtype == btype: state["args"] = state.get("args", "") + (delta.get("args") or "") if delta.get("id") is not None: diff --git a/libs/core/langchain_core/language_models/chat_model_stream.py b/libs/core/langchain_core/language_models/chat_model_stream.py index 5899531a599..b36000fed1d 100644 --- a/libs/core/langchain_core/language_models/chat_model_stream.py +++ b/libs/core/langchain_core/language_models/chat_model_stream.py @@ -703,9 +703,16 @@ class ChatModelStream: full_r = reasoning_block.get("reasoning", "") if full_r and full_r != self._reasoning_acc: self._reasoning_acc = full_r + # Keep provider-specific fields alongside the accumulated + # reasoning text. Anthropic's `signature` arrives under + # `extras` and is required on follow-up turns. finalized = cast( "FinalizedContentBlock", - {"type": "reasoning", "reasoning": self._reasoning_acc}, + { + **reasoning_block, + "type": "reasoning", + "reasoning": self._reasoning_acc, + }, ) elif btype == "tool_call": tcb = cast("ToolCallBlock", block) diff --git a/libs/core/tests/unit_tests/language_models/test_compat_bridge.py b/libs/core/tests/unit_tests/language_models/test_compat_bridge.py index 55a69b66f4b..cfbc9f6f6eb 100644 --- a/libs/core/tests/unit_tests/language_models/test_compat_bridge.py +++ b/libs/core/tests/unit_tests/language_models/test_compat_bridge.py @@ -814,6 +814,45 @@ def test_lifecycle_validator_anthropic_style_text_and_thinking() -> None: assert finishes[1]["content_block"]["text"] == "The answer is 42." +def test_lifecycle_validator_anthropic_reasoning_preserves_signature() -> None: + """A later reasoning delta's `extras.signature` must land on the finish block. + + Anthropic emits reasoning content as `thinking_delta` events (text), + followed by a `signature_delta` event carrying the cryptographic + signature that the API requires on any follow-up turn. After the + content-block-start/delta translation, that signature arrives as + `extras.signature` on a reasoning delta that has no new text. If + the bridge drops it, Claude rejects the next request with + `messages..content..thinking.signature: Field required`. + """ + chunks = [ + _aimsg_chunk([{"type": "reasoning", "reasoning": "Let me think", "index": 0}]), + _aimsg_chunk([{"type": "reasoning", "reasoning": " more", "index": 0}]), + # signature_delta arrives after the text; no new reasoning text + # but carries the signature under `extras`. + _aimsg_chunk( + [ + { + "type": "reasoning", + "reasoning": "", + "index": 0, + "extras": {"signature": "sig-abc123"}, + } + ] + ), + _aimsg_chunk([{"type": "text", "text": "Hi.", "index": 1}]), + ] + + events = list(chunks_to_events(iter(chunks), message_id="m")) + assert_valid_event_stream(events) + + finishes: list[Any] = [e for e in events if e["event"] == "content-block-finish"] + reasoning_finish = finishes[0]["content_block"] + assert reasoning_finish["type"] == "reasoning" + assert reasoning_finish["reasoning"] == "Let me think more" + assert reasoning_finish.get("extras", {}).get("signature") == "sig-abc123" + + def test_lifecycle_validator_anthropic_style_tool_use_after_text() -> None: """Text then tool_use (tool_call_chunk) — Anthropic tool-calling pattern.""" chunks = [