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:
Mason Daugherty
2026-05-27 13:59:49 -04:00
committed by GitHub
parent 800f02148b
commit e3ce6e557c
2 changed files with 232 additions and 15 deletions

View File

@@ -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 = {

View File

@@ -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",
}