litellm/tests/unit/containers/test_endpoint_factory.py
Mateo Wang 8b9cd12a89
test: catch regressions in the validation lines #44798 added (#47420)
* test: pin the validation lines #44798 kept with failing cases

* fix(containers): accept endpoint table entries that omit their param lists

* test: keep the endpoint table and xAI adapter as on main

The endpoint table ships inside the package, so optional param lists fixed no
reachable state and turned a loud failure into silently dropped params. Drops
the Gemini case that main's tests already cover.

* test: cover the cost map backup and Gemini image config validation lines

The cost map backup adapter's hide flag, and the validation itself, could be
removed with every test still green. The Gemini imageConfig adapter could be
narrowed with only the integration suite noticing, which does not run on PRs.

The endpoint table cases now read a real file through the module's location
instead of replacing open, so reading the file another way keeps them green.

* test: pin the container handler's loud table failure and loosen two pinned drops

A container endpoint table entry without path_params or query_params fails
the call on main, and no test held that. The new case trims each key from the
packaged table and expects the validation error.

The Gemini imageConfig case now accepts a drop or UnsupportedParamsError for a
non-object value, and the Bedrock beta case checks only that numbers come back
as strings, so neither pins behavior the drop_params convention may change.

* test: check a numeric beta array is never split as text and use the latest Gemini image model

The Bedrock case now also fails when a JSON array of numbers falls through to
the comma split, which returns bracketed fragments, while a drop or a string
coercion both stay allowed.
2026-10-20 01:06:37 +00:00

164 lines
6.4 KiB
Python

import json
from pathlib import Path
from typing import Final
import pytest
from pydantic import ValidationError
from litellm.containers import endpoint_factory
from litellm.containers.endpoint_factory import (
RESPONSE_TYPES,
_load_endpoints_config,
create_sync_endpoint_function,
generate_container_endpoints,
get_all_endpoint_names,
get_async_endpoint_names,
)
from litellm.types.containers.main import (
ContainerFileListResponse,
ContainerFileObject,
DeleteContainerFileResponse,
)
_SYNC_NAMES = [
"list_container_files",
"upload_container_file",
"retrieve_container_file",
"delete_container_file",
"retrieve_container_file_content",
]
_ASYNC_NAMES = ["a" + n for n in _SYNC_NAMES]
class TestEndpointsConfig:
def test_config_exposes_every_declared_endpoint(self):
config = _load_endpoints_config()
assert [e["name"] for e in config["endpoints"]] == _SYNC_NAMES
def test_every_endpoint_declares_the_keys_the_factory_reads(self):
for endpoint in _load_endpoints_config()["endpoints"]:
assert set(endpoint) >= {
"name",
"async_name",
"path",
"method",
"path_params",
"response_type",
}
def test_async_name_is_the_sync_name_prefixed_with_a(self):
for endpoint in _load_endpoints_config()["endpoints"]:
assert endpoint["async_name"] == "a" + endpoint["name"]
def test_config_is_reread_rather_than_shared_between_callers(self):
first = _load_endpoints_config()
second = _load_endpoints_config()
assert first is not second
assert first["endpoints"] is not second["endpoints"]
assert first == second
@pytest.mark.parametrize("document", ['["only-in-the-file"]', '"only-in-the-file"', "null"])
def test_a_packaged_file_that_is_not_a_json_object_is_rejected_without_quoting_it(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, document: str
):
(tmp_path / "endpoints.json").write_text(document)
monkeypatch.setattr(endpoint_factory, "__file__", str(tmp_path / "endpoint_factory.py"))
with pytest.raises(ValidationError) as rejected:
_load_endpoints_config()
assert "only-in-the-file" not in str(rejected.value)
def test_a_packaged_json_object_is_returned_as_written(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
document: Final = {"endpoints": [{"name": "list_container_files"}], "schema_version": 2}
(tmp_path / "endpoints.json").write_text(json.dumps(document))
monkeypatch.setattr(endpoint_factory, "__file__", str(tmp_path / "endpoint_factory.py"))
assert _load_endpoints_config() == document
class TestResponseTypeMapping:
def test_mapping_resolves_every_named_response_type(self):
assert RESPONSE_TYPES == {
"ContainerFileListResponse": ContainerFileListResponse,
"ContainerFileObject": ContainerFileObject,
"DeleteContainerFileResponse": DeleteContainerFileResponse,
}
@pytest.mark.parametrize(
"endpoint_name,expected",
[
("list_container_files", ContainerFileListResponse),
("upload_container_file", ContainerFileObject),
("retrieve_container_file", ContainerFileObject),
("delete_container_file", DeleteContainerFileResponse),
],
)
def test_each_endpoint_maps_to_its_declared_response_type(self, endpoint_name, expected):
config = next(e for e in _load_endpoints_config()["endpoints"] if e["name"] == endpoint_name)
assert RESPONSE_TYPES[config["response_type"]] is expected
def test_raw_response_type_is_deliberately_unmapped(self):
config = next(
e for e in _load_endpoints_config()["endpoints"] if e["name"] == "retrieve_container_file_content"
)
assert config["response_type"] == "raw"
assert RESPONSE_TYPES.get(config["response_type"]) is None
class TestGeneratedEndpoints:
def test_generates_exactly_one_sync_and_one_async_function_per_endpoint(self):
assert set(generate_container_endpoints()) == set(_SYNC_NAMES) | set(_ASYNC_NAMES)
def test_every_generated_value_is_callable(self):
assert all(callable(f) for f in generate_container_endpoints().values())
def test_sync_and_async_entries_are_distinct_objects(self):
endpoints = generate_container_endpoints()
for name in _SYNC_NAMES:
assert endpoints[name] is not endpoints["a" + name]
def test_each_call_builds_fresh_functions(self):
assert (
generate_container_endpoints()["list_container_files"]
is not generate_container_endpoints()["list_container_files"]
)
def test_module_exports_are_wired_and_not_none(self):
for name in _SYNC_NAMES + _ASYNC_NAMES:
assert getattr(endpoint_factory, name) is not None
class TestEndpointNameHelpers:
def test_all_endpoint_names_interleaves_sync_then_async_per_endpoint(self):
expected = [n for name in _SYNC_NAMES for n in (name, "a" + name)]
assert get_all_endpoint_names() == expected
def test_async_endpoint_names_are_only_the_async_ones(self):
assert get_async_endpoint_names() == _ASYNC_NAMES
def test_async_names_are_a_strict_subset_of_all_names(self):
assert set(get_async_endpoint_names()) < set(get_all_endpoint_names())
class TestSyncEndpointFactory:
def test_returns_a_callable_for_a_minimal_config(self):
assert callable(
create_sync_endpoint_function({"name": "x", "response_type": "ContainerFileObject", "path_params": []})
)
def test_missing_path_params_defaults_to_empty_rather_than_raising(self):
assert callable(create_sync_endpoint_function({"name": "x", "response_type": "ContainerFileObject"}))
def test_unknown_response_type_is_tolerated_at_build_time(self):
assert callable(
create_sync_endpoint_function({"name": "x", "response_type": "NotARealType", "path_params": []})
)
def test_missing_name_is_a_build_time_error(self):
with pytest.raises(KeyError):
create_sync_endpoint_function({"response_type": "ContainerFileObject"})
def test_missing_response_type_is_a_build_time_error(self):
with pytest.raises(KeyError):
create_sync_endpoint_function({"name": "x"})