mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_/litellm-logs-ui-lag-0ca4b8
This commit is contained in:
commit
b03004a1eb
15 changed files with 983 additions and 615 deletions
|
|
@ -2320,6 +2320,9 @@ async def _run_centralized_common_checks(
|
|||
None if isinstance(global_spend_result, BaseException) else global_spend_result
|
||||
)
|
||||
|
||||
if user_api_key_auth_obj.org_id is None and team_object is not None and team_object.organization_id is not None:
|
||||
user_api_key_auth_obj.org_id = team_object.organization_id
|
||||
|
||||
# common_checks identifies admin via user_object, not the token
|
||||
# (non_proxy_admin_allowed_routes_check). JWT admin shortcut and
|
||||
# master_key tokens get admin from the token; the DB row for the
|
||||
|
|
|
|||
|
|
@ -38,11 +38,44 @@ from litellm.proxy.openai_files_endpoints.common_utils import (
|
|||
update_batch_in_database,
|
||||
)
|
||||
from litellm.proxy.utils import handle_exception_on_proxy, is_known_model
|
||||
from litellm.repositories.table_repositories import ManagedFileRepository
|
||||
from litellm.types.llms.openai import LiteLLMBatchCreateRequest
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _resolve_managed_input_file_storage_url(input_file_id: str) -> "str | None":
|
||||
"""Resolve a managed (unified) input_file_id to its backend storage_url.
|
||||
|
||||
Provider batch handlers (e.g. Vertex AI, which parses a `publishers/`
|
||||
segment out of the file URI) need a real storage location; the opaque
|
||||
unified token crashes them. Returns None only when there is no database or
|
||||
the row has no storage_url yet, so callers fall back to the original id
|
||||
(which the managed-files deployment hook can still map). Fails closed
|
||||
rather than dispatch a token that cannot be resolved: 404 when no
|
||||
managed-file row exists, 503 when the lookup itself errors so the caller
|
||||
can retry.
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
return None
|
||||
try:
|
||||
db_file = await ManagedFileRepository(prisma_client).table.find_first(where={"unified_file_id": input_file_id})
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.warning("create_batch: managed file lookup failed for %s: %s", input_file_id, e)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail={"error": "Could not resolve managed file; please retry"},
|
||||
)
|
||||
if db_file is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": f"Managed file not found: {input_file_id}"},
|
||||
)
|
||||
return db_file.storage_url or None
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{provider}/v1/batches",
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
|
|
@ -224,6 +257,11 @@ async def create_batch(
|
|||
)
|
||||
model = target_model_names[0]
|
||||
_create_batch_data["model"] = model
|
||||
|
||||
resolved_storage_url = await _resolve_managed_input_file_storage_url(input_file_id)
|
||||
if resolved_storage_url is not None:
|
||||
_create_batch_data["input_file_id"] = resolved_storage_url
|
||||
|
||||
if llm_router is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
|
|
|
|||
|
|
@ -412,6 +412,19 @@ class HeadroomGuardrail(CustomGuardrail):
|
|||
)
|
||||
if key in body
|
||||
}
|
||||
tokens_before = stats.get("tokens_before")
|
||||
tokens_after = stats.get("tokens_after")
|
||||
if (
|
||||
"tokens_saved" not in stats
|
||||
and isinstance(tokens_before, (int, float))
|
||||
and not isinstance(tokens_before, bool)
|
||||
and isinstance(tokens_after, (int, float))
|
||||
and not isinstance(tokens_after, bool)
|
||||
):
|
||||
# Spend tracking (extract_compression_saved_tokens) reads only
|
||||
# tokens_saved, which the live compression service omits; derive it
|
||||
# so savings are counted, but let a service-sent value win.
|
||||
stats["tokens_saved"] = tokens_before - tokens_after
|
||||
return filtered, True, stats
|
||||
|
||||
async def _call_retrieve(self, hash_value: str, query: str | None = None) -> str:
|
||||
|
|
|
|||
|
|
@ -3796,6 +3796,167 @@ async def test_centralized_common_checks_user_http_exception_isolates_to_user_on
|
|||
setattr(_proxy_server_mod, k, v)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"key_org_id,team_org_id,expected_org_id",
|
||||
[
|
||||
(None, "org-from-team", "org-from-team"),
|
||||
("org-pinned-on-key", "org-from-team", "org-pinned-on-key"),
|
||||
(None, None, None),
|
||||
],
|
||||
)
|
||||
async def test_centralized_common_checks_backfills_org_id_from_team(key_org_id, team_org_id, expected_org_id):
|
||||
"""LIT-4688 regression: a key minted without an organization_id but attached
|
||||
to an org-linked team must leave auth with org_id set from the team, so the
|
||||
spend writer (which reads user_api_key_dict.org_id, no team fallback)
|
||||
credits the org and the org budget cap can actually trip. A key with an
|
||||
explicitly pinned org_id must win over the team's org."""
|
||||
import litellm.proxy.proxy_server as _proxy_server_mod
|
||||
from fastapi import Request
|
||||
from starlette.datastructures import URL
|
||||
|
||||
from litellm.proxy._types import LiteLLM_TeamTableCachedObj
|
||||
|
||||
token = UserAPIKeyAuth(api_key="sk-test", user_id="u", team_id="t1", org_id=key_org_id)
|
||||
request = Request(scope={"type": "http"})
|
||||
request._url = URL(url="/chat/completions")
|
||||
|
||||
fetched_team = LiteLLM_TeamTableCachedObj(team_id="t1", organization_id=team_org_id)
|
||||
|
||||
attrs = _proxy_attrs_for_centralized_checks(user_custom_auth=None)
|
||||
originals = {a: getattr(_proxy_server_mod, a, None) for a in attrs}
|
||||
try:
|
||||
for k, v in attrs.items():
|
||||
setattr(_proxy_server_mod, k, v)
|
||||
org_id_seen_by_common_checks = []
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.auth.user_api_key_auth.get_team_object",
|
||||
new_callable=AsyncMock,
|
||||
return_value=fetched_team,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.auth.user_api_key_auth.common_checks",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=lambda **kw: org_id_seen_by_common_checks.append(kw["valid_token"].org_id),
|
||||
) as mock_checks,
|
||||
):
|
||||
await _run_centralized_common_checks(
|
||||
user_api_key_auth_obj=token,
|
||||
request=request,
|
||||
request_data={"model": "gpt-4o"},
|
||||
route="/chat/completions",
|
||||
)
|
||||
|
||||
mock_checks.assert_awaited_once()
|
||||
assert token.org_id == expected_org_id
|
||||
assert org_id_seen_by_common_checks == [expected_org_id]
|
||||
finally:
|
||||
for k, v in originals.items():
|
||||
setattr(_proxy_server_mod, k, v)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cli_session_token_org_backfilled_from_team(monkeypatch):
|
||||
"""LIT-4688 root cause: CLI session tokens (from /sso/cli/poll) are minted
|
||||
with a real team_id but no org_id, and their auth path decrypts the blob
|
||||
without the combined_view team join, so their spend never reached the org.
|
||||
The centralized-checks backfill must complete the credential from the team
|
||||
the same way the SQL view does for DB keys."""
|
||||
import litellm.proxy.proxy_server as _proxy_server_mod
|
||||
from fastapi import Request
|
||||
from starlette.datastructures import URL
|
||||
|
||||
from litellm.proxy._types import LiteLLM_TeamTableCachedObj, LiteLLM_UserTable
|
||||
from litellm.proxy.auth.auth_checks import ExperimentalUIJWTToken
|
||||
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-test-salt-lit4688")
|
||||
|
||||
cli_user = LiteLLM_UserTable(user_id="cli-user", user_role="internal_user", teams=["t-cli"], models=[])
|
||||
blob = ExperimentalUIJWTToken.get_cli_jwt_auth_token(user_info=cli_user, team_id="t-cli", team_alias="cli-team")
|
||||
token = ExperimentalUIJWTToken.get_key_object_from_ui_hash_key(blob)
|
||||
assert token is not None
|
||||
assert token.is_session_token is True
|
||||
assert token.team_id == "t-cli"
|
||||
assert token.org_id is None
|
||||
|
||||
request = Request(scope={"type": "http"})
|
||||
request._url = URL(url="/chat/completions")
|
||||
|
||||
org_linked_team = LiteLLM_TeamTableCachedObj(team_id="t-cli", organization_id="org-infoops")
|
||||
|
||||
attrs = _proxy_attrs_for_centralized_checks(user_custom_auth=None)
|
||||
originals = {a: getattr(_proxy_server_mod, a, None) for a in attrs}
|
||||
try:
|
||||
for k, v in attrs.items():
|
||||
setattr(_proxy_server_mod, k, v)
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.auth.user_api_key_auth.get_team_object",
|
||||
new_callable=AsyncMock,
|
||||
return_value=org_linked_team,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.auth.user_api_key_auth.common_checks",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
):
|
||||
await _run_centralized_common_checks(
|
||||
user_api_key_auth_obj=token,
|
||||
request=request,
|
||||
request_data={"model": "gpt-4o"},
|
||||
route="/chat/completions",
|
||||
)
|
||||
|
||||
assert token.org_id == "org-infoops"
|
||||
finally:
|
||||
for k, v in originals.items():
|
||||
setattr(_proxy_server_mod, k, v)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_centralized_common_checks_org_backfill_survives_team_fetch_failure():
|
||||
"""When the team DB fetch fails, the token-derived fallback team carries no
|
||||
organization_id, so the backfill must leave org_id as None rather than
|
||||
crash or mis-attribute."""
|
||||
import litellm.proxy.proxy_server as _proxy_server_mod
|
||||
from fastapi import Request
|
||||
from starlette.datastructures import URL
|
||||
|
||||
token = UserAPIKeyAuth(api_key="sk-test", user_id="u", team_id="t1")
|
||||
request = Request(scope={"type": "http"})
|
||||
request._url = URL(url="/chat/completions")
|
||||
|
||||
attrs = _proxy_attrs_for_centralized_checks(user_custom_auth=None)
|
||||
originals = {a: getattr(_proxy_server_mod, a, None) for a in attrs}
|
||||
try:
|
||||
for k, v in attrs.items():
|
||||
setattr(_proxy_server_mod, k, v)
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.auth.user_api_key_auth.get_team_object",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=Exception("DB down"),
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.auth.user_api_key_auth.common_checks",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_checks,
|
||||
):
|
||||
await _run_centralized_common_checks(
|
||||
user_api_key_auth_obj=token,
|
||||
request=request,
|
||||
request_data={"model": "gpt-4o"},
|
||||
route="/chat/completions",
|
||||
)
|
||||
|
||||
mock_checks.assert_awaited_once()
|
||||
assert token.org_id is None
|
||||
finally:
|
||||
for k, v in originals.items():
|
||||
setattr(_proxy_server_mod, k, v)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_master_key_auth_substitutes_alias_for_api_key():
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -76,9 +76,7 @@ CREDS: Dict[str, Dict[str, str]] = {
|
|||
}
|
||||
|
||||
# A real model-encoded file id: decodes to "azure/gpt-4o", strips to "file-original123".
|
||||
AZURE_FILE_ID = encode_file_id_with_model(
|
||||
"file-original123", "azure/gpt-4o", id_type="file"
|
||||
)
|
||||
AZURE_FILE_ID = encode_file_id_with_model("file-original123", "azure/gpt-4o", id_type="file")
|
||||
|
||||
|
||||
def make_batch(
|
||||
|
|
@ -166,9 +164,7 @@ def harness():
|
|||
|
||||
router = MagicMock(spec=Router)
|
||||
router.acreate_batch = AsyncMock(return_value=make_batch())
|
||||
router.get_deployment_credentials_with_provider = MagicMock(
|
||||
side_effect=_creds_lookup
|
||||
)
|
||||
router.get_deployment_credentials_with_provider = MagicMock(side_effect=_creds_lookup)
|
||||
|
||||
read_body = AsyncMock(side_effect=lambda request: body_holder["body"])
|
||||
pre_call = AsyncMock(side_effect=lambda **kw: (body_holder["body"], MagicMock()))
|
||||
|
|
@ -186,11 +182,7 @@ def harness():
|
|||
pre_call,
|
||||
)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch.object(
|
||||
ProxyBaseLLMRequestProcessing, "get_custom_headers", get_headers
|
||||
)
|
||||
)
|
||||
stack.enter_context(patch.object(ProxyBaseLLMRequestProcessing, "get_custom_headers", get_headers))
|
||||
stack.enter_context(
|
||||
patch.object(
|
||||
endpoints,
|
||||
|
|
@ -200,14 +192,13 @@ def harness():
|
|||
)
|
||||
stack.enter_context(patch.object(endpoints, "is_known_model", is_known_model))
|
||||
stack.enter_context(patch.object(litellm, "acreate_batch", litellm_acreate))
|
||||
stack.enter_context(
|
||||
patch.object(litellm, "enable_loadbalancing_on_batch_endpoints", False)
|
||||
)
|
||||
stack.enter_context(patch.object(litellm, "enable_loadbalancing_on_batch_endpoints", False))
|
||||
stack.enter_context(patch.object(proxy_server, "llm_router", router))
|
||||
stack.enter_context(patch.object(proxy_server, "proxy_logging_obj", logging))
|
||||
stack.enter_context(patch.object(proxy_server, "general_settings", {}))
|
||||
stack.enter_context(patch.object(proxy_server, "proxy_config", MagicMock()))
|
||||
stack.enter_context(patch.object(proxy_server, "version", "test-version"))
|
||||
stack.enter_context(patch.object(proxy_server, "prisma_client", None))
|
||||
|
||||
h = Harness(
|
||||
body=body_holder,
|
||||
|
|
@ -283,9 +274,7 @@ async def test_create__model_encoded_file_id(harness):
|
|||
}
|
||||
|
||||
# 4. OUTPUT SHAPE - ids re-encoded with the model; input_file_id restored.
|
||||
assert resp.id == encode_file_id_with_model(
|
||||
"batch-provider-id", "azure/gpt-4o", id_type="batch"
|
||||
)
|
||||
assert resp.id == encode_file_id_with_model("batch-provider-id", "azure/gpt-4o", id_type="batch")
|
||||
assert resp.input_file_id == AZURE_FILE_ID
|
||||
|
||||
|
||||
|
|
@ -307,12 +296,8 @@ async def test_create__model_encoded_file_id__encodes_output_and_error_ids(harne
|
|||
|
||||
resp = await call_create(harness)
|
||||
|
||||
assert resp.output_file_id == encode_file_id_with_model(
|
||||
"file-out-raw", "azure/gpt-4o"
|
||||
)
|
||||
assert resp.error_file_id == encode_file_id_with_model(
|
||||
"file-err-raw", "azure/gpt-4o"
|
||||
)
|
||||
assert resp.output_file_id == encode_file_id_with_model("file-out-raw", "azure/gpt-4o")
|
||||
assert resp.error_file_id == encode_file_id_with_model("file-err-raw", "azure/gpt-4o")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -358,9 +343,7 @@ async def test_create__model_from_body(harness):
|
|||
payload = harness.acreate_kwargs()
|
||||
assert payload["custom_llm_provider"] == "vertex_ai"
|
||||
assert payload["input_file_id"] == "file-plain"
|
||||
assert resp.id == encode_file_id_with_model(
|
||||
"batch-provider-id", "vertex-model", id_type="batch"
|
||||
)
|
||||
assert resp.id == encode_file_id_with_model("batch-provider-id", "vertex-model", id_type="batch")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -495,10 +478,9 @@ async def test_create__unified_file_id_single_model(harness):
|
|||
"completion_window": "24h",
|
||||
},
|
||||
)
|
||||
with patch.object(
|
||||
endpoints, "_is_base64_encoded_unified_file_id", return_value="unified-xyz"
|
||||
), patch.object(
|
||||
endpoints, "get_models_from_unified_file_id", return_value=["gpt-4o-mini"]
|
||||
with (
|
||||
patch.object(endpoints, "_is_base64_encoded_unified_file_id", return_value="unified-xyz"),
|
||||
patch.object(endpoints, "get_models_from_unified_file_id", return_value=["gpt-4o-mini"]),
|
||||
):
|
||||
resp = await call_create(harness)
|
||||
|
||||
|
|
@ -522,10 +504,9 @@ async def test_create__unified_file_id_not_exactly_one_model_400(harness, models
|
|||
"completion_window": "24h",
|
||||
},
|
||||
)
|
||||
with patch.object(
|
||||
endpoints, "_is_base64_encoded_unified_file_id", return_value="unified-xyz"
|
||||
), patch.object(
|
||||
endpoints, "get_models_from_unified_file_id", return_value=models
|
||||
with (
|
||||
patch.object(endpoints, "_is_base64_encoded_unified_file_id", return_value="unified-xyz"),
|
||||
patch.object(endpoints, "get_models_from_unified_file_id", return_value=models),
|
||||
):
|
||||
with pytest.raises(ProxyException) as exc:
|
||||
await call_create(harness)
|
||||
|
|
@ -535,6 +516,182 @@ async def test_create__unified_file_id_not_exactly_one_model_400(harness, models
|
|||
harness.litellm_acreate.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create__unified_file_id_resolves_real_storage_url(harness):
|
||||
"""A base64 unified_file_id is a LiteLLM-internal token, not a real
|
||||
provider-side file reference (e.g. Vertex AI's batch transformation parses
|
||||
a `publishers/` segment out of the file URI and crashes on the opaque
|
||||
base64 string). The real backend location (`storage_url`) must be looked
|
||||
up from LiteLLM_ManagedFileTable and substituted before dispatch.
|
||||
|
||||
Regression lock on the lookup key: LiteLLM_ManagedFileTable.unified_file_id
|
||||
stores the raw base64 file id (see schema.prisma and the enterprise
|
||||
managed-files hook, which queries with the raw id), NOT the decoded
|
||||
litellm_proxy:... string. Querying with the decoded string never matches
|
||||
and silently falls back."""
|
||||
set_body(
|
||||
harness,
|
||||
{
|
||||
"input_file_id": "litellm_proxy_unified_id",
|
||||
"endpoint": "/v1/chat/completions",
|
||||
"completion_window": "24h",
|
||||
},
|
||||
)
|
||||
|
||||
fake_db_file = MagicMock(
|
||||
storage_url="gs://bucket/litellm-vertex-files/publishers/google/models/gemini-2.0/abc",
|
||||
)
|
||||
find_first = AsyncMock(return_value=fake_db_file)
|
||||
fake_repo_instance = MagicMock()
|
||||
fake_repo_instance.table.find_first = find_first
|
||||
fake_repo_cls = MagicMock(return_value=fake_repo_instance)
|
||||
|
||||
with (
|
||||
patch.object(endpoints, "_is_base64_encoded_unified_file_id", return_value="unified-xyz"),
|
||||
patch.object(endpoints, "get_models_from_unified_file_id", return_value=["gemini-2.0"]),
|
||||
patch.object(proxy_server, "prisma_client", MagicMock()),
|
||||
patch.object(endpoints, "ManagedFileRepository", fake_repo_cls),
|
||||
):
|
||||
resp = await call_create(harness)
|
||||
|
||||
assert harness.router_kwargs()["input_file_id"] == fake_db_file.storage_url
|
||||
find_first.assert_awaited_once_with(where={"unified_file_id": "litellm_proxy_unified_id"})
|
||||
assert resp.input_file_id == "litellm_proxy_unified_id"
|
||||
assert resp._hidden_params["unified_file_id"] == "unified-xyz"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create__unified_file_id_db_error_fails_closed_503(harness):
|
||||
"""A lookup error leaves the token unresolved, so it fails closed with a
|
||||
retryable 503 rather than dispatching the opaque id into the provider crash
|
||||
it cannot parse. Nothing is dispatched."""
|
||||
set_body(
|
||||
harness,
|
||||
{
|
||||
"input_file_id": "litellm_proxy_unified_id",
|
||||
"endpoint": "/v1/chat/completions",
|
||||
"completion_window": "24h",
|
||||
},
|
||||
)
|
||||
|
||||
find_first = AsyncMock(side_effect=Exception("db unavailable"))
|
||||
fake_repo_instance = MagicMock()
|
||||
fake_repo_instance.table.find_first = find_first
|
||||
fake_repo_cls = MagicMock(return_value=fake_repo_instance)
|
||||
|
||||
with (
|
||||
patch.object(endpoints, "_is_base64_encoded_unified_file_id", return_value="unified-xyz"),
|
||||
patch.object(endpoints, "get_models_from_unified_file_id", return_value=["gemini-2.0"]),
|
||||
patch.object(proxy_server, "prisma_client", MagicMock()),
|
||||
patch.object(endpoints, "ManagedFileRepository", fake_repo_cls),
|
||||
):
|
||||
with pytest.raises(ProxyException) as exc:
|
||||
await call_create(harness)
|
||||
|
||||
assert exc.value.code == "503"
|
||||
harness.router_acreate.assert_not_called()
|
||||
harness.litellm_acreate.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create__multi_model_unified_file_with_loadbalancing_keeps_router_branch(harness):
|
||||
"""Regression guard: a multi-model managed file dispatched with an explicit
|
||||
router model under load balancing must keep taking the load-balanced router
|
||||
branch, exactly as on the base revision, where the managed-files deployment
|
||||
hook remaps the unified id per model. Routing it into the unified branch
|
||||
instead would trip that branch's "exactly one model" 400 and break a path
|
||||
that works today, so the unified-file resolution must not steal the
|
||||
load-balanced branch."""
|
||||
set_body(
|
||||
harness,
|
||||
{
|
||||
"input_file_id": "litellm_proxy_unified_id",
|
||||
"model": "vertex-model",
|
||||
"endpoint": "/v1/chat/completions",
|
||||
"completion_window": "24h",
|
||||
},
|
||||
)
|
||||
harness.is_known_model.return_value = True
|
||||
|
||||
with (
|
||||
patch.object(litellm, "enable_loadbalancing_on_batch_endpoints", True),
|
||||
patch.object(endpoints, "_is_base64_encoded_unified_file_id", return_value="unified-xyz"),
|
||||
patch.object(endpoints, "get_models_from_unified_file_id", return_value=["model-a", "model-b"]),
|
||||
):
|
||||
await call_create(harness)
|
||||
|
||||
assert harness.router_acreate.call_count == 1
|
||||
assert harness.router_kwargs()["input_file_id"] == "litellm_proxy_unified_id"
|
||||
harness.litellm_acreate.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create__unified_file_id_missing_row_fails_closed_404(harness):
|
||||
"""With a database present, a managed unified id that has no row cannot be
|
||||
resolved to a real storage location, so it fails closed with a 404 rather
|
||||
than dispatching the opaque token, which would hit the Vertex
|
||||
publishers-segment IndexError this PR exists to prevent."""
|
||||
set_body(
|
||||
harness,
|
||||
{
|
||||
"input_file_id": "litellm_proxy_unified_id",
|
||||
"endpoint": "/v1/chat/completions",
|
||||
"completion_window": "24h",
|
||||
},
|
||||
)
|
||||
|
||||
find_first = AsyncMock(return_value=None)
|
||||
fake_repo_instance = MagicMock()
|
||||
fake_repo_instance.table.find_first = find_first
|
||||
fake_repo_cls = MagicMock(return_value=fake_repo_instance)
|
||||
|
||||
with (
|
||||
patch.object(endpoints, "_is_base64_encoded_unified_file_id", return_value="unified-xyz"),
|
||||
patch.object(endpoints, "get_models_from_unified_file_id", return_value=["gemini-2.0"]),
|
||||
patch.object(proxy_server, "prisma_client", MagicMock()),
|
||||
patch.object(endpoints, "ManagedFileRepository", fake_repo_cls),
|
||||
):
|
||||
with pytest.raises(ProxyException) as exc:
|
||||
await call_create(harness)
|
||||
|
||||
assert exc.value.code == "404"
|
||||
harness.router_acreate.assert_not_called()
|
||||
harness.litellm_acreate.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create__unified_file_id_legacy_row_without_storage_url_dispatches_raw(
|
||||
harness,
|
||||
):
|
||||
"""A managed file whose row predates the storage_url column still dispatches
|
||||
the original id (the managed-files deployment hook maps it); the row exists,
|
||||
so this is not the missing-row fail-closed case."""
|
||||
set_body(
|
||||
harness,
|
||||
{
|
||||
"input_file_id": "litellm_proxy_unified_id",
|
||||
"endpoint": "/v1/chat/completions",
|
||||
"completion_window": "24h",
|
||||
},
|
||||
)
|
||||
|
||||
fake_db_file = MagicMock(storage_url=None)
|
||||
find_first = AsyncMock(return_value=fake_db_file)
|
||||
fake_repo_instance = MagicMock()
|
||||
fake_repo_instance.table.find_first = find_first
|
||||
fake_repo_cls = MagicMock(return_value=fake_repo_instance)
|
||||
|
||||
with (
|
||||
patch.object(endpoints, "_is_base64_encoded_unified_file_id", return_value="unified-xyz"),
|
||||
patch.object(endpoints, "get_models_from_unified_file_id", return_value=["gemini-2.0"]),
|
||||
patch.object(proxy_server, "prisma_client", MagicMock()),
|
||||
patch.object(endpoints, "ManagedFileRepository", fake_repo_cls),
|
||||
):
|
||||
await call_create(harness)
|
||||
|
||||
assert harness.router_kwargs()["input_file_id"] == "litellm_proxy_unified_id"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create__model_encoded_beats_unified(harness):
|
||||
"""Precedence row: a file id that is BOTH model-encoded and (pretend) unified
|
||||
|
|
@ -547,10 +704,9 @@ async def test_create__model_encoded_beats_unified(harness):
|
|||
"completion_window": "24h",
|
||||
},
|
||||
)
|
||||
with patch.object(
|
||||
endpoints, "_is_base64_encoded_unified_file_id", return_value="unified-xyz"
|
||||
), patch.object(
|
||||
endpoints, "get_models_from_unified_file_id", return_value=["something-else"]
|
||||
with (
|
||||
patch.object(endpoints, "_is_base64_encoded_unified_file_id", return_value="unified-xyz"),
|
||||
patch.object(endpoints, "get_models_from_unified_file_id", return_value=["something-else"]),
|
||||
):
|
||||
await call_create(harness)
|
||||
|
||||
|
|
@ -579,9 +735,7 @@ async def test_create__loadbalancing_routes_to_router(harness):
|
|||
with patch.object(litellm, "enable_loadbalancing_on_batch_endpoints", True):
|
||||
await call_create(harness)
|
||||
|
||||
harness.is_known_model.assert_called_once_with(
|
||||
model="lb-model", llm_router=harness.router
|
||||
)
|
||||
harness.is_known_model.assert_called_once_with(model="lb-model", llm_router=harness.router)
|
||||
assert harness.router_acreate.call_count == 1
|
||||
harness.litellm_acreate.assert_not_called()
|
||||
harness.creds_resolver.assert_not_called()
|
||||
|
|
@ -630,9 +784,7 @@ async def test_create__team_expiry_injected(harness):
|
|||
},
|
||||
)
|
||||
|
||||
await call_create(
|
||||
harness, user=_user_with_expiry({"anchor": "created_at", "seconds": 3600})
|
||||
)
|
||||
await call_create(harness, user=_user_with_expiry({"anchor": "created_at", "seconds": 3600}))
|
||||
|
||||
assert harness.acreate_kwargs()["output_expires_after"] == {
|
||||
"anchor": "created_at",
|
||||
|
|
@ -738,12 +890,7 @@ async def test_create__exception_calls_failure_hook(harness):
|
|||
await call_create(harness)
|
||||
|
||||
harness.logging.post_call_failure_hook.assert_called_once()
|
||||
assert (
|
||||
harness.logging.post_call_failure_hook.call_args.kwargs[
|
||||
"original_exception"
|
||||
].args[0]
|
||||
== "provider boom"
|
||||
)
|
||||
assert harness.logging.post_call_failure_hook.call_args.kwargs["original_exception"].args[0] == "provider boom"
|
||||
|
||||
|
||||
# =========================================================================== #
|
||||
|
|
@ -770,9 +917,7 @@ async def test_create__exception_calls_failure_hook(harness):
|
|||
# A real model-encoded BATCH id: decodes to "azure/gpt-4o", strips to
|
||||
# "batch_orig123". Distinct from AZURE_FILE_ID so retrieve tests can't pass by
|
||||
# accidentally reusing the create fixture's value.
|
||||
AZURE_BATCH_ID = encode_file_id_with_model(
|
||||
"batch_orig123", "azure/gpt-4o", id_type="batch"
|
||||
)
|
||||
AZURE_BATCH_ID = encode_file_id_with_model("batch_orig123", "azure/gpt-4o", id_type="batch")
|
||||
|
||||
# A realistic decoded unified batch id (what _is_base64_encoded_unified_file_id
|
||||
# returns). model_id / llm_batch_id are parsed out of this by the real helpers.
|
||||
|
|
@ -823,9 +968,7 @@ def retrieve_harness():
|
|||
|
||||
router = MagicMock(spec=Router)
|
||||
router.aretrieve_batch = AsyncMock(return_value=make_batch())
|
||||
router.get_deployment_credentials_with_provider = MagicMock(
|
||||
side_effect=_creds_lookup
|
||||
)
|
||||
router.get_deployment_credentials_with_provider = MagicMock(side_effect=_creds_lookup)
|
||||
|
||||
pre_call = AsyncMock(side_effect=lambda **kw: (data_holder["data"], MagicMock()))
|
||||
get_headers = MagicMock(return_value={})
|
||||
|
|
@ -846,11 +989,7 @@ def retrieve_harness():
|
|||
pre_call,
|
||||
)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch.object(
|
||||
ProxyBaseLLMRequestProcessing, "get_custom_headers", get_headers
|
||||
)
|
||||
)
|
||||
stack.enter_context(patch.object(ProxyBaseLLMRequestProcessing, "get_custom_headers", get_headers))
|
||||
stack.enter_context(
|
||||
patch.object(
|
||||
endpoints,
|
||||
|
|
@ -865,24 +1004,12 @@ def retrieve_harness():
|
|||
provider_from_query,
|
||||
)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch.object(endpoints, "get_batch_from_database", get_batch_from_db)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch.object(endpoints, "update_batch_in_database", update_batch_in_db)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch.object(endpoints, "resolve_input_file_id_to_unified", resolve_input)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch.object(
|
||||
endpoints, "resolve_output_file_ids_to_unified", resolve_output
|
||||
)
|
||||
)
|
||||
stack.enter_context(patch.object(endpoints, "get_batch_from_database", get_batch_from_db))
|
||||
stack.enter_context(patch.object(endpoints, "update_batch_in_database", update_batch_in_db))
|
||||
stack.enter_context(patch.object(endpoints, "resolve_input_file_id_to_unified", resolve_input))
|
||||
stack.enter_context(patch.object(endpoints, "resolve_output_file_ids_to_unified", resolve_output))
|
||||
stack.enter_context(patch.object(litellm, "aretrieve_batch", litellm_aretrieve))
|
||||
stack.enter_context(
|
||||
patch.object(litellm, "enable_loadbalancing_on_batch_endpoints", False)
|
||||
)
|
||||
stack.enter_context(patch.object(litellm, "enable_loadbalancing_on_batch_endpoints", False))
|
||||
stack.enter_context(patch.object(proxy_server, "llm_router", router))
|
||||
stack.enter_context(patch.object(proxy_server, "proxy_logging_obj", logging))
|
||||
stack.enter_context(patch.object(proxy_server, "general_settings", {}))
|
||||
|
|
@ -956,9 +1083,7 @@ async def test_retrieve__model_encoded_id(retrieve_harness):
|
|||
}
|
||||
|
||||
# 4. OUTPUT SHAPE - ids re-encoded with the model for the round-trip.
|
||||
assert resp.id == encode_file_id_with_model(
|
||||
"batch-provider-id", "azure/gpt-4o", id_type="batch"
|
||||
)
|
||||
assert resp.id == encode_file_id_with_model("batch-provider-id", "azure/gpt-4o", id_type="batch")
|
||||
|
||||
# write-back to the managed-object table happened, tagged as a retrieve.
|
||||
assert retrieve_harness.update_batch_in_db.call_count == 1
|
||||
|
|
@ -989,12 +1114,8 @@ async def test_retrieve__model_encoded_id__encodes_output_and_error_ids(
|
|||
|
||||
resp = await call_retrieve(retrieve_harness, AZURE_BATCH_ID)
|
||||
|
||||
assert resp.output_file_id == encode_file_id_with_model(
|
||||
"file-out-raw", "azure/gpt-4o"
|
||||
)
|
||||
assert resp.error_file_id == encode_file_id_with_model(
|
||||
"file-err-raw", "azure/gpt-4o"
|
||||
)
|
||||
assert resp.output_file_id == encode_file_id_with_model("file-out-raw", "azure/gpt-4o")
|
||||
assert resp.error_file_id == encode_file_id_with_model("file-err-raw", "azure/gpt-4o")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -1018,9 +1139,7 @@ async def test_retrieve__model_encoded_beats_loadbalancing(retrieve_harness):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retrieve__unified_batch_id_routes_to_router(retrieve_harness):
|
||||
with patch.object(
|
||||
endpoints, "_is_base64_encoded_unified_file_id", return_value=UNIFIED_BATCH_ID
|
||||
):
|
||||
with patch.object(endpoints, "_is_base64_encoded_unified_file_id", return_value=UNIFIED_BATCH_ID):
|
||||
resp = await call_retrieve(retrieve_harness, "batch-unified-blob")
|
||||
|
||||
# DISPATCH - router fired, direct litellm did not.
|
||||
|
|
@ -1125,9 +1244,7 @@ async def test_retrieve__fallback_provider_precedence_path_over_header(
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"status", ["completed", "complete", "failed", "cancelled", "expired"]
|
||||
)
|
||||
@pytest.mark.parametrize("status", ["completed", "complete", "failed", "cancelled", "expired"])
|
||||
async def test_retrieve__db_terminal_state_short_circuits(retrieve_harness, status):
|
||||
# "complete" is the DB-normalized alias of "completed"; it is not a valid
|
||||
# constructor literal but reaches the endpoint via a stored row, so set it
|
||||
|
|
@ -1151,9 +1268,7 @@ async def test_retrieve__db_terminal_unified_resolves_file_ids(retrieve_harness)
|
|||
db_response = make_batch(id="batch-from-db", status="completed")
|
||||
retrieve_harness.get_batch_from_db.return_value = (MagicMock(), db_response)
|
||||
|
||||
with patch.object(
|
||||
endpoints, "_is_base64_encoded_unified_file_id", return_value=UNIFIED_BATCH_ID
|
||||
):
|
||||
with patch.object(endpoints, "_is_base64_encoded_unified_file_id", return_value=UNIFIED_BATCH_ID):
|
||||
await call_retrieve(retrieve_harness, "batch-unified-blob")
|
||||
|
||||
# Terminal short-circuit still resolves raw provider file ids to unified.
|
||||
|
|
@ -1186,9 +1301,7 @@ async def test_retrieve__db_non_terminal_state_syncs_with_provider(retrieve_harn
|
|||
async def test_retrieve__uses_aretrieve_batch_route_type(retrieve_harness):
|
||||
await call_retrieve(retrieve_harness, "batch-raw-xyz")
|
||||
|
||||
assert (
|
||||
retrieve_harness.pre_call.call_args.kwargs["route_type"] == "aretrieve_batch"
|
||||
)
|
||||
assert retrieve_harness.pre_call.call_args.kwargs["route_type"] == "aretrieve_batch"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -1200,9 +1313,7 @@ async def test_retrieve__exception_calls_failure_hook(retrieve_harness):
|
|||
|
||||
retrieve_harness.logging.post_call_failure_hook.assert_called_once()
|
||||
assert (
|
||||
retrieve_harness.logging.post_call_failure_hook.call_args.kwargs[
|
||||
"original_exception"
|
||||
].args[0]
|
||||
retrieve_harness.logging.post_call_failure_hook.call_args.kwargs["original_exception"].args[0]
|
||||
== "provider boom"
|
||||
)
|
||||
|
||||
|
|
@ -1275,9 +1386,7 @@ def list_harness():
|
|||
|
||||
router = MagicMock(spec=Router)
|
||||
router.alist_batches = AsyncMock(return_value=FakeListPage([]))
|
||||
router.get_deployment_credentials_with_provider = MagicMock(
|
||||
side_effect=_creds_lookup
|
||||
)
|
||||
router.get_deployment_credentials_with_provider = MagicMock(side_effect=_creds_lookup)
|
||||
|
||||
read_body = AsyncMock(side_effect=lambda request: body_holder["body"])
|
||||
pre_call = AsyncMock(side_effect=lambda **kw: (body_holder["body"], MagicMock()))
|
||||
|
|
@ -1295,11 +1404,7 @@ def list_harness():
|
|||
pre_call,
|
||||
)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch.object(
|
||||
ProxyBaseLLMRequestProcessing, "get_custom_headers", get_headers
|
||||
)
|
||||
)
|
||||
stack.enter_context(patch.object(ProxyBaseLLMRequestProcessing, "get_custom_headers", get_headers))
|
||||
stack.enter_context(
|
||||
patch.object(
|
||||
endpoints,
|
||||
|
|
@ -1432,21 +1537,15 @@ async def test_list__managed_files_beats_model_param(list_harness):
|
|||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_list__model_from_body_routes_and_encodes(list_harness):
|
||||
list_harness.litellm_alist.return_value = FakeListPage(
|
||||
[make_batch(id="batch-1"), make_batch(id="batch-2")]
|
||||
)
|
||||
list_harness.litellm_alist.return_value = FakeListPage([make_batch(id="batch-1"), make_batch(id="batch-2")])
|
||||
|
||||
resp = await call_list(list_harness, body={"model": "azure/gpt-4o"})
|
||||
|
||||
assert list_harness.litellm_alist.call_count == 1
|
||||
list_harness.router_alist.assert_not_called()
|
||||
list_harness.creds_resolver.assert_called_once_with(model_id="azure/gpt-4o")
|
||||
assert resp.data[0].id == encode_file_id_with_model(
|
||||
"batch-1", "azure/gpt-4o", id_type="batch"
|
||||
)
|
||||
assert resp.data[1].id == encode_file_id_with_model(
|
||||
"batch-2", "azure/gpt-4o", id_type="batch"
|
||||
)
|
||||
assert resp.data[0].id == encode_file_id_with_model("batch-1", "azure/gpt-4o", id_type="batch")
|
||||
assert resp.data[1].id == encode_file_id_with_model("batch-2", "azure/gpt-4o", id_type="batch")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
|
@ -1577,12 +1676,7 @@ async def test_list__exception_calls_failure_hook(list_harness):
|
|||
await call_list(list_harness)
|
||||
|
||||
list_harness.logging.post_call_failure_hook.assert_called_once()
|
||||
assert (
|
||||
list_harness.logging.post_call_failure_hook.call_args.kwargs[
|
||||
"original_exception"
|
||||
].args[0]
|
||||
== "provider boom"
|
||||
)
|
||||
assert list_harness.logging.post_call_failure_hook.call_args.kwargs["original_exception"].args[0] == "provider boom"
|
||||
|
||||
|
||||
# =========================================================================== #
|
||||
|
|
@ -1645,9 +1739,7 @@ def cancel_harness():
|
|||
|
||||
router = MagicMock(spec=Router)
|
||||
router.acancel_batch = AsyncMock(return_value=make_batch())
|
||||
router.get_deployment_credentials_with_provider = MagicMock(
|
||||
side_effect=_creds_lookup
|
||||
)
|
||||
router.get_deployment_credentials_with_provider = MagicMock(side_effect=_creds_lookup)
|
||||
|
||||
pre_call = AsyncMock(side_effect=lambda **kw: (data_holder["data"], MagicMock()))
|
||||
# add_litellm_data_to_request is a passthrough that returns the data it got.
|
||||
|
|
@ -1666,11 +1758,7 @@ def cancel_harness():
|
|||
pre_call,
|
||||
)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch.object(
|
||||
ProxyBaseLLMRequestProcessing, "get_custom_headers", get_headers
|
||||
)
|
||||
)
|
||||
stack.enter_context(patch.object(ProxyBaseLLMRequestProcessing, "get_custom_headers", get_headers))
|
||||
stack.enter_context(
|
||||
patch.object(
|
||||
endpoints,
|
||||
|
|
@ -1685,22 +1773,16 @@ def cancel_harness():
|
|||
provider_from_query,
|
||||
)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch.object(endpoints, "update_batch_in_database", update_batch_in_db)
|
||||
)
|
||||
stack.enter_context(patch.object(endpoints, "update_batch_in_database", update_batch_in_db))
|
||||
stack.enter_context(patch.object(litellm, "acancel_batch", litellm_acancel))
|
||||
stack.enter_context(
|
||||
patch.object(litellm, "enable_loadbalancing_on_batch_endpoints", False)
|
||||
)
|
||||
stack.enter_context(patch.object(litellm, "enable_loadbalancing_on_batch_endpoints", False))
|
||||
stack.enter_context(patch.object(proxy_server, "llm_router", router))
|
||||
stack.enter_context(patch.object(proxy_server, "proxy_logging_obj", logging))
|
||||
stack.enter_context(patch.object(proxy_server, "general_settings", {}))
|
||||
stack.enter_context(patch.object(proxy_server, "proxy_config", MagicMock()))
|
||||
stack.enter_context(patch.object(proxy_server, "version", "test-version"))
|
||||
stack.enter_context(patch.object(proxy_server, "prisma_client", MagicMock()))
|
||||
stack.enter_context(
|
||||
patch.object(proxy_server, "add_litellm_data_to_request", add_data)
|
||||
)
|
||||
stack.enter_context(patch.object(proxy_server, "add_litellm_data_to_request", add_data))
|
||||
|
||||
yield CancelHarness(
|
||||
data=data_holder,
|
||||
|
|
@ -1765,9 +1847,7 @@ async def test_cancel__model_encoded_id(cancel_harness):
|
|||
}
|
||||
|
||||
# OUTPUT SHAPE - response id re-encoded with the DECODED model.
|
||||
assert resp.id == encode_file_id_with_model(
|
||||
"batch-provider-id", "azure/gpt-4o", id_type="batch"
|
||||
)
|
||||
assert resp.id == encode_file_id_with_model("batch-provider-id", "azure/gpt-4o", id_type="batch")
|
||||
|
||||
# write-back tagged as a cancel.
|
||||
assert cancel_harness.update_batch_in_db.call_count == 1
|
||||
|
|
@ -1786,9 +1866,7 @@ async def test_cancel__model_encoded_id_forwards_deployment_model(cancel_harness
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel__model_encoded_beats_unified(cancel_harness):
|
||||
with patch.object(
|
||||
endpoints, "_is_base64_encoded_unified_file_id", return_value=UNIFIED_BATCH_ID
|
||||
):
|
||||
with patch.object(endpoints, "_is_base64_encoded_unified_file_id", return_value=UNIFIED_BATCH_ID):
|
||||
await call_cancel(cancel_harness, AZURE_BATCH_ID)
|
||||
|
||||
assert cancel_harness.litellm_acancel.call_count == 1
|
||||
|
|
@ -1804,9 +1882,7 @@ async def test_cancel__model_encoded_beats_unified(cancel_harness):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel__unified_batch_id_routes_to_router(cancel_harness):
|
||||
with patch.object(
|
||||
endpoints, "_is_base64_encoded_unified_file_id", return_value=UNIFIED_BATCH_ID
|
||||
):
|
||||
with patch.object(endpoints, "_is_base64_encoded_unified_file_id", return_value=UNIFIED_BATCH_ID):
|
||||
resp = await call_cancel(cancel_harness, "batch-unified-blob")
|
||||
|
||||
# DISPATCH - router fired, litellm did not, no creds lookup.
|
||||
|
|
@ -1845,8 +1921,9 @@ async def test_cancel__unified_missing_model_id_400(cancel_harness):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel__unified_no_router_500(cancel_harness):
|
||||
with patch.object(proxy_server, "llm_router", None), patch.object(
|
||||
endpoints, "_is_base64_encoded_unified_file_id", return_value=UNIFIED_BATCH_ID
|
||||
with (
|
||||
patch.object(proxy_server, "llm_router", None),
|
||||
patch.object(endpoints, "_is_base64_encoded_unified_file_id", return_value=UNIFIED_BATCH_ID),
|
||||
):
|
||||
with pytest.raises(ProxyException) as exc:
|
||||
await call_cancel(cancel_harness, "batch-unified-blob")
|
||||
|
|
@ -1885,9 +1962,7 @@ async def test_cancel__fallback_provider_path_param(cancel_harness):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel__fallback_provider_from_data_body(cancel_harness):
|
||||
await call_cancel(
|
||||
cancel_harness, "batch-raw-xyz", data_extra={"custom_llm_provider": "bedrock"}
|
||||
)
|
||||
await call_cancel(cancel_harness, "batch-raw-xyz", data_extra={"custom_llm_provider": "bedrock"})
|
||||
|
||||
assert cancel_harness.acancel_kwargs()["custom_llm_provider"] == "bedrock"
|
||||
|
||||
|
|
@ -1954,10 +2029,7 @@ async def test_cancel__exception_calls_failure_hook(cancel_harness):
|
|||
|
||||
cancel_harness.logging.post_call_failure_hook.assert_called_once()
|
||||
assert (
|
||||
cancel_harness.logging.post_call_failure_hook.call_args.kwargs[
|
||||
"original_exception"
|
||||
].args[0]
|
||||
== "provider boom"
|
||||
cancel_harness.logging.post_call_failure_hook.call_args.kwargs["original_exception"].args[0] == "provider boom"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1979,9 +2051,10 @@ async def test_create__loadbalancing_no_router_500(harness):
|
|||
},
|
||||
)
|
||||
harness.is_known_model.return_value = True
|
||||
with patch.object(
|
||||
litellm, "enable_loadbalancing_on_batch_endpoints", True
|
||||
), patch.object(proxy_server, "llm_router", None):
|
||||
with (
|
||||
patch.object(litellm, "enable_loadbalancing_on_batch_endpoints", True),
|
||||
patch.object(proxy_server, "llm_router", None),
|
||||
):
|
||||
with pytest.raises(ProxyException) as exc:
|
||||
await call_create(harness)
|
||||
|
||||
|
|
@ -2000,12 +2073,10 @@ async def test_create__unified_no_router_500(harness):
|
|||
"completion_window": "24h",
|
||||
},
|
||||
)
|
||||
with patch.object(
|
||||
endpoints, "_is_base64_encoded_unified_file_id", return_value="unified-xyz"
|
||||
), patch.object(
|
||||
endpoints, "get_models_from_unified_file_id", return_value=["gpt-4o-mini"]
|
||||
), patch.object(
|
||||
proxy_server, "llm_router", None
|
||||
with (
|
||||
patch.object(endpoints, "_is_base64_encoded_unified_file_id", return_value="unified-xyz"),
|
||||
patch.object(endpoints, "get_models_from_unified_file_id", return_value=["gpt-4o-mini"]),
|
||||
patch.object(proxy_server, "llm_router", None),
|
||||
):
|
||||
with pytest.raises(ProxyException) as exc:
|
||||
await call_create(harness)
|
||||
|
|
@ -2015,9 +2086,10 @@ async def test_create__unified_no_router_500(harness):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retrieve__unified_no_router_500(retrieve_harness):
|
||||
with patch.object(
|
||||
endpoints, "_is_base64_encoded_unified_file_id", return_value=UNIFIED_BATCH_ID
|
||||
), patch.object(proxy_server, "llm_router", None):
|
||||
with (
|
||||
patch.object(endpoints, "_is_base64_encoded_unified_file_id", return_value=UNIFIED_BATCH_ID),
|
||||
patch.object(proxy_server, "llm_router", None),
|
||||
):
|
||||
with pytest.raises(ProxyException) as exc:
|
||||
await call_retrieve(retrieve_harness, "batch-unified-blob")
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ Tests cover:
|
|||
- /v1/compress non-2xx surfaces as httpx.HTTPStatusError (raise_for_status),
|
||||
not a status_code check on the returned response -- both are handled
|
||||
- unreachable_fallback="fail_open" forwards the request uncompressed instead of raising
|
||||
- tokens_saved is derived from tokens_before/tokens_after when the compression
|
||||
service omits it, passed through verbatim when present, and skipped (without
|
||||
breaking compression) when the token counts are not numeric
|
||||
- CCR: headroom_retrieve tool injected when compressed messages contain hashes
|
||||
- CCR: async_should_run_agentic_loop returns True when response has headroom_retrieve tool calls
|
||||
- CCR: async_build_agentic_loop_plan calls retrieve endpoint and builds follow-up messages
|
||||
|
|
@ -32,6 +35,9 @@ from litellm.proxy.guardrails.guardrail_hooks.headroom.headroom import (
|
|||
has_headroom_retrieve_tool,
|
||||
HEADROOM_RETRIEVE_TOOL_NAME,
|
||||
)
|
||||
from litellm.proxy.spend_tracking.compression_savings import (
|
||||
extract_compression_saved_tokens,
|
||||
)
|
||||
from litellm.types.utils import GenericGuardrailAPIInputs
|
||||
|
||||
FAKE_API_BASE = "https://headroom.example.com"
|
||||
|
|
@ -165,6 +171,113 @@ async def test_apply_guardrail_compresses_and_returns_structured_messages(
|
|||
assert "headroom" in _applied_guardrails(request_data)
|
||||
|
||||
|
||||
def _recorded_guardrail_response(request_data: dict) -> dict:
|
||||
entries = request_data["metadata"]["standard_logging_guardrail_information"]
|
||||
assert len(entries) == 1
|
||||
return entries[0]["guardrail_response"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_guardrail_derives_tokens_saved_when_service_omits_it(
|
||||
guardrail: HeadroomGuardrail,
|
||||
):
|
||||
inputs = GenericGuardrailAPIInputs(
|
||||
texts=["A" * 5000],
|
||||
structured_messages=ORIGINAL_MESSAGES,
|
||||
)
|
||||
# _make_compress_response omits tokens_saved, matching the live service.
|
||||
mock_response = _make_compress_response(COMPRESSED_MESSAGES)
|
||||
request_data: dict = {"model": "gpt-4o"}
|
||||
|
||||
with patch.object(
|
||||
guardrail.async_handler,
|
||||
"post",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_response,
|
||||
):
|
||||
await guardrail.apply_guardrail(
|
||||
inputs=inputs,
|
||||
request_data=request_data,
|
||||
input_type="request",
|
||||
)
|
||||
|
||||
stats = _recorded_guardrail_response(request_data)
|
||||
assert stats["tokens_saved"] == 900
|
||||
|
||||
# Spend tracking reads the entry under the spend-log metadata key.
|
||||
entry = request_data["metadata"]["standard_logging_guardrail_information"][0]
|
||||
assert extract_compression_saved_tokens({"guardrail_information": [entry]}) == 900
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_guardrail_passes_through_service_sent_tokens_saved(
|
||||
guardrail: HeadroomGuardrail,
|
||||
):
|
||||
inputs = GenericGuardrailAPIInputs(
|
||||
texts=["A" * 5000],
|
||||
structured_messages=ORIGINAL_MESSAGES,
|
||||
)
|
||||
mock_response = _make_compress_response(COMPRESSED_MESSAGES)
|
||||
# Deliberately different from tokens_before - tokens_after (900): the
|
||||
# service-sent value must win over the derived one.
|
||||
mock_response.json.return_value["tokens_saved"] = 123
|
||||
request_data: dict = {"model": "gpt-4o"}
|
||||
|
||||
with patch.object(
|
||||
guardrail.async_handler,
|
||||
"post",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_response,
|
||||
):
|
||||
await guardrail.apply_guardrail(
|
||||
inputs=inputs,
|
||||
request_data=request_data,
|
||||
input_type="request",
|
||||
)
|
||||
|
||||
assert _recorded_guardrail_response(request_data)["tokens_saved"] == 123
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"tokens_before, tokens_after",
|
||||
[
|
||||
("1000", "100"),
|
||||
(True, False),
|
||||
(None, None),
|
||||
],
|
||||
)
|
||||
async def test_apply_guardrail_skips_derivation_for_non_numeric_token_counts(
|
||||
guardrail: HeadroomGuardrail,
|
||||
tokens_before,
|
||||
tokens_after,
|
||||
):
|
||||
inputs = GenericGuardrailAPIInputs(
|
||||
texts=["A" * 5000],
|
||||
structured_messages=ORIGINAL_MESSAGES,
|
||||
)
|
||||
mock_response = _make_compress_response(COMPRESSED_MESSAGES)
|
||||
mock_response.json.return_value["tokens_before"] = tokens_before
|
||||
mock_response.json.return_value["tokens_after"] = tokens_after
|
||||
request_data: dict = {"model": "gpt-4o"}
|
||||
|
||||
with patch.object(
|
||||
guardrail.async_handler,
|
||||
"post",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_response,
|
||||
):
|
||||
result = await guardrail.apply_guardrail(
|
||||
inputs=inputs,
|
||||
request_data=request_data,
|
||||
input_type="request",
|
||||
)
|
||||
|
||||
assert "tokens_saved" not in _recorded_guardrail_response(request_data)
|
||||
# Compression itself is unaffected by the skipped derivation.
|
||||
assert result.get("structured_messages") == COMPRESSED_MESSAGES
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_guardrail_injects_retrieve_tool_when_hashes_present(
|
||||
guardrail: HeadroomGuardrail,
|
||||
|
|
|
|||
|
|
@ -2959,11 +2959,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/TableHeaderSortDropdown/TableHeaderSortDropdown.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/budget_duration_dropdown.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
|
|
@ -3549,11 +3544,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/routing_groups/RoutingGroupsTable.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/routing_groups/index.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
|
|
|
|||
|
|
@ -1,148 +0,0 @@
|
|||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { TableHeaderSortDropdown } from "./TableHeaderSortDropdown";
|
||||
|
||||
describe("TableHeaderSortDropdown", () => {
|
||||
it("should render", () => {
|
||||
const onSortChange = vi.fn();
|
||||
render(<TableHeaderSortDropdown sortState={false} onSortChange={onSortChange} />);
|
||||
expect(screen.getByRole("button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should open dropdown menu when button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSortChange = vi.fn();
|
||||
render(<TableHeaderSortDropdown sortState={false} onSortChange={onSortChange} />);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Ascending")).toBeInTheDocument();
|
||||
expect(screen.getByText("Descending")).toBeInTheDocument();
|
||||
expect(screen.getByText("Reset")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should call onSortChange with asc when ascending option is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSortChange = vi.fn();
|
||||
render(<TableHeaderSortDropdown sortState={false} onSortChange={onSortChange} />);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Ascending")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const ascendingOption = screen.getByText("Ascending");
|
||||
await user.click(ascendingOption);
|
||||
|
||||
expect(onSortChange).toHaveBeenCalledTimes(1);
|
||||
expect(onSortChange).toHaveBeenCalledWith("asc");
|
||||
});
|
||||
|
||||
it("should call onSortChange with desc when descending option is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSortChange = vi.fn();
|
||||
render(<TableHeaderSortDropdown sortState={false} onSortChange={onSortChange} />);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Descending")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const descendingOption = screen.getByText("Descending");
|
||||
await user.click(descendingOption);
|
||||
|
||||
expect(onSortChange).toHaveBeenCalledTimes(1);
|
||||
expect(onSortChange).toHaveBeenCalledWith("desc");
|
||||
});
|
||||
|
||||
it("should call onSortChange with false when reset option is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSortChange = vi.fn();
|
||||
render(<TableHeaderSortDropdown sortState="asc" onSortChange={onSortChange} />);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Reset")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const resetOption = screen.getByText("Reset");
|
||||
await user.click(resetOption);
|
||||
|
||||
expect(onSortChange).toHaveBeenCalledTimes(1);
|
||||
expect(onSortChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("should highlight ascending option when sort state is asc", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSortChange = vi.fn();
|
||||
render(<TableHeaderSortDropdown sortState="asc" onSortChange={onSortChange} />);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
const ascendingOption = screen.getByText("Ascending");
|
||||
const menuItem = ascendingOption.closest(".ant-dropdown-menu-item");
|
||||
expect(menuItem).toHaveClass("ant-dropdown-menu-item-selected");
|
||||
});
|
||||
});
|
||||
|
||||
it("should highlight descending option when sort state is desc", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSortChange = vi.fn();
|
||||
render(<TableHeaderSortDropdown sortState="desc" onSortChange={onSortChange} />);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
const descendingOption = screen.getByText("Descending");
|
||||
const menuItem = descendingOption.closest(".ant-dropdown-menu-item");
|
||||
expect(menuItem).toHaveClass("ant-dropdown-menu-item-selected");
|
||||
});
|
||||
});
|
||||
|
||||
it("should not highlight any option when sort state is false", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSortChange = vi.fn();
|
||||
render(<TableHeaderSortDropdown sortState={false} onSortChange={onSortChange} />);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Ascending")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const ascendingOption = screen.getByText("Ascending");
|
||||
const menuItem = ascendingOption.closest(".ant-dropdown-menu-item");
|
||||
expect(menuItem).not.toHaveClass("ant-dropdown-menu-item-selected");
|
||||
});
|
||||
|
||||
it("should stop event propagation when button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSortChange = vi.fn();
|
||||
const onParentClick = vi.fn();
|
||||
|
||||
render(
|
||||
<div onClick={onParentClick}>
|
||||
<TableHeaderSortDropdown sortState={false} onSortChange={onSortChange} />
|
||||
</div>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
await user.click(button);
|
||||
|
||||
expect(onParentClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
import React from "react";
|
||||
import { Button, Dropdown, MenuProps } from "antd";
|
||||
import { SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon, XIcon } from "@heroicons/react/outline";
|
||||
|
||||
export type SortState = "asc" | "desc" | false;
|
||||
|
||||
interface TableHeaderSortDropdownProps {
|
||||
/**
|
||||
* Current sort state: "asc", "desc", or false for neutral
|
||||
*/
|
||||
sortState: SortState;
|
||||
/**
|
||||
* Callback when sort state changes
|
||||
* @param newState - The new sort state: "asc", "desc", or false
|
||||
*/
|
||||
onSortChange: (newState: SortState) => void;
|
||||
/**
|
||||
* Optional column ID for identification
|
||||
*/
|
||||
columnId?: string;
|
||||
}
|
||||
|
||||
export const TableHeaderSortDropdown: React.FC<TableHeaderSortDropdownProps> = ({ sortState, onSortChange }) => {
|
||||
const handleMenuClick: MenuProps["onClick"] = ({ key }) => {
|
||||
if (key === "asc") {
|
||||
onSortChange("asc");
|
||||
} else if (key === "desc") {
|
||||
onSortChange("desc");
|
||||
} else if (key === "reset") {
|
||||
onSortChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const menuItems: MenuProps["items"] = [
|
||||
{
|
||||
key: "asc",
|
||||
label: "Ascending",
|
||||
icon: <ChevronUpIcon className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
key: "desc",
|
||||
label: "Descending",
|
||||
icon: <ChevronDownIcon className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
key: "reset",
|
||||
label: "Reset",
|
||||
icon: <XIcon className="h-4 w-4" />,
|
||||
},
|
||||
];
|
||||
|
||||
// Determine which icon to display based on current sort state
|
||||
const renderIcon = () => {
|
||||
if (sortState === "asc") {
|
||||
return <ChevronUpIcon className="h-4 w-4" />;
|
||||
} else if (sortState === "desc") {
|
||||
return <ChevronDownIcon className="h-4 w-4" />;
|
||||
} else {
|
||||
return <SwitchVerticalIcon className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: menuItems,
|
||||
onClick: handleMenuClick,
|
||||
selectable: true,
|
||||
selectedKeys: sortState ? [sortState] : [],
|
||||
}}
|
||||
trigger={["click"]}
|
||||
autoAdjustOverflow
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
icon={renderIcon()}
|
||||
className={sortState ? "text-blue-500 hover:text-blue-600" : "text-gray-400 hover:text-blue-500"}
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
"use client";
|
||||
|
||||
import { Code2 } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
import CodeBlock from "@/components/CodeBlock";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
import { formatStrategyLabel } from "./strategy";
|
||||
import type { RoutingGroup } from "./types";
|
||||
|
||||
interface RoutingGroupUsagePanelProps {
|
||||
group: RoutingGroup;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
const exampleModel = (group: RoutingGroup): string => group.models[0] ?? "<your-model>";
|
||||
|
||||
const buildCurlSnippet = (group: RoutingGroup, baseUrl: string): string =>
|
||||
`curl -X POST '${baseUrl}/v1/chat/completions' \\
|
||||
-H 'Content-Type: application/json' \\
|
||||
-H 'Authorization: Bearer $LITELLM_API_KEY' \\
|
||||
-d '{
|
||||
"model": "${exampleModel(group)}",
|
||||
"messages": [{"role": "user", "content": "Hello!"}]
|
||||
}'`;
|
||||
|
||||
const buildPythonSnippet = (group: RoutingGroup, baseUrl: string): string =>
|
||||
`from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key="$LITELLM_API_KEY",
|
||||
base_url="${baseUrl}",
|
||||
)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="${exampleModel(group)}",
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
)
|
||||
|
||||
print(response)`;
|
||||
|
||||
const buildJsSnippet = (group: RoutingGroup, baseUrl: string): string =>
|
||||
`import OpenAI from "openai";
|
||||
|
||||
const client = new OpenAI({
|
||||
apiKey: process.env.LITELLM_API_KEY,
|
||||
baseURL: "${baseUrl}",
|
||||
});
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
model: "${exampleModel(group)}",
|
||||
messages: [{ role: "user", content: "Hello!" }],
|
||||
});
|
||||
|
||||
console.log(response);`;
|
||||
|
||||
const SNIPPET_TABS = [
|
||||
{ value: "curl", label: "cURL", language: "bash", build: buildCurlSnippet },
|
||||
{ value: "python", label: "Python (OpenAI SDK)", language: "python", build: buildPythonSnippet },
|
||||
{ value: "javascript", label: "JavaScript (OpenAI SDK)", language: "javascript", build: buildJsSnippet },
|
||||
] as const;
|
||||
|
||||
export function RoutingGroupUsagePanel({ group, baseUrl }: RoutingGroupUsagePanelProps) {
|
||||
return (
|
||||
<div className="border-y bg-muted/40 px-4 py-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Code2 className="size-4 text-primary" />
|
||||
<span className="text-sm font-medium text-foreground">How routing works for this group</span>
|
||||
</div>
|
||||
<p className="mb-3 text-sm text-muted-foreground">
|
||||
Callers request any model in the group by name; LiteLLM picks a deployment behind the scenes using the{" "}
|
||||
<span className="font-medium text-foreground">{formatStrategyLabel(group.routing_strategy)}</span> strategy.
|
||||
</p>
|
||||
<Tabs defaultValue="curl">
|
||||
<TabsList variant="line" className="h-auto w-full justify-start rounded-none border-b p-0">
|
||||
{SNIPPET_TABS.map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value} className="flex-none rounded-none px-4 py-2">
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{SNIPPET_TABS.map((tab) => (
|
||||
<TabsContent key={tab.value} value={tab.value} className="pt-3">
|
||||
<CodeBlock language={tab.language} code={tab.build(group, baseUrl)} />
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import RoutingGroupsTable from "./RoutingGroupsTable";
|
||||
import type { RoutingGroup } from "./types";
|
||||
|
||||
describe("RoutingGroupsTable", () => {
|
||||
const onEdit = vi.fn();
|
||||
const onDelete = vi.fn();
|
||||
|
||||
const prodGroup: RoutingGroup = {
|
||||
group_name: "prod-group",
|
||||
models: ["gpt-4o", "claude-sonnet-4-5"],
|
||||
routing_strategy: "usage-based-routing",
|
||||
};
|
||||
|
||||
const devGroup: RoutingGroup = {
|
||||
group_name: "dev-group",
|
||||
models: ["gpt-4o-mini"],
|
||||
routing_strategy: "simple-shuffle",
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
groups: [] as RoutingGroup[],
|
||||
onEdit,
|
||||
onDelete,
|
||||
proxyBaseUrl: "https://proxy.example.com",
|
||||
};
|
||||
|
||||
const rowFor = (groupName: string): HTMLElement => {
|
||||
const row = document.querySelector(`[data-row-id="${groupName}"]`);
|
||||
if (!(row instanceof HTMLElement)) {
|
||||
throw new Error(`No row rendered for ${groupName}`);
|
||||
}
|
||||
return row;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render every column header", () => {
|
||||
render(<RoutingGroupsTable {...defaultProps} />);
|
||||
for (const header of ["Group Name", "Models", "Strategy"]) {
|
||||
expect(screen.getByText(header)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("should show the empty state when there are no groups", () => {
|
||||
render(<RoutingGroupsTable {...defaultProps} />);
|
||||
expect(screen.getByText("No routing groups yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the group name, its models, and a human-readable strategy label", () => {
|
||||
render(<RoutingGroupsTable {...defaultProps} groups={[prodGroup]} />);
|
||||
const row = rowFor("prod-group");
|
||||
expect(within(row).getByText("prod-group")).toBeInTheDocument();
|
||||
expect(within(row).getByText("gpt-4o")).toBeInTheDocument();
|
||||
expect(within(row).getByText("claude-sonnet-4-5")).toBeInTheDocument();
|
||||
expect(within(row).getByText("Usage Based")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should fall back to the raw strategy value when it has no friendly label", () => {
|
||||
render(<RoutingGroupsTable {...defaultProps} groups={[{ ...prodGroup, routing_strategy: "custom-strategy" }]} />);
|
||||
expect(within(rowFor("prod-group")).getByText("custom-strategy")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should collapse models beyond the first three behind a +N more badge", () => {
|
||||
const wideGroup: RoutingGroup = { ...prodGroup, models: ["a", "b", "c", "d", "e"] };
|
||||
render(<RoutingGroupsTable {...defaultProps} groups={[wideGroup]} />);
|
||||
const row = rowFor("prod-group");
|
||||
expect(within(row).getByText("+2 more")).toBeInTheDocument();
|
||||
expect(within(row).queryByText("d")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should keep the incoming order until a column is sorted", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RoutingGroupsTable {...defaultProps} groups={[prodGroup, devGroup]} />);
|
||||
|
||||
const namesInOrder = () =>
|
||||
screen
|
||||
.getAllByRole("row")
|
||||
.slice(1)
|
||||
.map((row) => row.getAttribute("data-row-id"));
|
||||
|
||||
expect(namesInOrder()).toEqual(["prod-group", "dev-group"]);
|
||||
|
||||
await user.click(screen.getByTestId("sort-header-group_name"));
|
||||
expect(namesInOrder()).toEqual(["dev-group", "prod-group"]);
|
||||
});
|
||||
|
||||
it("should toggle the usage panel when the group name is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RoutingGroupsTable {...defaultProps} groups={[prodGroup]} />);
|
||||
|
||||
expect(screen.queryByText("How routing works for this group")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "prod-group" }));
|
||||
expect(await screen.findByText("How routing works for this group")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "prod-group" }));
|
||||
expect(screen.queryByText("How routing works for this group")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should build the usage snippet from the proxy base url and the group's first model", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RoutingGroupsTable {...defaultProps} groups={[prodGroup]} />);
|
||||
await user.click(screen.getByRole("button", { name: "prod-group" }));
|
||||
|
||||
const panel = (await screen.findByText("How routing works for this group")).closest("div")?.parentElement;
|
||||
expect(panel?.textContent).toContain("https://proxy.example.com");
|
||||
expect(panel?.textContent).toContain("gpt-4o");
|
||||
});
|
||||
|
||||
it("should expand only the clicked group", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RoutingGroupsTable {...defaultProps} groups={[prodGroup, devGroup]} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "dev-group" }));
|
||||
expect(await screen.findAllByText("How routing works for this group")).toHaveLength(1);
|
||||
expect(within(rowFor("prod-group")).queryByText("How routing works for this group")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should edit a group through the actions menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RoutingGroupsTable {...defaultProps} groups={[prodGroup]} />);
|
||||
await user.click(screen.getByTestId("routing-group-actions-prod-group"));
|
||||
await user.click(await screen.findByTestId("routing-group-action-edit"));
|
||||
expect(onEdit).toHaveBeenCalledWith(prodGroup);
|
||||
});
|
||||
|
||||
it("should delete a group through the actions menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RoutingGroupsTable {...defaultProps} groups={[prodGroup]} />);
|
||||
await user.click(screen.getByTestId("routing-group-actions-prod-group"));
|
||||
await user.click(await screen.findByTestId("routing-group-action-delete"));
|
||||
expect(onDelete).toHaveBeenCalledWith(prodGroup);
|
||||
});
|
||||
|
||||
it("should show skeleton rows instead of the empty state while loading", () => {
|
||||
render(<RoutingGroupsTable {...defaultProps} isLoading />);
|
||||
expect(screen.queryByText("No routing groups yet")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,229 +1,82 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Flex, Table, Tabs, Tag, Tooltip, Typography, Button } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { BranchesOutlined, DeleteOutlined, EditOutlined, CodeOutlined } from "@ant-design/icons";
|
||||
import type { RoutingGroup } from "./types";
|
||||
import type { ExpandedState, SortingState } from "@tanstack/react-table";
|
||||
import { Inbox } from "lucide-react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
|
||||
import { RoutingGroupUsagePanel } from "./RoutingGroupUsagePanel";
|
||||
import { getRoutingGroupsTableColumns } from "./RoutingGroupsTableColumns";
|
||||
import type { RoutingGroup } from "./types";
|
||||
|
||||
interface RoutingGroupsTableProps {
|
||||
groups: RoutingGroup[];
|
||||
loading?: boolean;
|
||||
isLoading?: boolean;
|
||||
onEdit: (group: RoutingGroup) => void;
|
||||
onDelete: (group: RoutingGroup) => void;
|
||||
proxyBaseUrl?: string;
|
||||
}
|
||||
|
||||
const formatStrategyLabel = (strategy: string): string => {
|
||||
switch (strategy) {
|
||||
case "simple-shuffle":
|
||||
return "Simple Shuffle";
|
||||
case "least-busy":
|
||||
return "Least Busy";
|
||||
case "usage-based-routing":
|
||||
return "Usage Based";
|
||||
case "latency-based-routing":
|
||||
return "Latency Based";
|
||||
default:
|
||||
return strategy;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveBaseUrl = (proxyBaseUrl?: string): string => {
|
||||
if (proxyBaseUrl && proxyBaseUrl.trim()) return proxyBaseUrl;
|
||||
if (typeof window !== "undefined" && window.location?.origin) return window.location.origin;
|
||||
return "<your_proxy_base_url>";
|
||||
};
|
||||
|
||||
const exampleModel = (group: RoutingGroup): string => group.models[0] ?? "<your-model>";
|
||||
|
||||
const buildCurlSnippet = (group: RoutingGroup, baseUrl: string): string =>
|
||||
`curl -X POST '${baseUrl}/v1/chat/completions' \\
|
||||
-H 'Content-Type: application/json' \\
|
||||
-H 'Authorization: Bearer $LITELLM_API_KEY' \\
|
||||
-d '{
|
||||
"model": "${exampleModel(group)}",
|
||||
"messages": [{"role": "user", "content": "Hello!"}]
|
||||
}'`;
|
||||
|
||||
const buildPythonSnippet = (group: RoutingGroup, baseUrl: string): string =>
|
||||
`from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key="$LITELLM_API_KEY",
|
||||
base_url="${baseUrl}",
|
||||
)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="${exampleModel(group)}",
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
)
|
||||
|
||||
print(response)`;
|
||||
|
||||
const buildJsSnippet = (group: RoutingGroup, baseUrl: string): string =>
|
||||
`import OpenAI from "openai";
|
||||
|
||||
const client = new OpenAI({
|
||||
apiKey: process.env.LITELLM_API_KEY,
|
||||
baseURL: "${baseUrl}",
|
||||
});
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
model: "${exampleModel(group)}",
|
||||
messages: [{ role: "user", content: "Hello!" }],
|
||||
});
|
||||
|
||||
console.log(response);`;
|
||||
|
||||
interface RoutingGroupSnippetProps {
|
||||
group: RoutingGroup;
|
||||
baseUrl: string;
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-6">
|
||||
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Inbox className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">No routing groups yet</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Create a group to load-balance a set of models behind one name.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SNIPPET_BLOCK_STYLE: React.CSSProperties = {
|
||||
backgroundColor: "#111827",
|
||||
color: "#f3f4f6",
|
||||
borderRadius: 6,
|
||||
padding: 16,
|
||||
fontSize: 12,
|
||||
whiteSpace: "pre",
|
||||
overflowX: "auto",
|
||||
};
|
||||
|
||||
const RoutingGroupSnippet: React.FC<RoutingGroupSnippetProps> = ({ group, baseUrl }) => {
|
||||
const snippets = {
|
||||
curl: buildCurlSnippet(group, baseUrl),
|
||||
python: buildPythonSnippet(group, baseUrl),
|
||||
javascript: buildJsSnippet(group, baseUrl),
|
||||
} as const;
|
||||
type SnippetKey = keyof typeof snippets;
|
||||
const [activeKey, setActiveKey] = useState<SnippetKey>("curl");
|
||||
|
||||
const items = [
|
||||
{ key: "curl", label: "cURL" },
|
||||
{ key: "python", label: "Python (OpenAI SDK)" },
|
||||
{ key: "javascript", label: "JavaScript (OpenAI SDK)" },
|
||||
].map(({ key, label }) => ({
|
||||
key,
|
||||
label,
|
||||
children: (
|
||||
<Paragraph code className="mb-0!" style={SNIPPET_BLOCK_STYLE}>
|
||||
{snippets[key as SnippetKey]}
|
||||
</Paragraph>
|
||||
),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
size="small"
|
||||
activeKey={activeKey}
|
||||
onChange={(k) => setActiveKey(k as SnippetKey)}
|
||||
items={items}
|
||||
tabBarExtraContent={
|
||||
<Paragraph copyable={{ text: snippets[activeKey], tooltips: ["Copy", "Copied"] }} className="mb-0!" />
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const RoutingGroupsTable: React.FC<RoutingGroupsTableProps> = ({ groups, loading, onEdit, onDelete, proxyBaseUrl }) => {
|
||||
const [expandedRowKeys, setExpandedRowKeys] = useState<React.Key[]>([]);
|
||||
const RoutingGroupsTable: React.FC<RoutingGroupsTableProps> = ({
|
||||
groups,
|
||||
isLoading,
|
||||
onEdit,
|
||||
onDelete,
|
||||
proxyBaseUrl,
|
||||
}) => {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [expanded, setExpanded] = useState<ExpandedState>({});
|
||||
const baseUrl = resolveBaseUrl(proxyBaseUrl);
|
||||
|
||||
const columns: ColumnsType<RoutingGroup> = [
|
||||
{
|
||||
title: "GROUP NAME",
|
||||
dataIndex: "group_name",
|
||||
key: "group_name",
|
||||
render: (name: string) => (
|
||||
<Text strong className="text-blue-600">
|
||||
{name}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "MODELS",
|
||||
dataIndex: "models",
|
||||
key: "models",
|
||||
render: (models: string[]) => (
|
||||
<Flex wrap="wrap" gap={4}>
|
||||
{models.map((m) => (
|
||||
<Tag key={m}>{m}</Tag>
|
||||
))}
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "STRATEGY",
|
||||
dataIndex: "routing_strategy",
|
||||
key: "routing_strategy",
|
||||
render: (strategy: string) => (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<BranchesOutlined className="text-gray-400" />
|
||||
<Text>{formatStrategyLabel(strategy)}</Text>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "ACTIONS",
|
||||
key: "actions",
|
||||
width: 120,
|
||||
align: "right",
|
||||
render: (_, group) => (
|
||||
<Flex justify="flex-end" align="center" gap={8}>
|
||||
<Tooltip title="Edit">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(group);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete">
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(group);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
];
|
||||
const toggleUsage = useCallback((group: RoutingGroup) => {
|
||||
setExpanded((previous) => {
|
||||
const current = previous === true ? {} : previous;
|
||||
return { ...current, [group.group_name]: current[group.group_name] !== true };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const deps = { onEdit, onDelete, onToggleUsage: toggleUsage };
|
||||
return getRoutingGroupsTableColumns(deps);
|
||||
}, [onEdit, onDelete, toggleUsage]);
|
||||
|
||||
return (
|
||||
<Table<RoutingGroup>
|
||||
rowKey="group_name"
|
||||
<DataTable
|
||||
data={groups}
|
||||
columns={columns}
|
||||
dataSource={groups}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
expandable={{
|
||||
expandedRowKeys,
|
||||
onExpandedRowsChange: (keys) => setExpandedRowKeys([...keys]),
|
||||
expandedRowRender: (group) => (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-md p-4 my-2">
|
||||
<Flex align="center" gap={8} className="mb-2">
|
||||
<CodeOutlined className="text-blue-500" />
|
||||
<Text strong>How routing works for this group</Text>
|
||||
</Flex>
|
||||
<Paragraph className="text-sm text-gray-600 mb-3">
|
||||
Callers request any model in the group by name — LiteLLM picks a deployment behind the scenes using the{" "}
|
||||
<Text strong>{formatStrategyLabel(group.routing_strategy)}</Text> strategy.
|
||||
</Paragraph>
|
||||
<RoutingGroupSnippet group={group} baseUrl={baseUrl} />
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
getRowId={(group) => group.group_name}
|
||||
sortingMode="client"
|
||||
sorting={sorting}
|
||||
onSortingChange={setSorting}
|
||||
expanded={expanded}
|
||||
onExpandedChange={setExpanded}
|
||||
getRowCanExpand={() => true}
|
||||
renderSubComponent={({ row }) => <RoutingGroupUsagePanel group={row.original} baseUrl={baseUrl} />}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading routing groups…"
|
||||
noDataMessage={<EmptyState />}
|
||||
size="compact"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
"use client";
|
||||
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { GitBranch, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { IdentityCell, ModelsCell } from "@/components/shared/table_cells";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import { formatStrategyLabel } from "./strategy";
|
||||
import type { RoutingGroup } from "./types";
|
||||
|
||||
interface RoutingGroupRowActionsProps {
|
||||
group: RoutingGroup;
|
||||
onEdit: (group: RoutingGroup) => void;
|
||||
onDelete: (group: RoutingGroup) => void;
|
||||
}
|
||||
|
||||
function RoutingGroupRowActions({ group, onEdit, onDelete }: RoutingGroupRowActionsProps) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={`Open actions for ${group.group_name}`}
|
||||
data-testid={`routing-group-actions-${group.group_name}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem data-testid="routing-group-action-edit" onClick={() => onEdit(group)}>
|
||||
<Pencil />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
data-testid="routing-group-action-delete"
|
||||
onClick={() => onDelete(group)}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
interface RoutingGroupsTableColumnsDeps {
|
||||
onEdit: (group: RoutingGroup) => void;
|
||||
onDelete: (group: RoutingGroup) => void;
|
||||
onToggleUsage: (group: RoutingGroup) => void;
|
||||
}
|
||||
|
||||
export const getRoutingGroupsTableColumns = ({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggleUsage,
|
||||
}: RoutingGroupsTableColumnsDeps): ColumnDef<RoutingGroup>[] => [
|
||||
{
|
||||
id: "group_name",
|
||||
accessorKey: "group_name",
|
||||
meta: { title: "Group Name", skeleton: "text" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Group Name" />,
|
||||
size: 240,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
<IdentityCell title={row.original.group_name} className="max-w-60" onClick={() => onToggleUsage(row.original)} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
meta: { title: "Models", skeleton: "chips" },
|
||||
header: "Models",
|
||||
size: 320,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <ModelsCell models={row.original.models} />,
|
||||
},
|
||||
{
|
||||
id: "routing_strategy",
|
||||
accessorKey: "routing_strategy",
|
||||
meta: { title: "Strategy", skeleton: "text" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Strategy" />,
|
||||
size: 180,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
<span className="flex items-center gap-1.5 text-sm">
|
||||
<GitBranch className="size-4 shrink-0 text-muted-foreground" />
|
||||
{formatStrategyLabel(row.original.routing_strategy)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
meta: { className: "text-right", headerClassName: "text-right" },
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
size: 64,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end">
|
||||
<RoutingGroupRowActions group={row.original} onEdit={onEdit} onDelete={onDelete} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
@ -126,7 +126,7 @@ const RoutingGroups: React.FC = () => {
|
|||
|
||||
<RoutingGroupsTable
|
||||
groups={filteredGroups}
|
||||
loading={isLoading}
|
||||
isLoading={isLoading}
|
||||
onEdit={openEdit}
|
||||
onDelete={(g) => setDeletingGroup(g)}
|
||||
proxyBaseUrl={proxySettings.LITELLM_UI_API_DOC_BASE_URL?.trim() || proxySettings.PROXY_BASE_URL || ""}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
const STRATEGY_LABELS: Readonly<Record<string, string>> = {
|
||||
"simple-shuffle": "Simple Shuffle",
|
||||
"least-busy": "Least Busy",
|
||||
"usage-based-routing": "Usage Based",
|
||||
"latency-based-routing": "Latency Based",
|
||||
};
|
||||
|
||||
export const formatStrategyLabel = (strategy: string): string => STRATEGY_LABELS[strategy] ?? strategy;
|
||||
Loading…
Add table
Reference in a new issue