fix(fal_ai): honour global api_base for image generation and reject non-string reasoning_effort with 400 (#42512)

* fix(fal_ai): honour global api_base for image generation and reject non-string reasoning_effort with 400

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(fal_ai): add return annotations to reasoning_effort regression tests

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(fal_ai): move the LIT-8340 repro from tests/e2e to tests/integration

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: kerry <kerry@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-09-22 13:13:54 -07:00 • committed by GitHub
parent 4677f1028e
commit 9be3c1b168
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 126 additions and 11 deletions

View file

@ -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":
(

View file

@ -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"):

View file

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

View file

@ -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() == ()

View file

@ -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")]

View file

@ -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")

View file

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