mirror of
https://github.com/tiennm99/litellm.git
synced 2026-06-17 14:48:44 +00:00
12d29a38a7
* test(proxy/proxy_server): pin forwarding routes (PR2) (#28887) * test(proxy): pin proxy_server.py forwarding-route behavior PR2 of the proxy_server.py behavior-pinning project: fills the 12 forwarding-route test files added by the harness PR with happy + error pins for all 52 LLM-facing routes (models, chat/completions, completions, embeddings, moderations, audio, assistants, threads, utils, model-info, model-metrics, queue). Every happy-path test asserts the full response dict via normalize() so the gate enforces real shape pinning rather than status codes. * test(proxy): drop task-plumbing comments from PR2 test files * test(proxy): tighten PR2 error-path status-code pins Apply the same review feedback Greptile gave on PR1 (#28856) and PR3 (#28850) to PR2's forwarding-route tests: - Replace permissive `>= 400` / `in (X, Y)` status assertions with the exact 500/405 the handler actually returns, so a regression that silently shifts the code now fails the pin. - Add a body-presence check alongside each tightened status assertion to satisfy _pin_check.py's no-status-only rule. --------- Co-authored-by: Claude <noreply@anthropic.com> * test(proxy): pin proxy_server.py non-route surface behavior (PR1) (#28856) * test(proxy): pin proxy_server.py non-route surface behavior (PR1) Fills the 7 PR1 placeholder files under tests/test_litellm/proxy/proxy_server/ with behavior pins for the non-route surface of proxy_server.py: lifecycle/init/shutdown, ProxyConfig class methods, DB-overlay config scrubbers, spend counters, background-health helpers, OpenAPI customization, exception handlers, and streaming-generator helpers. 233 tests cover 101 pin-list symbols (1+ happy + 1+ error each). New-tests-only coverage on litellm/proxy/proxy_server.py: 32.80% line / 20.91% branch (PR1 gate: 25% line / 18% branch). Full directory runs in ~22s with -n 4. Plan: https://www.notion.so/Plan-Pin-proxy_server-py-behavior-2026-05-25-36c43b8acdab81ee845fd5365128a2fc * test(proxy): address Greptile review comments on test_lifecycle.py - test_initialize_signature_is_async_with_expected_params: hard-code expected_param_count so a signature change actually trips the gate (previously both sides of the comparison were len(sig.parameters)). - test_check_request_disconnection_invalid_when_connected_times_out: patch asyncio.sleep so the test no longer spins for ~1.2 s of real wall-clock; timeout lowered to 0.05 s. --------- Co-authored-by: Claude <noreply@anthropic.com> * test(proxy/proxy_server): pin control-plane routes (PR3) (#28850) * test(proxy/proxy_server): pin misc routes (PR3, partial) Adds happy + error tests for the misc control-plane routes: GET /, /routes, /adaptive_router/state, /get_logo_url, /get_image, /get_favicon. Also gitignores .pin_list.txt (used by the pin gate). * test(proxy/proxy_server): pin login/SSO routes (PR3, partial) Adds happy + error tests for the 5 login/SSO control-plane routes: GET /fallback/login, POST /login, POST /v2/login, POST /v3/login, POST /v3/login/exchange. Mocks authenticate_user and create_ui_token_object at their imported location. * test(proxy/proxy_server): pin onboarding routes (PR3, partial) Adds happy + error tests for the 2 onboarding control-plane routes: GET /onboarding/get_token, POST /onboarding/claim_token. Wires a MagicMock async context manager for prisma_client.db.tx() and signs the onboarding JWT with the patched master_key. * test(proxy/proxy_server): pin model_cost_map reload routes (PR3, partial) Adds happy + error tests for the 5 model-cost-map control-plane routes: POST /reload/model_cost_map, POST|DELETE|GET /schedule/model_cost_map_reload(/status), GET /model/cost_map/source. Attaches litellm_config to mock_prisma per-test (the table is not in the default _PRISMA_TABLES fixture). * test(proxy/proxy_server): pin anthropic_beta_headers reload routes (PR3, partial) Adds happy + error tests for the 4 anthropic-beta-headers control-plane routes: POST /reload/anthropic_beta_headers, POST|DELETE|GET /schedule/anthropic_beta_headers_reload(/status). Stubs db.litellm_config (not in default _PRISMA_TABLES) and monkeypatches reload_beta_headers_config so no network calls fire. * test(proxy/proxy_server): pin invitation routes (PR3, partial) Adds happy + error tests for the 4 invitation control-plane routes: POST /invitation/new, GET /invitation/info, POST /invitation/update, POST /invitation/delete. Patches _user_has_admin_privileges / _user_has_admin_view to avoid extensive get_user_object mocking. * test(proxy/proxy_server): pin config CRUD routes (PR3, partial) Adds happy + error tests for the 8 config-CRUD control-plane routes: POST /config/update, POST|GET /config/field/update|info, GET /config/list, POST /config/field/delete, POST /config/callback/delete, GET /get/config/callbacks, GET /config/yaml. Attaches litellm_config to mock_prisma per-test. * test(proxy/proxy_server): tighten pin assertions per review - test_routes_misc.py: `b"" in response.content` is trivially true; replace with `len(response.content) > 0` so an empty 405 body trips the gate. - test_routes_login_sso.py: `len(response.content) >= 0` is trivially true; tighten to `> 0`. - test_routes_anthropic_beta.py: replace brittle string-literal checks on the serialized JSON (`'"interval_hours": 12' in payload`) with `json.loads` + dict access so the assertion survives any serializer spacing. - test_routes_config.py: `assert status_code in (404, 500)` was too permissive; the handler re-raises HTTPException(404) verbatim, so pin 404 strictly. --------- Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
448 lines
15 KiB
Python
448 lines
15 KiB
Python
"""Behavior pins for proxy_server OpenAPI customization + CORS helpers.
|
|
|
|
Pins covered:
|
|
- ``_generate_stable_operation_id``
|
|
- ``_strip_operation_id_method_suffix``
|
|
- ``ensure_unique_openapi_operation_ids``
|
|
- ``_inject_websocket_stubs_into_openapi_schema``
|
|
- ``get_openapi_schema``
|
|
- ``custom_openapi``
|
|
- ``mount_swagger_ui``
|
|
- ``_get_cors_config``
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
|
|
from litellm.proxy.proxy_server import (
|
|
_generate_stable_operation_id,
|
|
_get_cors_config,
|
|
_inject_websocket_stubs_into_openapi_schema,
|
|
_strip_operation_id_method_suffix,
|
|
custom_openapi,
|
|
ensure_unique_openapi_operation_ids,
|
|
get_openapi_schema,
|
|
mount_swagger_ui,
|
|
)
|
|
|
|
from .conftest import normalize
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _generate_stable_operation_id
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_generate_stable_operation_id_single_method_appends_suffix():
|
|
route = SimpleNamespace(
|
|
name="list_models",
|
|
path_format="/v1/models",
|
|
methods={"GET"},
|
|
)
|
|
observed = {
|
|
"operation_id": _generate_stable_operation_id(route),
|
|
"name": route.name,
|
|
"path": route.path_format,
|
|
}
|
|
assert normalize(observed) == {
|
|
"operation_id": "list_models_v1_models_get",
|
|
"name": "list_models",
|
|
"path": "/v1/models",
|
|
}
|
|
|
|
|
|
def test_generate_stable_operation_id_multi_method_no_suffix():
|
|
route = SimpleNamespace(
|
|
name="multi_op",
|
|
path_format="/v1/things/{id}",
|
|
methods={"GET", "POST"},
|
|
)
|
|
observed = {
|
|
"operation_id": _generate_stable_operation_id(route),
|
|
"method_count": len(route.methods),
|
|
"has_method_suffix": _generate_stable_operation_id(route).endswith(
|
|
("_get", "_post")
|
|
),
|
|
}
|
|
assert normalize(observed) == {
|
|
"operation_id": "multi_op_v1_things__id_",
|
|
"method_count": 2,
|
|
"has_method_suffix": False,
|
|
}
|
|
|
|
|
|
def test_generate_stable_operation_id_missing_attrs_raises_error():
|
|
bad_route = SimpleNamespace() # missing name/path_format/methods
|
|
with pytest.raises(AttributeError):
|
|
_generate_stable_operation_id(bad_route)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _strip_operation_id_method_suffix
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_strip_operation_id_method_suffix_removes_known_method():
|
|
observed = {
|
|
"with_get": _strip_operation_id_method_suffix("list_models_v1_models_get"),
|
|
"with_post": _strip_operation_id_method_suffix("create_thing_post"),
|
|
"with_delete": _strip_operation_id_method_suffix("drop_thing_delete"),
|
|
}
|
|
assert observed == {
|
|
"with_get": "list_models_v1_models",
|
|
"with_post": "create_thing",
|
|
"with_delete": "drop_thing",
|
|
}
|
|
|
|
|
|
def test_strip_operation_id_method_suffix_invalid_suffix_unchanged():
|
|
# "foo" is not a known HTTP method; "nounderscore" has no separator at all.
|
|
observed = {
|
|
"unknown_suffix": _strip_operation_id_method_suffix("operation_foo"),
|
|
"no_underscore": _strip_operation_id_method_suffix("nounderscore"),
|
|
"empty": _strip_operation_id_method_suffix(""),
|
|
}
|
|
assert observed == {
|
|
"unknown_suffix": "operation_foo",
|
|
"no_underscore": "nounderscore",
|
|
"empty": "",
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ensure_unique_openapi_operation_ids
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_ensure_unique_openapi_operation_ids_rewrites_duplicates():
|
|
schema = {
|
|
"paths": {
|
|
"/a": {"get": {"operationId": "dup_get"}},
|
|
"/b": {"get": {"operationId": "dup_get"}},
|
|
"/c": {"post": {"operationId": "unique_post"}},
|
|
}
|
|
}
|
|
result = ensure_unique_openapi_operation_ids(schema)
|
|
observed = {
|
|
"a_get": result["paths"]["/a"]["get"]["operationId"],
|
|
"b_get": result["paths"]["/b"]["get"]["operationId"],
|
|
"c_post": result["paths"]["/c"]["post"]["operationId"],
|
|
"ids_are_distinct": len(
|
|
{
|
|
result["paths"]["/a"]["get"]["operationId"],
|
|
result["paths"]["/b"]["get"]["operationId"],
|
|
result["paths"]["/c"]["post"]["operationId"],
|
|
}
|
|
)
|
|
== 3,
|
|
}
|
|
assert normalize(observed) == {
|
|
"a_get": "dup_get",
|
|
"b_get": "dup_get_2",
|
|
"c_post": "unique_post",
|
|
"ids_are_distinct": True,
|
|
}
|
|
|
|
|
|
def test_ensure_unique_openapi_operation_ids_respects_reserved():
|
|
# operationId already ends with "_get" (an HTTP method), so the suffix is
|
|
# stripped before re-appending the current method, yielding "reserved_get".
|
|
schema = {
|
|
"paths": {
|
|
"/a": {"get": {"operationId": "reserved_get"}},
|
|
}
|
|
}
|
|
reserved = {"reserved_get"}
|
|
result = ensure_unique_openapi_operation_ids(
|
|
schema, reserved_operation_ids=reserved
|
|
)
|
|
observed = {
|
|
"rewritten": result["paths"]["/a"]["get"]["operationId"],
|
|
"still_includes_original": "reserved_get" in reserved,
|
|
"reserved_grew": len(reserved) > 1,
|
|
}
|
|
assert normalize(observed) == {
|
|
"rewritten": "reserved_get_2",
|
|
"still_includes_original": True,
|
|
"reserved_grew": True,
|
|
}
|
|
|
|
|
|
def test_ensure_unique_openapi_operation_ids_missing_paths_invalid_returns_empty():
|
|
"""No ``paths`` key — function must not crash and must return the schema as-is."""
|
|
schema = {"info": {"title": "x"}}
|
|
result = ensure_unique_openapi_operation_ids(schema)
|
|
assert result is schema
|
|
assert "paths" not in result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _inject_websocket_stubs_into_openapi_schema
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_inject_websocket_stubs_into_openapi_schema_adds_stub():
|
|
schema = {"paths": {}}
|
|
route = SimpleNamespace(path="/ws/chat", name="ws_chat", dependant=None)
|
|
result = _inject_websocket_stubs_into_openapi_schema(schema, [route])
|
|
stub = result["paths"]["/ws/chat"]["get"]
|
|
assert normalize(stub) == {
|
|
"summary": "WebSocket: ws_chat",
|
|
"description": "WebSocket connection endpoint",
|
|
"operationId": "websocket_ws_chat",
|
|
"parameters": [],
|
|
"responses": {"101": {"description": "WebSocket Protocol Switched"}},
|
|
"tags": ["WebSocket"],
|
|
}
|
|
|
|
|
|
def test_inject_websocket_stubs_into_openapi_schema_does_not_overwrite_existing_get():
|
|
# Existing GET on the same path must not be replaced by the stub.
|
|
existing_get = {"summary": "real http get", "operationId": "real_get"}
|
|
schema = {"paths": {"/ws/chat": {"get": existing_get}}}
|
|
route = SimpleNamespace(path="/ws/chat", name="ws_chat", dependant=None)
|
|
result = _inject_websocket_stubs_into_openapi_schema(schema, [route])
|
|
assert result["paths"]["/ws/chat"]["get"] is existing_get
|
|
|
|
|
|
def test_inject_websocket_stubs_into_openapi_schema_missing_paths_key_raises_error():
|
|
schema = {} # no "paths" key — setdefault on missing schema["paths"] will KeyError
|
|
route = SimpleNamespace(path="/ws/x", name="ws_x", dependant=None)
|
|
with pytest.raises(KeyError):
|
|
_inject_websocket_stubs_into_openapi_schema(schema, [route])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_openapi_schema
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_get_openapi_schema_returns_well_formed_schema(monkeypatch):
|
|
"""Patch ps.app to a fresh FastAPI so we get a deterministic minimal schema
|
|
without depending on whatever the session app currently has cached."""
|
|
import litellm.proxy.proxy_server as ps
|
|
|
|
fresh = FastAPI(title="pinned-title", version="0.0.1")
|
|
|
|
@fresh.get("/ping")
|
|
def _ping():
|
|
return {"ok": True}
|
|
|
|
monkeypatch.setattr(ps, "app", fresh, raising=True)
|
|
schema = get_openapi_schema()
|
|
observed = {
|
|
"openapi_present": "openapi" in schema,
|
|
"has_paths": isinstance(schema.get("paths"), dict),
|
|
"has_info": isinstance(schema.get("info"), dict),
|
|
"title": schema["info"]["title"],
|
|
"ping_path_in_schema": "/ping" in schema["paths"],
|
|
}
|
|
assert normalize(observed) == {
|
|
"openapi_present": True,
|
|
"has_paths": True,
|
|
"has_info": True,
|
|
"title": "pinned-title",
|
|
"ping_path_in_schema": True,
|
|
}
|
|
|
|
|
|
def test_get_openapi_schema_returns_cached_when_present(monkeypatch):
|
|
"""When the patched app already has openapi_schema set, the function
|
|
returns it untouched (no regeneration)."""
|
|
import litellm.proxy.proxy_server as ps
|
|
|
|
fresh = FastAPI()
|
|
sentinel = {"openapi": "3.0.0", "paths": {}, "info": {"title": "cached"}}
|
|
fresh.openapi_schema = sentinel
|
|
monkeypatch.setattr(ps, "app", fresh, raising=True)
|
|
result = get_openapi_schema()
|
|
observed = {
|
|
"is_sentinel": result is sentinel,
|
|
"title": result["info"]["title"],
|
|
"paths_empty": result["paths"] == {},
|
|
}
|
|
assert normalize(observed) == {
|
|
"is_sentinel": True,
|
|
"title": "cached",
|
|
"paths_empty": True,
|
|
}
|
|
|
|
|
|
def test_get_openapi_schema_missing_app_attribute_raises_error(monkeypatch):
|
|
"""If the module-level ``app`` is replaced by something without
|
|
``openapi_schema`` and without ``routes``, the function fails fast."""
|
|
import litellm.proxy.proxy_server as ps
|
|
|
|
monkeypatch.setattr(ps, "app", SimpleNamespace(), raising=True)
|
|
with pytest.raises(AttributeError):
|
|
get_openapi_schema()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# custom_openapi
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_custom_openapi_filters_to_openai_routes(monkeypatch):
|
|
"""custom_openapi() filters paths down to the OpenAI-compatible set and
|
|
caches the result on the patched app."""
|
|
import litellm.proxy.proxy_server as ps
|
|
|
|
fresh = FastAPI(title="pinned-custom", version="0.0.1")
|
|
|
|
@fresh.get("/ping")
|
|
def _ping():
|
|
return {"ok": True}
|
|
|
|
monkeypatch.setattr(ps, "app", fresh, raising=True)
|
|
schema = custom_openapi()
|
|
observed = {
|
|
"openapi_present": "openapi" in schema,
|
|
"paths_is_dict": isinstance(schema.get("paths"), dict),
|
|
"info_title": schema["info"]["title"],
|
|
"cached_now": fresh.openapi_schema is schema,
|
|
"non_openai_path_filtered": "/ping" not in schema["paths"],
|
|
}
|
|
assert normalize(observed) == {
|
|
"openapi_present": True,
|
|
"paths_is_dict": True,
|
|
"info_title": "pinned-custom",
|
|
"cached_now": True,
|
|
"non_openai_path_filtered": True,
|
|
}
|
|
|
|
|
|
def test_custom_openapi_returns_cached_when_present(monkeypatch):
|
|
import litellm.proxy.proxy_server as ps
|
|
|
|
fresh = FastAPI()
|
|
sentinel = {"openapi": "3.0.0", "paths": {}, "info": {"title": "cached"}}
|
|
fresh.openapi_schema = sentinel
|
|
monkeypatch.setattr(ps, "app", fresh, raising=True)
|
|
result = custom_openapi()
|
|
observed = {
|
|
"is_sentinel": result is sentinel,
|
|
"title": result["info"]["title"],
|
|
"paths_empty": result["paths"] == {},
|
|
}
|
|
assert normalize(observed) == {
|
|
"is_sentinel": True,
|
|
"title": "cached",
|
|
"paths_empty": True,
|
|
}
|
|
|
|
|
|
def test_custom_openapi_missing_app_attribute_raises_error(monkeypatch):
|
|
import litellm.proxy.proxy_server as ps
|
|
|
|
monkeypatch.setattr(ps, "app", SimpleNamespace(), raising=True)
|
|
with pytest.raises(AttributeError):
|
|
custom_openapi()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# mount_swagger_ui
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_mount_swagger_ui_mounts_static_route(monkeypatch):
|
|
"""mount_swagger_ui mutates the global app — patch the module's `app` to a
|
|
fresh FastAPI() so we don't pollute the session app's mount table."""
|
|
import litellm.proxy.proxy_server as ps
|
|
from fastapi import applications as fa_applications
|
|
|
|
fresh_app = FastAPI()
|
|
monkeypatch.setattr(ps, "app", fresh_app, raising=True)
|
|
original_get_swagger = fa_applications.get_swagger_ui_html
|
|
|
|
try:
|
|
mount_swagger_ui()
|
|
finally:
|
|
# Restore the swagger monkey-patch so other tests are unaffected.
|
|
fa_applications.get_swagger_ui_html = original_get_swagger
|
|
|
|
mount_names = [getattr(r, "name", None) for r in fresh_app.routes]
|
|
observed = {
|
|
"swagger_mounted": "swagger" in mount_names,
|
|
"patched_get_swagger": (
|
|
fa_applications.get_swagger_ui_html is original_get_swagger
|
|
),
|
|
"route_count_positive": len(fresh_app.routes) > 0,
|
|
}
|
|
assert normalize(observed) == {
|
|
"swagger_mounted": True,
|
|
"patched_get_swagger": True,
|
|
"route_count_positive": True,
|
|
}
|
|
|
|
|
|
def test_mount_swagger_ui_missing_directory_raises_error(monkeypatch, tmp_path):
|
|
"""If the swagger directory is missing, StaticFiles raises RuntimeError."""
|
|
import litellm.proxy.proxy_server as ps
|
|
from fastapi import applications as fa_applications
|
|
|
|
fresh_app = FastAPI()
|
|
monkeypatch.setattr(ps, "app", fresh_app, raising=True)
|
|
monkeypatch.setattr(
|
|
ps, "current_dir", str(tmp_path / "does_not_exist"), raising=True
|
|
)
|
|
original_get_swagger = fa_applications.get_swagger_ui_html
|
|
|
|
try:
|
|
with pytest.raises(RuntimeError):
|
|
mount_swagger_ui()
|
|
finally:
|
|
fa_applications.get_swagger_ui_html = original_get_swagger
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _get_cors_config
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_get_cors_config_explicit_origins_and_credentials():
|
|
origins, allow_creds = _get_cors_config(
|
|
cors_origins_env="https://a.example,https://b.example",
|
|
cors_credentials_env="true",
|
|
)
|
|
observed = {
|
|
"origins": origins,
|
|
"allow_credentials": allow_creds,
|
|
"origin_count": len(origins),
|
|
}
|
|
assert normalize(observed) == {
|
|
"origins": ["https://a.example", "https://b.example"],
|
|
"allow_credentials": True,
|
|
"origin_count": 2,
|
|
}
|
|
|
|
|
|
def test_get_cors_config_wildcard_defaults_credentials_false(monkeypatch):
|
|
# Clear env to ensure we test the default branch deterministically.
|
|
monkeypatch.delenv("LITELLM_CORS_ORIGINS", raising=False)
|
|
monkeypatch.delenv("LITELLM_CORS_ALLOW_CREDENTIALS", raising=False)
|
|
origins, allow_creds = _get_cors_config()
|
|
observed = {
|
|
"origins": origins,
|
|
"allow_credentials": allow_creds,
|
|
"wildcard_in_origins": "*" in origins,
|
|
}
|
|
assert normalize(observed) == {
|
|
"origins": ["*"],
|
|
"allow_credentials": False,
|
|
"wildcard_in_origins": True,
|
|
}
|
|
|
|
|
|
def test_get_cors_config_invalid_credentials_value_treated_as_false():
|
|
"""Anything other than the literal "true" (case-insensitive) is false —
|
|
misconfigured strings should not silently enable credentialed CORS."""
|
|
_, allow_creds = _get_cors_config(
|
|
cors_origins_env="https://a.example",
|
|
cors_credentials_env="yes-please",
|
|
)
|
|
assert allow_creds is False
|