diff --git a/libs/partners/fireworks/langchain_fireworks/chat_models.py b/libs/partners/fireworks/langchain_fireworks/chat_models.py index 0fd4c425bc1..ee2fef10cc3 100644 --- a/libs/partners/fireworks/langchain_fireworks/chat_models.py +++ b/libs/partners/fireworks/langchain_fireworks/chat_models.py @@ -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].'`. 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": }` 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 = { diff --git a/libs/partners/fireworks/tests/unit_tests/test_chat_models.py b/libs/partners/fireworks/tests/unit_tests/test_chat_models.py index 1a54025a4fa..0db2b230d98 100644 --- a/libs/partners/fireworks/tests/unit_tests/test_chat_models.py +++ b/libs/partners/fireworks/tests/unit_tests/test_chat_models.py @@ -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", }