mirror of
https://github.com/hwchase17/langchain.git
synced 2026-06-09 10:17:00 +00:00
fix(fireworks): strip non-wire keys from all content parts before serialization (#37714)
`_sanitize_chat_completions_content` now filters every content-part dict against an allowlist derived at import time from the `fireworks-ai` SDK's `ContentUnionMember1` TypedDict, and runs on every message role — not just `ToolMessage`. Fixes 400s of the form `Extra inputs are not permitted, field: 'messages[N].content.list[ChatMessageContent][i].<key>'` when cross-provider history (e.g. an Anthropic-shaped `AIMessage` carrying the v1 streaming-reassembly `index` marker) is forwarded to a Fireworks-hosted model.
This commit is contained in:
@@ -172,22 +172,78 @@ def _convert_dict_to_message(_dict: Mapping[str, Any]) -> BaseMessage:
|
||||
return ChatMessage(content=_dict.get("content", ""), role=role or "")
|
||||
|
||||
|
||||
def _sanitize_chat_completions_content(content: Any) -> Any:
|
||||
"""Strip non-wire keys from text content blocks.
|
||||
def _allowed_content_part_keys() -> frozenset[str]:
|
||||
"""Allowlist of wire-valid keys on a Fireworks content part.
|
||||
|
||||
Fireworks's chat completions endpoint rejects unknown fields on tool
|
||||
message content blocks (e.g. the `id` that LangChain auto-generates on
|
||||
`TextContentBlock`). For list content, keep only `type` and `text` on
|
||||
text blocks; pass other blocks and non-list content through unchanged.
|
||||
Derived at import time from the stainless-generated TypedDict so the
|
||||
allowlist tracks the upstream OpenAPI spec as `fireworks-ai` is bumped:
|
||||
new fields widen the allowlist for free, removed/renamed fields shrink it
|
||||
in lockstep. If the SDK reshuffles its module layout the import falls back
|
||||
to a conservative hand-coded set and emits a warning, and the layout test
|
||||
(`test_fireworks_sdk_request_layout_stable`) fails to surface the drift.
|
||||
"""
|
||||
try:
|
||||
from typing import get_type_hints
|
||||
|
||||
from fireworks.types.shared_params.chat_message import (
|
||||
ContentUnionMember1,
|
||||
)
|
||||
|
||||
return frozenset(get_type_hints(ContentUnionMember1))
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"Could not import `fireworks.types.shared_params.chat_message."
|
||||
"ContentUnionMember1`; falling back to a conservative content-part "
|
||||
"key allowlist. Bump `fireworks-ai` or update "
|
||||
"`_allowed_content_part_keys` if the SDK has moved this type.",
|
||||
)
|
||||
return frozenset({"type", "text", "image_url", "video_url"})
|
||||
|
||||
|
||||
_ALLOWED_CONTENT_PART_KEYS: frozenset[str] = _allowed_content_part_keys()
|
||||
|
||||
|
||||
def _sanitize_chat_completions_content(content: Any) -> Any:
|
||||
"""Strip non-wire keys from content blocks before serializing to Fireworks.
|
||||
|
||||
Fireworks's chat completions endpoint rejects unknown fields on message
|
||||
content parts with `Extra inputs are not permitted, field: 'messages[N]
|
||||
.content.list[ChatMessageContent][i].<key>'`. This surfaces when a
|
||||
conversation accumulates AIMessages from a different provider (e.g.
|
||||
Anthropic's v1 streaming-reassembly `index` marker on text blocks, or the
|
||||
LangChain-internal `caller` key on `tool_use` blocks) and that history is
|
||||
later forwarded to a Fireworks-hosted model.
|
||||
|
||||
For list content:
|
||||
- each block dict is filtered down to keys in
|
||||
`_ALLOWED_CONTENT_PART_KEYS` (sourced from the SDK TypedDict, so it
|
||||
stays in sync with the upstream spec).
|
||||
- if the result is a list of exactly one block that, post-strip, is
|
||||
`{"type": "text", "text": <str>}` and nothing else, it is coerced to
|
||||
a plain string. Fireworks's `content` union lists `str` first
|
||||
(`Input should be a valid string, field: 'messages[N].content.str'`),
|
||||
and the stricter shape avoids the union-validation noise on the
|
||||
server side.
|
||||
Non-list content (strings, None) passes through unchanged.
|
||||
"""
|
||||
if not isinstance(content, list):
|
||||
return content
|
||||
sanitized: list[Any] = []
|
||||
for block in content:
|
||||
if isinstance(block, dict) and block.get("type") == "text" and "text" in block:
|
||||
sanitized.append({"type": "text", "text": block["text"]})
|
||||
if isinstance(block, dict):
|
||||
sanitized.append(
|
||||
{k: v for k, v in block.items() if k in _ALLOWED_CONTENT_PART_KEYS}
|
||||
)
|
||||
else:
|
||||
sanitized.append(block)
|
||||
if (
|
||||
len(sanitized) == 1
|
||||
and isinstance(sanitized[0], dict)
|
||||
and set(sanitized[0]) == {"type", "text"}
|
||||
and sanitized[0]["type"] == "text"
|
||||
and isinstance(sanitized[0]["text"], str)
|
||||
):
|
||||
return sanitized[0]["text"]
|
||||
return sanitized
|
||||
|
||||
|
||||
@@ -269,12 +325,16 @@ def _convert_message_to_dict(message: BaseMessage) -> dict:
|
||||
if isinstance(message, ChatMessage):
|
||||
message_dict = {
|
||||
"role": message.role,
|
||||
"content": _format_message_content(message.content),
|
||||
"content": _sanitize_chat_completions_content(
|
||||
_format_message_content(message.content)
|
||||
),
|
||||
}
|
||||
elif isinstance(message, HumanMessage):
|
||||
message_dict = {
|
||||
"role": "user",
|
||||
"content": _format_message_content(message.content),
|
||||
"content": _sanitize_chat_completions_content(
|
||||
_format_message_content(message.content)
|
||||
),
|
||||
}
|
||||
elif isinstance(message, AIMessage):
|
||||
# Translate v1 content
|
||||
@@ -282,7 +342,9 @@ def _convert_message_to_dict(message: BaseMessage) -> dict:
|
||||
message = _convert_from_v1_to_chat_completions(message)
|
||||
message_dict = {
|
||||
"role": "assistant",
|
||||
"content": _format_message_content(message.content),
|
||||
"content": _sanitize_chat_completions_content(
|
||||
_format_message_content(message.content)
|
||||
),
|
||||
}
|
||||
if "function_call" in message.additional_kwargs:
|
||||
message_dict["function_call"] = message.additional_kwargs["function_call"]
|
||||
@@ -306,7 +368,9 @@ def _convert_message_to_dict(message: BaseMessage) -> dict:
|
||||
elif isinstance(message, SystemMessage):
|
||||
message_dict = {
|
||||
"role": "system",
|
||||
"content": _format_message_content(message.content),
|
||||
"content": _sanitize_chat_completions_content(
|
||||
_format_message_content(message.content)
|
||||
),
|
||||
}
|
||||
elif isinstance(message, FunctionMessage):
|
||||
message_dict = {
|
||||
|
||||
@@ -26,6 +26,7 @@ from langchain_core.messages import (
|
||||
|
||||
from langchain_fireworks import ChatFireworks
|
||||
from langchain_fireworks.chat_models import (
|
||||
_ALLOWED_CONTENT_PART_KEYS,
|
||||
FireworksContextOverflowError,
|
||||
_acompletion_with_retry,
|
||||
_completion_with_retry,
|
||||
@@ -124,7 +125,9 @@ def test_sanitize_chat_completions_text_blocks_strips_id() -> None:
|
||||
"""LangChain auto-generated `id` on text blocks must not reach the wire.
|
||||
|
||||
Fireworks's chat completions schema rejects unknown keys on tool message
|
||||
content blocks (`Extra inputs are not permitted, ... [0].id`).
|
||||
content blocks (`Extra inputs are not permitted, ... [0].id`). A single
|
||||
text-only block also collapses to a plain string, since the Fireworks
|
||||
`content` union lists `str` first.
|
||||
"""
|
||||
message = ToolMessage(
|
||||
content=[{"type": "text", "text": "foo", "id": "lc_abc123"}],
|
||||
@@ -132,7 +135,7 @@ def test_sanitize_chat_completions_text_blocks_strips_id() -> None:
|
||||
)
|
||||
assert _convert_message_to_dict(message) == {
|
||||
"role": "tool",
|
||||
"content": [{"type": "text", "text": "foo"}],
|
||||
"content": "foo",
|
||||
"tool_call_id": "def456",
|
||||
}
|
||||
|
||||
@@ -146,6 +149,153 @@ def test_sanitize_chat_completions_content_passthrough_non_text_block() -> None:
|
||||
assert _sanitize_chat_completions_content(blocks) == blocks
|
||||
|
||||
|
||||
def test_sanitize_chat_completions_content_strips_anthropic_v1_index() -> None:
|
||||
"""Reproduction for the Kimi-via-Fireworks 400 from cross-provider history.
|
||||
|
||||
Anthropic-shaped AIMessage text blocks carry an `index` streaming-reassembly
|
||||
marker that Fireworks rejects as `Extra inputs are not permitted, field:
|
||||
'messages[N].content.list[ChatMessageContent][0].index'`. After sanitization
|
||||
the single text block collapses to a plain string, matching the first branch
|
||||
of Fireworks's `content` union.
|
||||
"""
|
||||
blocks = [{"text": "hello", "type": "text", "index": 0}]
|
||||
|
||||
assert _sanitize_chat_completions_content(blocks) == "hello"
|
||||
|
||||
|
||||
def test_sanitize_chat_completions_content_strips_tool_use_extras() -> None:
|
||||
"""Unknown keys on a `tool_use` block are stripped down to allowlisted keys.
|
||||
|
||||
`_format_message_content` is the real guard against `tool_use` blocks on
|
||||
outbound messages — it drops them entirely. The sanitizer only enforces
|
||||
the per-block key allowlist; it does not validate `type` against
|
||||
Fireworks's content union, so the surviving `{"type": "tool_use"}` is not
|
||||
itself wire-valid. This test pins the key-stripping contract; wire
|
||||
validity of `tool_use` blocks is `_format_message_content`'s job.
|
||||
"""
|
||||
blocks = [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_1",
|
||||
"name": "foo",
|
||||
"input": {},
|
||||
"caller": {"type": "direct"},
|
||||
"index": 0,
|
||||
}
|
||||
]
|
||||
|
||||
sanitized = _sanitize_chat_completions_content(blocks)
|
||||
|
||||
assert sanitized == [{"type": "tool_use"}]
|
||||
|
||||
|
||||
def test_sanitize_chat_completions_content_keeps_multi_block_list() -> None:
|
||||
"""A multi-block list is not coerced to a string."""
|
||||
blocks = [
|
||||
{"type": "text", "text": "a", "index": 0},
|
||||
{"type": "text", "text": "b", "index": 1},
|
||||
]
|
||||
|
||||
assert _sanitize_chat_completions_content(blocks) == [
|
||||
{"type": "text", "text": "a"},
|
||||
{"type": "text", "text": "b"},
|
||||
]
|
||||
|
||||
|
||||
def test_sanitize_chat_completions_content_does_not_coerce_image_only() -> None:
|
||||
"""Coercion to string is gated on a single `{type:text,text:str}` block."""
|
||||
blocks = [{"type": "image_url", "image_url": {"url": "https://x/y.png"}}]
|
||||
|
||||
assert _sanitize_chat_completions_content(blocks) == blocks
|
||||
|
||||
|
||||
def test_sanitize_does_not_coerce_when_text_is_non_string() -> None:
|
||||
"""Coercion is gated on `text` being a `str` — non-string `text` stays a list.
|
||||
|
||||
Without the `isinstance(..., str)` guard the gate would send a malformed
|
||||
payload (e.g. `content=123`) downstream, which is worse than letting
|
||||
Fireworks reject the list.
|
||||
"""
|
||||
blocks = [{"type": "text", "text": 123}]
|
||||
|
||||
assert _sanitize_chat_completions_content(blocks) == [{"type": "text", "text": 123}]
|
||||
|
||||
|
||||
def test_sanitize_does_not_coerce_when_text_key_missing_after_strip() -> None:
|
||||
"""After stripping, a `{"type":"text"}` block with no `text` is not coerced."""
|
||||
blocks = [{"type": "text", "index": 0}]
|
||||
|
||||
assert _sanitize_chat_completions_content(blocks) == [{"type": "text"}]
|
||||
|
||||
|
||||
def test_convert_human_message_with_anthropic_v1_blocks_is_wire_clean() -> None:
|
||||
"""`HumanMessage` content also routes through the sanitizer end-to-end."""
|
||||
message = HumanMessage(
|
||||
content=[{"type": "text", "text": "hi", "index": 0}],
|
||||
)
|
||||
|
||||
assert _convert_message_to_dict(message) == {"role": "user", "content": "hi"}
|
||||
|
||||
|
||||
def test_convert_ai_message_with_anthropic_v1_blocks_is_wire_clean() -> None:
|
||||
"""End-to-end: an AIMessage carrying Anthropic v1 markers serializes clean.
|
||||
|
||||
Mirrors the actual payload that triggered the 400 in production (Fleet ran
|
||||
a Sonnet-started conversation through ChatFireworks/Kimi).
|
||||
"""
|
||||
message = AIMessage(
|
||||
content=[
|
||||
{
|
||||
"text": (
|
||||
"I don't have a direct Hex API integration available "
|
||||
"as a built-in tool."
|
||||
),
|
||||
"type": "text",
|
||||
"index": 0,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert _convert_message_to_dict(message) == {
|
||||
"role": "assistant",
|
||||
"content": (
|
||||
"I don't have a direct Hex API integration available as a built-in tool."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def test_fireworks_sdk_request_layout_stable() -> None:
|
||||
"""Fail loudly if `fireworks-ai` reshuffles its request TypedDict layout.
|
||||
|
||||
The content-part allowlist (`_ALLOWED_CONTENT_PART_KEYS`) is derived from
|
||||
the SDK's stainless-generated TypedDict at import time. If a future SDK
|
||||
version renames the module, renames the class, or drops a key the
|
||||
sanitizer assumes is present, this test fails so the strip logic gets
|
||||
updated in the same PR as the `fireworks-ai` bump.
|
||||
"""
|
||||
from typing import get_type_hints
|
||||
|
||||
from fireworks.types.shared_params.chat_message import (
|
||||
ChatMessage as SDKChatMessage,
|
||||
)
|
||||
from fireworks.types.shared_params.chat_message import (
|
||||
ContentUnionMember1,
|
||||
)
|
||||
|
||||
content_keys = set(get_type_hints(ContentUnionMember1))
|
||||
message_keys = set(get_type_hints(SDKChatMessage))
|
||||
|
||||
assert "type" in content_keys, (
|
||||
"Fireworks SDK no longer exposes `type` on ContentUnionMember1; "
|
||||
"update `_allowed_content_part_keys` / the sanitizer."
|
||||
)
|
||||
assert "text" in content_keys
|
||||
assert {"role", "content"} <= message_keys
|
||||
# The sanitizer's allowlist must equal the SDK TypedDict's keys; this is
|
||||
# the actual production contract, not just the `type`/`text` spot-checks.
|
||||
assert frozenset(content_keys) == _ALLOWED_CONTENT_PART_KEYS
|
||||
|
||||
|
||||
def test_format_message_content_translates_v1_image_block() -> None:
|
||||
"""Canonical v1 image block is translated to OpenAI image_url + data URI."""
|
||||
blocks = [{"type": "image", "base64": "abc", "mime_type": "image/png"}]
|
||||
@@ -413,9 +563,12 @@ def test_convert_message_to_dict_translates_system_list_content() -> None:
|
||||
|
||||
result = _convert_message_to_dict(system_message)
|
||||
|
||||
# `thinking` is dropped by `_format_message_content`; the lone text block
|
||||
# is coerced to a plain string by `_sanitize_chat_completions_content`
|
||||
# since Fireworks's `content` union lists `str` as its first branch.
|
||||
assert result == {
|
||||
"role": "system",
|
||||
"content": [{"type": "text", "text": "rules"}],
|
||||
"content": "rules",
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user