Files
litellm/tests/test_litellm/proxy/proxy_server/test_openapi_customization.py
T
yuneng-jiang 12d29a38a7 tests(proxy_server): surface current behavior in tests (#29309)
* 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>
2026-05-29 23:17:24 -07:00

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