Skip to content

Commit ed53d82

Browse files
committed
Merge remote-tracking branch 'origin/release-1.11.0' into fix/release-1.11.0-core-runtime-wheel-assert
2 parents e07dd0d + ba79861 commit ed53d82

10 files changed

Lines changed: 273 additions & 6 deletions

File tree

src/bundles/anthropic/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "lfx-anthropic"
3-
version = "0.1.0"
3+
version = "0.1.1"
44
description = "Anthropic component(s) as a standalone Langflow Extension Bundle."
55
readme = "README.md"
66
requires-python = ">=3.10,<3.15"
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""Bundle-local ChatAnthropic compatibility wrapper.
2+
3+
Keep this implementation inside ``lfx-anthropic`` so the standalone bundle
4+
continues to work with every LFX version admitted by its minor-line dependency
5+
floor. The LFX unified-model registry carries an equivalent wrapper for Agent
6+
model construction.
7+
"""
8+
9+
from typing import Any
10+
11+
from langchain_anthropic import ChatAnthropic
12+
13+
14+
def _ensure_thinking_field(payload: dict) -> None:
15+
"""Backfill `thinking` on thinking blocks serialized without it."""
16+
for message in payload.get("messages", []):
17+
content = message.get("content")
18+
if not isinstance(content, list):
19+
continue
20+
for block in content:
21+
if isinstance(block, dict) and block.get("type") == "thinking" and block.get("thinking") is None:
22+
block["thinking"] = ""
23+
24+
25+
class ChatAnthropicThinkingCompat(ChatAnthropic):
26+
"""ChatAnthropic that normalizes thinking blocks in outgoing payloads."""
27+
28+
def _get_request_payload(self, *args: Any, **kwargs: Any) -> dict:
29+
payload = super()._get_request_payload(*args, **kwargs)
30+
_ensure_thinking_field(payload)
31+
return payload

src/bundles/anthropic/src/lfx_anthropic/components/anthropic/anthropic.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,14 +77,14 @@ class AnthropicModelComponent(LCModelComponent):
7777

7878
def build_model(self) -> LanguageModel: # type: ignore[type-var]
7979
try:
80-
from langchain_anthropic.chat_models import ChatAnthropic
80+
from lfx_anthropic.anthropic_chat_model import ChatAnthropicThinkingCompat
8181
except ImportError as e:
8282
msg = "langchain_anthropic is not installed. Please install it with `pip install langchain_anthropic`."
8383
raise ImportError(msg) from e
8484
try:
8585
max_tokens_value = getattr(self, "max_tokens", "")
8686
max_tokens_value = 4096 if max_tokens_value == "" else int(max_tokens_value)
87-
output = ChatAnthropic(
87+
output = ChatAnthropicThinkingCompat(
8888
model=self.model_name,
8989
anthropic_api_key=self.api_key,
9090
max_tokens=max_tokens_value,

src/bundles/anthropic/src/lfx_anthropic/extension.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://schemas.langflow.org/extension/v1.json",
33
"id": "lfx-anthropic",
4-
"version": "0.1.0",
4+
"version": "0.1.1",
55
"name": "Anthropic",
66
"description": "Anthropic component(s) as a standalone Langflow Extension Bundle.",
77
"lfx": {
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""Regression tests for Anthropic thinking-block compatibility in the bundle."""
2+
3+
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
4+
from lfx_anthropic.anthropic_chat_model import ChatAnthropicThinkingCompat, _ensure_thinking_field
5+
from lfx_anthropic.components.anthropic.anthropic import AnthropicModelComponent
6+
7+
8+
def _malformed_history() -> list:
9+
"""Return a tool-use history whose thinking block is missing `thinking`."""
10+
ai_message = AIMessage(
11+
content=[
12+
{"type": "thinking", "signature": "sig==", "index": 0},
13+
{
14+
"type": "tool_use",
15+
"id": "toolu_01",
16+
"name": "echo",
17+
"input": {"text": "hello"},
18+
"index": 1,
19+
},
20+
],
21+
tool_calls=[{"name": "echo", "args": {"text": "hello"}, "id": "toolu_01", "type": "tool_call"}],
22+
)
23+
return [
24+
HumanMessage(content="Use the 'echo' tool to echo: hello"),
25+
ai_message,
26+
ToolMessage(content="hello", tool_call_id="toolu_01"),
27+
]
28+
29+
30+
def _thinking_blocks(payload: dict) -> list[dict]:
31+
blocks = []
32+
for message in payload.get("messages", []):
33+
content = message.get("content")
34+
if isinstance(content, list):
35+
blocks.extend(block for block in content if isinstance(block, dict) and block.get("type") == "thinking")
36+
return blocks
37+
38+
39+
def _build_model() -> ChatAnthropicThinkingCompat:
40+
component = AnthropicModelComponent(
41+
model_name="claude-sonnet-5",
42+
api_key="test-key", # pragma: allowlist secret
43+
max_tokens=1024,
44+
temperature=0.1,
45+
base_url="https://api.anthropic.com",
46+
stream=False,
47+
)
48+
return component.build_model()
49+
50+
51+
def test_component_builds_bundle_local_compat_class():
52+
model = _build_model()
53+
54+
assert type(model) is ChatAnthropicThinkingCompat
55+
blocks = _thinking_blocks(model._get_request_payload(_malformed_history()))
56+
assert blocks
57+
assert all(block.get("thinking") == "" for block in blocks)
58+
59+
60+
def test_payload_preserves_existing_thinking_text():
61+
model = _build_model()
62+
history = _malformed_history()
63+
history[1].content[0] = {
64+
"type": "thinking",
65+
"thinking": "let me reason",
66+
"signature": "sig==",
67+
"index": 0,
68+
}
69+
70+
blocks = _thinking_blocks(model._get_request_payload(history))
71+
72+
assert blocks
73+
assert blocks[0]["thinking"] == "let me reason"
74+
75+
76+
def test_ensure_thinking_field_handles_missing_and_none():
77+
payload = {
78+
"messages": [
79+
{
80+
"role": "assistant",
81+
"content": [
82+
{"type": "thinking", "signature": "a=="},
83+
{"type": "thinking", "thinking": None, "signature": "b=="},
84+
{"type": "thinking", "thinking": "kept", "signature": "c=="},
85+
{"type": "text", "text": "hi"},
86+
],
87+
},
88+
{"role": "user", "content": "plain string content"},
89+
]
90+
}
91+
92+
_ensure_thinking_field(payload)
93+
94+
blocks = payload["messages"][0]["content"]
95+
assert blocks[0]["thinking"] == ""
96+
assert blocks[1]["thinking"] == ""
97+
assert blocks[2]["thinking"] == "kept"
98+
assert payload["messages"][1]["content"] == "plain string content"

src/lfx/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ dev = [
128128
"blockbuster>=1.5.25",
129129
"coverage>=7.9.2",
130130
"hypothesis>=6.136.3",
131+
"langchain-anthropic~=1.4.6",
131132
"pytest>=9.0.3",
132133
"pytest-asyncio>=0.26.0",
133134
"pytest-cov>=7.0.0",
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""ChatAnthropic subclass that keeps thinking blocks round-trippable.
2+
3+
langchain-anthropic (<= 1.4.8) drops the empty `thinking` text while merging
4+
streamed chunks when the model omits thinking display (the default on
5+
claude-sonnet-5 and newer), so assistant turns that carried a thinking block
6+
are serialized back to the API without the required field and the follow-up
7+
request fails with HTTP 400 `thinking.thinking: Field required`, aborting the
8+
Agent turn with an empty final message.
9+
"""
10+
11+
from typing import Any
12+
13+
from langchain_anthropic import ChatAnthropic
14+
15+
16+
def _ensure_thinking_field(payload: dict) -> None:
17+
"""Backfill `thinking` on thinking blocks serialized without it."""
18+
for message in payload.get("messages", []):
19+
content = message.get("content")
20+
if not isinstance(content, list):
21+
continue
22+
for block in content:
23+
if isinstance(block, dict) and block.get("type") == "thinking" and block.get("thinking") is None:
24+
block["thinking"] = ""
25+
26+
27+
class ChatAnthropicThinkingCompat(ChatAnthropic):
28+
"""ChatAnthropic that normalizes thinking blocks in outgoing payloads."""
29+
30+
def _get_request_payload(self, *args: Any, **kwargs: Any) -> dict:
31+
payload = super()._get_request_payload(*args, **kwargs)
32+
_ensure_thinking_field(payload)
33+
return payload

src/lfx/src/lfx/base/models/unified_models/class_registry.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@
1616

1717
_MODEL_CLASS_IMPORTS: dict[str, tuple[str, str, str | None]] = {
1818
"ChatOpenAI": ("langchain_openai", "ChatOpenAI", None),
19-
"ChatAnthropic": ("langchain_anthropic", "ChatAnthropic", None),
19+
"ChatAnthropic": (
20+
"lfx.base.models.anthropic_chat_model",
21+
"ChatAnthropicThinkingCompat",
22+
"langchain-anthropic",
23+
),
2024
"ChatGoogleGenerativeAIFixed": (
2125
"lfx.base.models.google_generative_ai_model",
2226
"ChatGoogleGenerativeAIFixed",
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""Regression tests for Anthropic thinking-block compatibility in LFX."""
2+
3+
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
4+
from lfx.base.models.anthropic_chat_model import ChatAnthropicThinkingCompat, _ensure_thinking_field
5+
from lfx.base.models.unified_models.class_registry import get_model_class
6+
7+
8+
def _malformed_history() -> list:
9+
"""Return a tool-use history whose thinking block is missing `thinking`."""
10+
ai_message = AIMessage(
11+
content=[
12+
{"type": "thinking", "signature": "sig==", "index": 0},
13+
{
14+
"type": "tool_use",
15+
"id": "toolu_01",
16+
"name": "echo",
17+
"input": {"text": "hello"},
18+
"index": 1,
19+
},
20+
],
21+
tool_calls=[{"name": "echo", "args": {"text": "hello"}, "id": "toolu_01", "type": "tool_call"}],
22+
)
23+
return [
24+
HumanMessage(content="Use the 'echo' tool to echo: hello"),
25+
ai_message,
26+
ToolMessage(content="hello", tool_call_id="toolu_01"),
27+
]
28+
29+
30+
def _thinking_blocks(payload: dict) -> list[dict]:
31+
blocks = []
32+
for message in payload.get("messages", []):
33+
content = message.get("content")
34+
if isinstance(content, list):
35+
blocks.extend(block for block in content if isinstance(block, dict) and block.get("type") == "thinking")
36+
return blocks
37+
38+
39+
def test_payload_thinking_blocks_always_carry_thinking_field():
40+
model = ChatAnthropicThinkingCompat(
41+
model="claude-sonnet-5",
42+
api_key="test-key", # pragma: allowlist secret
43+
max_tokens=1024,
44+
)
45+
46+
blocks = _thinking_blocks(model._get_request_payload(_malformed_history()))
47+
48+
assert blocks
49+
assert all(block.get("thinking") == "" for block in blocks)
50+
51+
52+
def test_payload_preserves_existing_thinking_text():
53+
model = ChatAnthropicThinkingCompat(
54+
model="claude-sonnet-5",
55+
api_key="test-key", # pragma: allowlist secret
56+
max_tokens=1024,
57+
)
58+
history = _malformed_history()
59+
history[1].content[0] = {
60+
"type": "thinking",
61+
"thinking": "let me reason",
62+
"signature": "sig==",
63+
"index": 0,
64+
}
65+
66+
blocks = _thinking_blocks(model._get_request_payload(history))
67+
68+
assert blocks
69+
assert blocks[0]["thinking"] == "let me reason"
70+
71+
72+
def test_agent_registry_resolves_compat_class():
73+
assert get_model_class("ChatAnthropic") is ChatAnthropicThinkingCompat
74+
75+
76+
def test_ensure_thinking_field_handles_missing_and_none():
77+
payload = {
78+
"messages": [
79+
{
80+
"role": "assistant",
81+
"content": [
82+
{"type": "thinking", "signature": "a=="},
83+
{"type": "thinking", "thinking": None, "signature": "b=="},
84+
{"type": "thinking", "thinking": "kept", "signature": "c=="},
85+
{"type": "text", "text": "hi"},
86+
],
87+
},
88+
{"role": "user", "content": "plain string content"},
89+
]
90+
}
91+
92+
_ensure_thinking_field(payload)
93+
94+
blocks = payload["messages"][0]["content"]
95+
assert blocks[0]["thinking"] == ""
96+
assert blocks[1]["thinking"] == ""
97+
assert blocks[2]["thinking"] == "kept"
98+
assert payload["messages"][1]["content"] == "plain string content"

uv.lock

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)