mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
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:
parent
4677f1028e
commit
9be3c1b168
7 changed files with 126 additions and 11 deletions
|
|
@ -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":
|
||||
(
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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() == ()
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue