diff --git a/litellm/llms/fireworks_ai/responses/transformation.py b/litellm/llms/fireworks_ai/responses/transformation.py index fb0587553d4..61ef0731900 100644 --- a/litellm/llms/fireworks_ai/responses/transformation.py +++ b/litellm/llms/fireworks_ai/responses/transformation.py @@ -5,6 +5,7 @@ from urllib.parse import unquote import httpx from openai.types.responses import EasyInputMessageParam, ResponseInputItemParam +from pydantic import TypeAdapter from litellm.llms.fireworks_ai.common_utils import ( resolve_fireworks_api_key, @@ -31,16 +32,32 @@ def _session_params(litellm_params: GenericLiteLLMParams) -> Mapping[str, object ) -def _developer_item_as_system(item: ResponseInputItemParam) -> ResponseInputItemParam: - if "role" not in item or item["role"] != "developer": - return item - return EasyInputMessageParam(role="system", content=item["content"], type="message") +_instructions_adapter: Final = TypeAdapter[str | None](str | None) -def _developer_items_as_system(input: str | ResponseInputParam) -> str | ResponseInputParam: - if isinstance(input, str): +def _instruction_text(item: ResponseInputItemParam) -> str | None: + if "role" not in item or (item["role"] != "system" and item["role"] != "developer"): + return None + content: Final = item["content"] + if isinstance(content, str): + return content + return "\n\n".join(part["text"] for part in content if part["type"] == "input_text") + + +def _with_single_leading_system_item( + input: str | ResponseInputParam, instructions: str | None +) -> str | ResponseInputParam: + items: Final = () if isinstance(input, str) else tuple(input) + instruction_texts: Final = tuple(text for text in (instructions, *map(_instruction_text, items)) if text) + if not instruction_texts: return input - return [_developer_item_as_system(item) for item in input] + leading: Final = EasyInputMessageParam(role="system", content="\n\n".join(instruction_texts), type="message") + rest: Final = ( + (EasyInputMessageParam(role="user", content=input),) + if isinstance(input, str) + else tuple(item for item in items if _instruction_text(item) is None) + ) + return [leading, *rest] class FireworksAIResponsesAPIConfig(OpenAIResponsesAPIConfig): @@ -68,9 +85,6 @@ class FireworksAIResponsesAPIConfig(OpenAIResponsesAPIConfig): base: Final = (api_base or get_secret_str("FIREWORKS_API_BASE") or FIREWORKS_AI_DEFAULT_API_BASE).rstrip("/") return f"{base}/responses" - def _validate_input_param(self, input: str | ResponseInputParam) -> str | ResponseInputParam: - return _developer_items_as_system(super()._validate_input_param(input)) - def transform_responses_api_request( self, model: str, @@ -79,10 +93,16 @@ class FireworksAIResponsesAPIConfig(OpenAIResponsesAPIConfig): litellm_params: GenericLiteLLMParams, headers: dict, # mutable-ok: overrides the base class signature ) -> dict: # mutable-ok: overrides the base class signature + instructions: Final = _instructions_adapter.validate_python( + response_api_optional_request_params.get("instructions") + ) + folded_params: Final = { # mutable-ok: the base class takes the optional params as a dict + key: value for key, value in response_api_optional_request_params.items() if key != "instructions" + } return super().transform_responses_api_request( model=resolve_fireworks_resource_name(model), - input=input, - response_api_optional_request_params=response_api_optional_request_params, + input=_with_single_leading_system_item(self._validate_input_param(input), instructions), + response_api_optional_request_params=folded_params, litellm_params=litellm_params, headers=headers, ) diff --git a/tests/test_litellm/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py b/tests/test_litellm/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py index b9408d44e9a..3462e041a19 100644 --- a/tests/test_litellm/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py +++ b/tests/test_litellm/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py @@ -162,7 +162,7 @@ def test_responses_call_forwards_previous_response_id_and_store() -> None: assert body["input"][0]["call_id"] == "call_abc123" -def test_responses_call_sends_developer_items_as_system_messages() -> None: +def test_responses_call_hoists_developer_items_into_one_leading_system_message() -> None: client: Final = _mock_http_client(_fireworks_response("accounts/fireworks/models/kimi-k3")) with patch(HTTPX_CLIENT_FACTORY, return_value=client): litellm.responses( @@ -176,12 +176,78 @@ def test_responses_call_sends_developer_items_as_system_messages() -> None: ) _, _, body = _sent_request(client) assert tuple(body["input"]) == ( - {"role": "user", "content": "Hi there"}, {"role": "system", "content": "Answer with exactly one word.", "type": "message"}, + {"role": "user", "content": "Hi there"}, {"role": "user", "content": [{"type": "input_text", "text": "What is the capital of France?"}]}, ) +def test_responses_call_folds_instructions_and_developer_item_into_one_leading_system_message() -> None: + client: Final = _mock_http_client(_fireworks_response("accounts/fireworks/models/qwen3p8-2p4t-a95b")) + with patch(HTTPX_CLIENT_FACTORY, return_value=client): + litellm.responses( + model="fireworks_ai/accounts/fireworks/models/qwen3p8-2p4t-a95b", + instructions="You are a coding agent running in the Codex CLI.", + input=[ # mutable-ok: the Responses API takes input as a JSON list + { + "role": "developer", + "content": [{"type": "input_text", "text": "read-only"}], + }, + {"role": "user", "content": [{"type": "input_text", "text": "What is the capital of France?"}]}, + {"id": "rs_1", "type": "reasoning", "summary": [{"type": "summary_text", "text": "A trivial question."}]}, + { + "id": "msg_1", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": "Paris is the capital of France.", "annotations": []}], + }, + {"role": "user", "content": [{"type": "input_text", "text": "And of Spain?"}]}, + ], + store=False, + api_key="fw-test-key", + ) + _, _, body = _sent_request(client) + assert "instructions" not in body + assert tuple(body["input"]) == ( + { + "role": "system", + "content": ( + "You are a coding agent running in the Codex CLI.\n\n" + "read-only" + ), + "type": "message", + }, + {"role": "user", "content": [{"type": "input_text", "text": "What is the capital of France?"}]}, + {"id": "rs_1", "type": "reasoning", "summary": [{"type": "summary_text", "text": "A trivial question."}]}, + { + "id": "msg_1", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": "Paris is the capital of France.", "annotations": []}], + }, + {"role": "user", "content": [{"type": "input_text", "text": "And of Spain?"}]}, + ) + + +def test_responses_call_turns_string_input_with_instructions_into_system_then_user_messages() -> None: + client: Final = _mock_http_client(_fireworks_response("accounts/fireworks/models/kimi-k3")) + with patch(HTTPX_CLIENT_FACTORY, return_value=client): + litellm.responses( + model="fireworks_ai/accounts/fireworks/models/kimi-k3", + instructions="Answer with exactly one word.", + input="What is the capital of France?", + api_key="fw-test-key", + ) + _, _, body = _sent_request(client) + assert "instructions" not in body + assert tuple(body["input"]) == ( + {"role": "system", "content": "Answer with exactly one word.", "type": "message"}, + {"role": "user", "content": "What is the capital of France?"}, + ) + + def test_responses_call_maps_pydantic_developer_items_and_replays_pydantic_output_items() -> None: client: Final = _mock_http_client(_fireworks_response("accounts/fireworks/models/kimi-k3")) pydantic_input: Final = cast(