diff --git a/litellm/litellm_core_utils/get_llm_provider_logic.py b/litellm/litellm_core_utils/get_llm_provider_logic.py index 192679957b1..f622098920c 100644 --- a/litellm/litellm_core_utils/get_llm_provider_logic.py +++ b/litellm/litellm_core_utils/get_llm_provider_logic.py @@ -860,7 +860,6 @@ def _get_openai_compatible_provider_info( api_base = litellm.EdenAIChatConfig.get_api_base(api_base) # rebind-ok: chain resolves in place dynamic_api_key = litellm.EdenAIChatConfig.get_api_key(api_key) # rebind-ok: chain resolves in place elif custom_llm_provider == "fal_ai": - api_base = litellm.FalAIChatConfig.get_api_base(api_base) # rebind-ok: chain resolves in place dynamic_api_key = litellm.FalAIChatConfig.get_api_key(api_key) # rebind-ok: chain resolves in place elif custom_llm_provider == "aiml": ( diff --git a/litellm/llms/fal_ai/chat/transformation.py b/litellm/llms/fal_ai/chat/transformation.py index 2c5af6538ab..164b660b21a 100644 --- a/litellm/llms/fal_ai/chat/transformation.py +++ b/litellm/llms/fal_ai/chat/transformation.py @@ -115,13 +115,13 @@ class FalAIChatConfig(BaseConfig): return list(("reasoning_effort", "temperature", "top_p")) # mutable-ok: inherited contract returns a list def _map_reasoning_effort(self, value: object, model: str, drop_params: bool) -> bool | None: - if value in REASONING_DISABLED_EFFORTS: + if isinstance(value, str) and value in REASONING_DISABLED_EFFORTS: return False - if value in REASONING_ENABLED_EFFORTS: + if isinstance(value, str) and value in REASONING_ENABLED_EFFORTS: return True if drop_params: return None - raise FalAIError(status_code=400, message=f"Unsupported reasoning_effort '{value}' for {model}") + raise FalAIError(status_code=400, message=f"Unsupported reasoning_effort {value!r} for {model}") def _translate_param(self, param: str, value: object, model: str, drop_params: bool) -> tuple[str, object] | None: if param in ("temperature", "top_p"): diff --git a/tests/integration/contracts.json b/tests/integration/contracts.json index 9d4778801a5..d12e3ae4620 100644 --- a/tests/integration/contracts.json +++ b/tests/integration/contracts.json @@ -205,6 +205,12 @@ "tests/integration/providers/test_fal_ai_chat_wire.py::test_fal_moondream3_chat_sends_prompt_image_and_reasoning": [ "other.provider_wire.fal_ai.moondream3_chat_query_wire_and_token_pricing" ], + "tests/integration/providers/test_fal_ai_chat_wire.py::test_fal_moondream3_chat_rejects_non_string_reasoning_effort_before_the_wire": [ + "other.provider_wire.fal_ai.chat_non_string_reasoning_effort_rejected_before_wire" + ], + "tests/integration/providers/test_fal_ai_image_wire.py::test_fal_flux_dev_generation_without_deployment_api_base_uses_global_api_base": [ + "other.provider_wire.fal_ai.global_api_base_routes_image_generation" + ], "tests/integration/providers/test_xiaomi_mimo_wire.py::test_xiaomi_mimo_nonstream_surfaces_reasoning_and_charges_registry_price[mimo-v2.6-pro]": [ "other.provider_wire.xiaomi_mimo.reasoning_content_and_registry_pricing" ], diff --git a/tests/integration/providers/test_fal_ai_chat_wire.py b/tests/integration/providers/test_fal_ai_chat_wire.py index 2bb1ac3f168..8cfa51f4389 100644 --- a/tests/integration/providers/test_fal_ai_chat_wire.py +++ b/tests/integration/providers/test_fal_ai_chat_wire.py @@ -2,7 +2,6 @@ import json from pathlib import Path from typing import Final -import httpx import pytest from integration._support.client import Gateway from integration._support.wire import Reply, Request, wire_server @@ -57,7 +56,6 @@ def test_fal_moondream3_chat_sends_prompt_image_and_reasoning(gateway: Gateway) ) with wire_server(respond) as wire, gateway.scenario() as scenario: - wire_url: Final = wire.url model: Final = scenario.model(model=f"fal_ai/{_MODEL}", api_base=wire.url, api_key="synthetic-fal-key") response: Final = gateway.request( "POST", @@ -97,3 +95,32 @@ def test_fal_moondream3_chat_sends_prompt_image_and_reasoning(gateway: Gateway) + 7 * _catalog_cost(f"fal_ai/{_MODEL}", "output_cost_per_token") ) assert [(request.method, request.target) for request in wire.drain()] == [("POST", f"/{_MODEL}")] + + +@pytest.mark.covers("other.provider_wire.fal_ai.chat_non_string_reasoning_effort_rejected_before_wire") +def test_fal_moondream3_chat_rejects_non_string_reasoning_effort_before_the_wire(gateway: Gateway) -> None: + def respond(request: Request) -> Reply: + raise AssertionError(f"provider must not be reached: {request.method} {request.target}") + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=f"fal_ai/{_MODEL}", api_base=wire.url, api_key="synthetic-fal-key") + response: Final = gateway.request( + "POST", + "/v1/chat/completions", + { + "model": model, + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": _PROMPT}, + {"type": "image_url", "image_url": {"url": "https://example.com/pic.png"}}, + ], + } + ], + "reasoning_effort": {"level": "low"}, + }, + ) + assert response.status_code == 400, response.text + assert "reasoning_effort" in response.text + assert wire.drain() == () diff --git a/tests/integration/providers/test_fal_ai_image_wire.py b/tests/integration/providers/test_fal_ai_image_wire.py index 02f24f9e369..c85bcdae8b8 100644 --- a/tests/integration/providers/test_fal_ai_image_wire.py +++ b/tests/integration/providers/test_fal_ai_image_wire.py @@ -4,12 +4,15 @@ from pathlib import Path from typing import Final import httpx -import litellm import pytest -from integration._support.client import Gateway +import yaml +from integration._support.client import Gateway, object_value +from integration._support.process import owned_proxy from integration._support.wire import Reply, Request, wire_server from pydantic import JsonValue, TypeAdapter +import litellm + _GPT_IMAGE_MODEL: Final = "openai/gpt-image-2.5/flare/text-to-image" _FLUX_MODEL: Final = "fal-ai/flux/dev" _EDIT_MODEL: Final = "openai/gpt-image-2.5/flare/edit" @@ -301,6 +304,44 @@ def test_fal_flux_lora_depth_edit_sends_single_image_url_and_charges_flat_row(ga ] cost: Final = _response_cost(response) assert cost == _approx(_catalog_cost("fal_ai/fal-ai/flux-lora-depth")) - assert [(request.method, request.target) for request in wire.drain()] == [ - ("POST", "/fal-ai/flux-lora-depth") - ] + assert [(request.method, request.target) for request in wire.drain()] == [("POST", "/fal-ai/flux-lora-depth")] + + +@pytest.mark.covers("other.provider_wire.fal_ai.global_api_base_routes_image_generation") +def test_fal_flux_dev_generation_without_deployment_api_base_uses_global_api_base( + gateway: Gateway, tmp_path: Path +) -> None: + def respond(request: Request) -> Reply: + assert request.method == "POST" + assert request.headers["authorization"] == "Key synthetic-fal-key" + assert request.target == "/fal-ai/flux/dev" + assert _JSON_OBJECT.validate_json(request.body) == {"prompt": _PROMPT, "num_images": 1} + return Reply(body=_image_response(((f"{wire_url}/files/global.png", 1024, 1024),), _PROMPT)) + + with wire_server(respond) as wire: + wire_url: Final = wire.url + configuration: Final = _JSON_OBJECT.validate_python( + yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()) + ) + configuration["litellm_settings"] = { + **object_value(configuration["litellm_settings"]), + "api_base": wire.url, + } + path: Final = tmp_path / "global-api-base.yaml" + path.write_text(yaml.safe_dump(configuration)) + with owned_proxy(gateway, tmp_path, {}, config=path) as candidate, candidate.scenario() as scenario: + model: Final = scenario.model(model=f"fal_ai/{_FLUX_MODEL}", api_key="synthetic-fal-key", api_base=None) + response: Final = candidate.request( + "POST", "/v1/images/generations", {"model": model, "prompt": _PROMPT, "n": 1} + ) + assert response.status_code == 200, response.text + payload: Final = _JSON_OBJECT.validate_json(response.content) + assert payload["data"] == [ + { + "url": f"{wire.url}/files/global.png", + "b64_json": None, + "revised_prompt": None, + "provider_specific_fields": {"width": 1024, "height": 1024, "content_type": "image/png"}, + } + ] + assert [(request.method, request.target) for request in wire.drain()] == [("POST", "/fal-ai/flux/dev")] diff --git a/tests/test_litellm/litellm_core_utils/test_get_llm_provider_logic.py b/tests/test_litellm/litellm_core_utils/test_get_llm_provider_logic.py index 1ecef9ffff7..ed8438bb1de 100644 --- a/tests/test_litellm/litellm_core_utils/test_get_llm_provider_logic.py +++ b/tests/test_litellm/litellm_core_utils/test_get_llm_provider_logic.py @@ -1,5 +1,6 @@ from typing import Final +import httpx import pytest import litellm @@ -8,6 +9,7 @@ from litellm.litellm_core_utils.get_llm_provider_logic import ( get_llm_provider, is_registered_custom_provider, ) +from litellm.llms.custom_httpx.http_handler import HTTPHandler CUSTOM_PROVIDER: Final = "test-onprem-llm" @@ -53,3 +55,26 @@ def test_get_llm_provider_still_rejects_unregistered_prefix(registered_custom_pr ) def test_is_registered_custom_provider(registered_custom_provider: str, candidate: str | None, expected: bool) -> None: assert is_registered_custom_provider(candidate) is expected + + +def test_get_llm_provider_leaves_fal_ai_api_base_unset_for_global_fallback() -> None: + _, provider, _, api_base = get_llm_provider(model="fal_ai/fal-ai/flux/schnell") + assert provider == "fal_ai" + assert api_base is None + + _, _, _, explicit = get_llm_provider(model="fal_ai/fal-ai/flux/schnell", api_base="http://edge.local/fal") + assert explicit == "http://edge.local/fal" + + +def test_image_generation_fal_ai_egresses_to_global_api_base(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(litellm, "api_base", "http://gateway.local/fal") + monkeypatch.setenv("FAL_AI_API_KEY", "test") + seen: Final[list[httpx.URL]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request.url) + return httpx.Response(200, json={"images": [{"url": "https://fal.media/a.png"}]}) + + client: Final = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(handler))) + litellm.image_generation(model="fal_ai/fal-ai/flux/schnell", prompt="a red kite", client=client) + assert str(seen[0]).startswith("http://gateway.local/fal") diff --git a/tests/test_litellm/llms/fal_ai/chat/test_fal_ai_chat_transformation.py b/tests/test_litellm/llms/fal_ai/chat/test_fal_ai_chat_transformation.py index 41e8fc0c8c5..b7a79f03f8d 100644 --- a/tests/test_litellm/llms/fal_ai/chat/test_fal_ai_chat_transformation.py +++ b/tests/test_litellm/llms/fal_ai/chat/test_fal_ai_chat_transformation.py @@ -249,6 +249,23 @@ def test_map_openai_params_drops_unknown_reasoning_effort_when_dropping(): assert "reasoning" not in mapped +@pytest.mark.parametrize("effort", [{"level": "low"}, ["low"], 1]) +def test_map_openai_params_rejects_non_string_reasoning_effort(effort: object) -> None: + with pytest.raises(FalAIError) as exc_info: + FalAIChatConfig().map_openai_params( + non_default_params={"reasoning_effort": effort}, optional_params={}, model=MODEL, drop_params=False + ) + assert exc_info.value.status_code == 400 + + +@pytest.mark.parametrize("effort", [{"level": "low"}, ["low"], 1]) +def test_map_openai_params_drops_non_string_reasoning_effort_when_dropping(effort: object) -> None: + mapped = FalAIChatConfig().map_openai_params( + non_default_params={"reasoning_effort": effort}, optional_params={}, model=MODEL, drop_params=True + ) + assert "reasoning" not in mapped + + def test_map_openai_params_maps_sampling_params(): mapped = FalAIChatConfig().map_openai_params( non_default_params={"temperature": 0.5, "top_p": 0.7, "max_tokens": 10},