mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
* feat(providers): add ZeroGPU as an OpenAI-compatible provider Registers zerogpu through the JSON provider registry so zerogpu/<model> routes to https://api.zerogpu.ai/v1/chat/completions with ZEROGPU_API_KEY and an optional ZEROGPU_API_BASE. Adds LlmProviders.ZEROGPU, the constants.py provider and endpoint lists, cost map entries for the 10 ZeroGPU text generation models, the endpoint matrix, the Add Model form fields, the README row and the dashboard provider entry * feat(zerogpu): add capability flags verified against the live API Live calls to https://api.zerogpu.ai/v1/chat/completions confirmed tools, tool_choice and json_schema response_format on 8 of the 10 catalog text models, cached token reporting on deepseek-v4.1-flash, gpt-4.1-mini, gpt-5.4-nano and gpt-5.6-luna, and reasoning_effort on gpt-5.6-luna. LFM2.5-1.2B-Instruct answers stream requests with plain JSON instead of SSE and LFM2.5-1.2B-Thinking returns 500 on every request, so both stay out of the cost map until the API serves them correctly * feat(zerogpu): price LFM2.5-1.2B-Instruct Adds zerogpu/LFM2.5-1.2B-Instruct to the cost map with the catalog prices and context window. It gets no capability flags because the live API ignores tools, response_format and max_completion_tokens for this model, so LiteLLM drops tool params for it instead of sending them. LFM2.5-1.2B-Thinking stays out of the cost map * fix(zerogpu): reject transcription requests before calling the upstream API ZeroGPU only serves chat completions, so drop it from the OpenAI transcription transport set. A zerogpu transcription request now fails locally instead of hitting an /audio/transcriptions endpoint that does not exist * test(zerogpu): cover the responses and messages bridges Both endpoints reach ZeroGPU through chat completions, so assert the request each one sends upstream and the reply it returns * refactor(zerogpu): move zerogpu out of the provider list tails New upstream providers append to the end of openai_compatible_endpoints, openai_compatible_providers and providers.json, so an entry at the tail conflicts with every one of them. Placing zerogpu next to long-stable entries keeps those merges clean. No behavior change * fix(zerogpu): price cached input for qwen3-30b-a3b-fp8 The ZeroGPU model catalog (https://docs.zerogpu.ai/docs/model-catalog, checked 2026-10-09) lists cached input for qwen3-30b-a3b-fp8 at $0.05 per 1M tokens. Without cache_read_input_token_cost, cached tokens were billed at the full $0.10 input rate * revert(zerogpu): leave the audio transcription provider set unchanged Per review, keep OPENAI_AUDIO_TRANSCRIPTION_PROVIDERS as upstream defines it. This PR only covers chat completions, and ZeroGPU now serves /audio/transcriptions anyway, so the local refusal and its test are removed * fix(zerogpu): add max_tokens so trim_messages can size ZeroGPU history trim_messages reads max_tokens as the fallback for max_input_tokens and evaluates it eagerly, so without it the lookup raised, got swallowed, and the full history came back untrimmed. max_tokens matches each model's maxTokens in ZeroGPU's models API (https://api-dashboard.zerogpu.ai/api/models, checked 2026-10-10) * test(zerogpu): record mocked requests on a recorder object The mock handler appended to a list the caller passed in, which AGENTS.md forbids. A recorder now owns the captured requests as a tuple * fix(zerogpu): cap max_tokens at each model's output limit Each cap is the max_output_tokens ZeroGPU stores for the model in its production models table (read 2026-10-20), the same column its /v1/models serves as max_length. That gives 65536 for deepseek-v4.1-flash, 32768 for gpt-4.1-mini and gpt-oss-120b, 128000 for gpt-5.4-nano and gpt-5.6-luna, 8192 for LFM2.5-1.2B-Instruct and llama-3.1-8b-instruct-fast, and 16384 for qwen3-30b-a3b-fp8. glm-5.3-flash is stored as 32786, which reads as a typo for 32768, so that row uses 32768 trim_messages sizes history from max_input_tokens, so lowering max_tokens leaves it unaffected * fix(zerogpu): flag prompt caching on glm-5.3-flash Repeating the same ~4.3K-token prompt against ZeroGPU on 2026-10-20 returned cached_tokens of 4320 on some glm-5.3-flash calls. gpt-oss-120b and llama-3.1-8b-instruct-fast returned 0 on all nine calls, and qwen3-30b-a3b-fp8 never returned cached_tokens, so they stay unflagged. Their cached prices stay as ZeroGPU publishes them, so any cache hit is still billed at the right rate * fix(zerogpu): set gpt-5.4-nano context window to 272000 ZeroGPU accepts a 271001-token gpt-5.4-nano prompt and rejects 273000 tokens as over the context window (checked 2026-10-21), so max_input_tokens is 272000 rather than the 400000 its catalog lists * fix(zerogpu): list zerogpu models for zerogpu/* wildcards zerogpu had no models_by_provider entry, so get_provider_models("zerogpu") returned None and a zerogpu/* wildcard listed no models. zerogpu_models is now filled from the cost map rows like openinfer_models and registered in models_by_provider * fix(zerogpu): flag gpt-5.4-nano as a reasoning model ZeroGPU honors reasoning_effort on gpt-5.4-nano (checked 2026-10-21). Without it the model spends 0 reasoning tokens, and with low or high it spends 32 or more. LiteLLM only treated it as a reasoning model by falling back to OpenAI's bare gpt-5.4-nano row, so get_model_info and /model/info reported supports_reasoning as null. The row now carries the flag itself * fix(zerogpu): flag deepseek, glm, gpt-oss and qwen3 as reasoning models ZeroGPU's /v1/models marks these four as reasoning models and accepts reasoning_effort on all of them (checked 2026-10-21), but LiteLLM rejected the param with UnsupportedParamsError because their rows had no supports_reasoning flag. glm reports reasoning_tokens under reasoning_effort, and gpt-oss and qwen3 spend hidden reasoning tokens on every reply. The gpt-oss test case now expects reasoning_effort to reach ZeroGPU * fix(zerogpu): set gpt-oss-120b context window to 128000 ZeroGPU routes gpt-oss-120b across several upstream backends, and at least one of them returns 413 past a 128000-token window, so max_input_tokens uses that as the safe floor. The backends reached on 2026-10-21 accepted up to about 131K prompt tokens, so this only gives up a little headroom on the larger ones * feat(zerogpu): call ZeroGPU's Responses API natively ZeroGPU serves /v1/responses for all nine catalog models, so LiteLLM no longer bridges Responses requests through chat completions. Native calls return ZeroGPU's reasoning output items for glm, gpt-oss and qwen3, which the bridge dropped. Checked live on 2026-10-22 through litellm.responses for every model, non-streaming and streaming, plus a gpt-oss tool call. LFM2.5-1.2B-Instruct streams correctly now that ZeroGPU's text_generation lane emits Responses SSE * fix(zerogpu): stop inferring the zerogpu provider from api_base alone Listing https://api.zerogpu.ai/v1 in openai_compatible_endpoints rerouted bare model names on that api_base from openai to zerogpu. A bare gpt-5.6-luna call with verbosity used to return 200 and started raising UnsupportedParamsError. Drop the entry and its api_base-alone test, so zerogpu is only selected through the zerogpu/ prefix * fix(zerogpu): price gpt-6-luna, llama-3.2-3b, LFM2.5-1.2B-Thinking and qwen3.6 A zerogpu/* wildcard served these four chat models with no cost-map row, so their calls logged $0 spend. Add rows at ZeroGPU's catalog prices from https://api-dashboard.zerogpu.ai/api/models, with context windows and output caps from ZeroGPU's models table. LFM2.5-1.2B-Thinking has no cached price, like LFM2.5-1.2B-Instruct. gpt-6-luna is flagged supports_reasoning because LiteLLM already routes gpt-6 names as reasoning models. Checked live on 2026-10-28, all four now report nonzero response_cost * fix(zerogpu): drop the retired max_tokens field from the zerogpu rows Upstream #48471 retired max_tokens from the cost map in favor of max_input_tokens and max_output_tokens, and test_no_row_carries_the_retired_max_tokens_field failed on all 13 zerogpu rows. Every row already carries the same value in max_output_tokens, so nothing is lost. The zerogpu limits test now checks that each row's output limit fits its context window * chore(zerogpu): regenerate provider endpoint support for zerogpu Upstream #48619 generates provider_endpoints_support.json from the cost map and checks it in CI, and the hand-written zerogpu entry no longer matched. Regenerated with scripts/generate_provider_endpoints_support.py. Only the zerogpu entry changed, and it now matches what the generator emits for other JSON-registry providers such as openinfer * fix(zerogpu): flag tools, json_schema and prompt caching on gpt-6-luna ZeroGPU serves function calling, tool_choice, json_schema response_format and prompt caching on gpt-6-luna, so set supports_function_calling, supports_tool_choice, supports_response_schema and supports_prompt_caching on its row in both cost maps. Checked live on 2026-10-28, a weather tool call came back with the right arguments, json_schema output validated, and repeated prompts reported 3260 of 3263 tokens cached * fix(zerogpu): set qwen3.6-35b-a3b context to 262144 and flag its tools and reasoning ZeroGPU accepts up to 262,144 input tokens on qwen3.6-35b-a3b and serves tools, tool_choice, json_schema response_format and reasoning_effort on it. Raise max_input_tokens from 16384 and set supports_function_calling, supports_tool_choice, supports_response_schema and supports_reasoning in both cost maps. Before this, LiteLLM rejected tools and reasoning_effort for the model. Checked live on 2026-10-28, a 209,905-token prompt succeeded, an oversized one was refused with a 262144-token limit, and tool calls, json_schema output and reasoning_effort all worked * fix(zerogpu): flag vision on gpt-6-luna and qwen3.6-35b-a3b ZeroGPU reads image inputs on gpt-6-luna and qwen3.6-35b-a3b, so set supports_vision on both rows in both cost maps. Checked live on 2026-10-28, both models named the animal in an image_url photo, and the image showed up in their prompt token counts
1745 lines
65 KiB
Python
1745 lines
65 KiB
Python
import asyncio
|
|
import json
|
|
import re
|
|
from datetime import datetime, timezone
|
|
from importlib.resources import files
|
|
from itertools import chain
|
|
from types import SimpleNamespace
|
|
from typing import Final
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
from pydantic import TypeAdapter
|
|
|
|
import litellm
|
|
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
|
from litellm.proxy.db.health_check_latest import LatestHealthCheckRow
|
|
from litellm.proxy.public_endpoints import router
|
|
from litellm.router_strategy.complexity_router.fuse_presets import get_fuse_presets
|
|
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
|
|
ModelGroupInfoProxy,
|
|
)
|
|
from litellm.types.proxy.public_endpoints.public_endpoints import ProviderCreateInfo, ProviderCredentialField
|
|
from litellm.types.utils import LlmProviders
|
|
|
|
|
|
def test_fuse_presets_route_serves_the_shared_catalog_without_authentication() -> None:
|
|
app: Final = FastAPI()
|
|
app.include_router(router)
|
|
client: Final = TestClient(app)
|
|
response: Final = client.get("/public/complexity_router/fuse_presets")
|
|
assert response.status_code == 200
|
|
assert response.json() == get_fuse_presets().model_dump(mode="json")
|
|
assert client.get("/public/complexity_router/fuse_presets").json() == response.json()
|
|
|
|
|
|
def test_get_supported_providers_returns_enum_values():
|
|
app_instance = FastAPI()
|
|
app_instance.include_router(router)
|
|
client = TestClient(app_instance)
|
|
|
|
response = client.get("/public/providers")
|
|
|
|
assert response.status_code == 200
|
|
expected_providers = sorted(provider.value for provider in LlmProviders)
|
|
assert response.json() == expected_providers
|
|
|
|
|
|
def test_get_provider_create_fields():
|
|
app_instance = FastAPI()
|
|
app_instance.include_router(router)
|
|
client = TestClient(app_instance)
|
|
|
|
response = client.get("/public/providers/fields")
|
|
|
|
assert response.status_code == 200
|
|
|
|
response_data = response.json()
|
|
|
|
assert isinstance(response_data, list)
|
|
|
|
assert len(response_data) > 0
|
|
|
|
first_provider = response_data[0]
|
|
assert "provider" in first_provider
|
|
assert "provider_display_name" in first_provider
|
|
assert "litellm_provider" in first_provider
|
|
assert "credential_fields" in first_provider
|
|
|
|
assert isinstance(first_provider["credential_fields"], list)
|
|
|
|
has_detailed_fields = any(
|
|
provider.get("credential_fields") and len(provider.get("credential_fields", [])) > 0
|
|
for provider in response_data
|
|
)
|
|
assert has_detailed_fields, "Expected at least one provider to have detailed credential fields"
|
|
|
|
|
|
def test_get_litellm_model_cost_map_catalog_only_excludes_runtime_registered_entries(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap
|
|
|
|
monkeypatch.setattr(litellm, "model_cost", dict(litellm.model_cost))
|
|
runtime_key: Final = "lit-9263-deployment-alias"
|
|
litellm.register_model({runtime_key: {"litellm_provider": "openai", "mode": "chat"}}, persist_across_reloads=False)
|
|
app: Final = FastAPI()
|
|
app.include_router(router)
|
|
client: Final = TestClient(app)
|
|
|
|
live_response: Final = client.get("/public/litellm_model_cost_map")
|
|
catalog_response: Final = client.get("/public/litellm_model_cost_map", params={"catalog_only": "true"})
|
|
|
|
assert live_response.status_code == 200
|
|
assert live_response.json()[runtime_key]["litellm_provider"] == "openai"
|
|
assert catalog_response.status_code == 200
|
|
catalog_payload: Final = catalog_response.json()
|
|
assert runtime_key not in catalog_payload
|
|
assert catalog_payload == json.loads(
|
|
json.dumps({key: dict(entry) for key, entry in GetModelCostMap.loaded_model_cost_map().items()})
|
|
)
|
|
|
|
|
|
def test_get_litellm_model_cost_map_returns_cost_map():
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
client = TestClient(app)
|
|
|
|
response = client.get("/public/litellm_model_cost_map")
|
|
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
assert isinstance(payload, dict)
|
|
assert len(payload) > 0, "Expected model cost map to contain at least one model"
|
|
|
|
# Verify the structure contains expected keys for at least one model
|
|
# Check for a common model like gpt-4 or gpt-3.5-turbo
|
|
model_keys = list(payload.keys())
|
|
assert len(model_keys) > 0
|
|
|
|
# Verify at least one model has expected cost fields
|
|
sample_model = model_keys[0]
|
|
sample_model_data = payload[sample_model]
|
|
assert isinstance(sample_model_data, dict)
|
|
# Check for common cost fields that should be present
|
|
assert "input_cost_per_token" in sample_model_data or "output_cost_per_token" in sample_model_data
|
|
|
|
|
|
def test_public_ai_hub_info_is_public_by_default(monkeypatch):
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
client = TestClient(app)
|
|
|
|
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {})
|
|
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "sk-master")
|
|
|
|
response = client.get("/public/model_hub/info")
|
|
|
|
assert response.status_code == 200, response.text
|
|
|
|
|
|
def test_watsonx_provider_fields():
|
|
"""Test that Watsonx provider has all required credential fields including multiple auth options."""
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
client = TestClient(app)
|
|
|
|
response = client.get("/public/providers/fields")
|
|
providers = response.json()
|
|
|
|
watsonx = next((p for p in providers if p["provider"] == "WATSONX"), None)
|
|
assert watsonx is not None
|
|
|
|
field_keys = [f["key"] for f in watsonx["credential_fields"]]
|
|
# Core fields
|
|
assert "api_base" in field_keys
|
|
assert "project_id" in field_keys
|
|
assert "space_id" in field_keys
|
|
# Multiple auth methods supported
|
|
assert "api_key" in field_keys
|
|
assert "token" in field_keys
|
|
assert "zen_api_key" in field_keys
|
|
|
|
|
|
def test_azure_provider_fields_include_entra_id():
|
|
"""Azure provider must expose Entra ID (Service Principal) credential fields so
|
|
the UI can input tenant_id / client_id / client_secret as an alternative to api_key.
|
|
"""
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
client = TestClient(app)
|
|
|
|
response = client.get("/public/providers/fields")
|
|
providers = response.json()
|
|
|
|
azure = next((p for p in providers if p["provider"] == "Azure"), None)
|
|
assert azure is not None
|
|
|
|
fields_by_key = {f["key"]: f for f in azure["credential_fields"]}
|
|
# API-key auth still supported
|
|
assert "api_key" in fields_by_key
|
|
# Entra ID fields
|
|
assert "tenant_id" in fields_by_key
|
|
assert "client_id" in fields_by_key
|
|
assert "client_secret" in fields_by_key
|
|
# client_secret must be masked in the UI
|
|
assert fields_by_key["client_secret"]["field_type"] == "password"
|
|
# Entra ID is an alternative to api_key, so none of these are individually required
|
|
assert fields_by_key["tenant_id"]["required"] is False
|
|
assert fields_by_key["client_id"]["required"] is False
|
|
assert fields_by_key["client_secret"]["required"] is False
|
|
|
|
|
|
def test_anthropic_provider_fields_support_byok():
|
|
"""
|
|
The Anthropic provider form must allow BYOK:
|
|
- api_key is optional (not required) so admins can create models without a key
|
|
- api_key has a non-null tooltip explaining the BYOK use case
|
|
"""
|
|
app_instance = FastAPI()
|
|
app_instance.include_router(router)
|
|
test_client = TestClient(app_instance)
|
|
|
|
response = test_client.get("/public/providers/fields")
|
|
assert response.status_code == 200
|
|
providers = response.json()
|
|
|
|
anthropic = next((p for p in providers if p["provider"] == "Anthropic"), None)
|
|
assert anthropic is not None, "Anthropic provider entry not found"
|
|
|
|
fields_by_key = {f["key"]: f for f in anthropic["credential_fields"]}
|
|
assert "api_key" in fields_by_key
|
|
assert fields_by_key["api_key"]["required"] is False, (
|
|
"Anthropic api_key must be optional so admins can configure BYOK models "
|
|
"without entering a key. See BYOK tutorial."
|
|
)
|
|
assert fields_by_key["api_key"].get("tooltip"), (
|
|
"Anthropic api_key must have a tooltip explaining the BYOK use case."
|
|
)
|
|
assert "api_base" in fields_by_key, (
|
|
"Anthropic provider form must expose api_base so cloud customers "
|
|
"can override the upstream URL without env var access."
|
|
)
|
|
api_base_field = fields_by_key["api_base"]
|
|
assert api_base_field["required"] is False
|
|
assert api_base_field["field_type"] == "text"
|
|
assert api_base_field.get("tooltip"), "api_base should have a tooltip explaining it is optional."
|
|
|
|
# UI forms render fields in credential_fields order; api_base should come first
|
|
# so an admin sees the URL override before the key field.
|
|
field_order = [f["key"] for f in anthropic["credential_fields"]]
|
|
assert field_order.index("api_base") < field_order.index("api_key"), (
|
|
"api_base must appear before api_key in credential_fields (matches AI21 and ANTHROPIC_TEXT convention)."
|
|
)
|
|
|
|
|
|
def test_bedrock_mantle_provider_fields():
|
|
"""Amazon Bedrock Mantle must be a selectable provider in the Add Model flow.
|
|
|
|
The dropdown is driven entirely by /public/providers/fields, so a missing
|
|
entry means Mantle cannot be added through the UI at all (regression guard
|
|
for LIT-3885). The credential fields must match what the backend actually
|
|
honors: an optional bearer api_key (BYOK), the AWS SigV4 chain, a region,
|
|
and an api_base override.
|
|
"""
|
|
app_instance = FastAPI()
|
|
app_instance.include_router(router)
|
|
test_client = TestClient(app_instance)
|
|
|
|
response = test_client.get("/public/providers/fields")
|
|
assert response.status_code == 200
|
|
providers = response.json()
|
|
|
|
mantle = next((p for p in providers if p["provider"] == "BedrockMantle"), None)
|
|
assert mantle is not None, "Bedrock Mantle provider entry not found"
|
|
|
|
# provider must equal the UI provider_map key so the model dropdown resolves
|
|
# bedrock_mantle models; litellm_provider must be the backend slug.
|
|
assert mantle["provider_display_name"] == "Amazon Bedrock Mantle"
|
|
assert mantle["litellm_provider"] == "bedrock_mantle"
|
|
assert mantle["default_model_placeholder"].startswith("bedrock_mantle/")
|
|
|
|
fields_by_key = {f["key"]: f for f in mantle["credential_fields"]}
|
|
|
|
# Bearer-token auth is BYOK: optional and masked.
|
|
assert "api_key" in fields_by_key
|
|
assert fields_by_key["api_key"]["required"] is False
|
|
assert fields_by_key["api_key"]["field_type"] == "password"
|
|
|
|
# AWS SigV4 fallback credentials.
|
|
assert fields_by_key["aws_access_key_id"]["field_type"] == "password"
|
|
assert fields_by_key["aws_secret_access_key"]["field_type"] == "password"
|
|
assert "aws_region_name" in fields_by_key
|
|
|
|
# api_base override so admins can target a custom Mantle host without env access.
|
|
assert fields_by_key["api_base"]["field_type"] == "text"
|
|
|
|
|
|
def test_vllm_provider_display_names_are_distinct():
|
|
"""Hosted and local vLLM must not share a dropdown label.
|
|
|
|
The Add Model provider dropdown is driven by /public/providers/fields.
|
|
Both entries previously rendered as near-identical "vllm"/"Vllm" rows
|
|
with the same logo, so admins could not tell them apart.
|
|
"""
|
|
app_instance = FastAPI()
|
|
app_instance.include_router(router)
|
|
test_client = TestClient(app_instance)
|
|
|
|
response = test_client.get("/public/providers/fields")
|
|
assert response.status_code == 200
|
|
providers = response.json()
|
|
|
|
hosted = next((p for p in providers if p["provider"] == "Hosted_Vllm"), None)
|
|
local = next((p for p in providers if p["provider"] == "VLLM"), None)
|
|
assert hosted is not None, "Hosted vLLM provider entry not found"
|
|
assert local is not None, "Local vLLM provider entry not found"
|
|
|
|
assert hosted["provider_display_name"] == "Hosted vLLM"
|
|
assert local["provider_display_name"] == "Local vLLM"
|
|
assert hosted["provider_display_name"].casefold() != local["provider_display_name"].casefold()
|
|
assert hosted["litellm_provider"] == "hosted_vllm"
|
|
assert local["litellm_provider"] == "vllm"
|
|
|
|
|
|
def test_nvidia_riva_provider_fields():
|
|
app_instance = FastAPI()
|
|
app_instance.include_router(router)
|
|
test_client = TestClient(app_instance)
|
|
|
|
response = test_client.get("/public/providers/fields")
|
|
assert response.status_code == 200
|
|
providers = response.json()
|
|
|
|
riva = next((p for p in providers if p["provider"] == "NVIDIA_RIVA"), None)
|
|
assert riva is not None, "NVIDIA Riva provider entry not found"
|
|
|
|
assert riva["provider_display_name"] == "Nvidia Riva"
|
|
assert riva["litellm_provider"] == LlmProviders.NVIDIA_RIVA.value
|
|
assert riva["default_model_placeholder"].startswith("nvidia_riva/")
|
|
|
|
fields_by_key = {f["key"]: f for f in riva["credential_fields"]}
|
|
|
|
assert fields_by_key["api_base"]["required"] is True
|
|
assert fields_by_key["api_base"]["field_type"] == "text"
|
|
|
|
assert fields_by_key["api_key"]["required"] is False
|
|
assert fields_by_key["api_key"]["field_type"] == "password"
|
|
|
|
assert "nvcf_function_id" in fields_by_key
|
|
assert fields_by_key["nvcf_function_id"]["required"] is False
|
|
|
|
|
|
def test_cognition_provider_fields():
|
|
"""Cognition must be selectable in the Add Model flow (LIT-5348).
|
|
|
|
The dropdown is driven entirely by /public/providers/fields, so without an
|
|
entry here admins have to fall back to the generic OpenAI-compatible route,
|
|
which is exactly the provider identity mix-up this feature removes.
|
|
"""
|
|
app_instance = FastAPI()
|
|
app_instance.include_router(router)
|
|
test_client = TestClient(app_instance)
|
|
|
|
response = test_client.get("/public/providers/fields")
|
|
assert response.status_code == 200
|
|
providers = response.json()
|
|
|
|
cognition = next((p for p in providers if p["provider"] == "Cognition"), None)
|
|
assert cognition is not None, "Cognition provider entry not found"
|
|
|
|
assert cognition["provider_display_name"] == "Cognition"
|
|
assert cognition["litellm_provider"] == "cognition"
|
|
assert cognition["default_model_placeholder"].startswith("cognition/")
|
|
|
|
fields_by_key = {f["key"]: f for f in cognition["credential_fields"]}
|
|
|
|
assert fields_by_key["api_key"]["required"] is True
|
|
assert fields_by_key["api_key"]["field_type"] == "password"
|
|
|
|
assert fields_by_key["api_base"]["field_type"] == "text"
|
|
assert fields_by_key["api_base"]["required"] is False
|
|
|
|
|
|
def test_zerogpu_add_model_form_defaults_resolve_to_a_priced_zerogpu_model():
|
|
import litellm
|
|
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
|
|
|
|
app_instance: Final = FastAPI()
|
|
app_instance.include_router(router)
|
|
providers: Final = TestClient(app_instance).get("/public/providers/fields").json()
|
|
|
|
zerogpu: Final = next(p for p in providers if p["litellm_provider"] == "zerogpu")
|
|
fields_by_key: Final = {f["key"]: f for f in zerogpu["credential_fields"]}
|
|
placeholder: Final = zerogpu["default_model_placeholder"]
|
|
_, provider, _, api_base = get_llm_provider(model=placeholder)
|
|
|
|
assert provider == "zerogpu"
|
|
assert api_base == fields_by_key["api_base"]["placeholder"]
|
|
assert litellm.model_cost[placeholder]["litellm_provider"] == "zerogpu"
|
|
assert fields_by_key["api_key"]["required"] is True
|
|
assert fields_by_key["api_key"]["field_type"] == "password"
|
|
|
|
|
|
def test_qwen_mainland_provider_fields_carry_the_qianwen_brand():
|
|
app_instance = FastAPI()
|
|
app_instance.include_router(router)
|
|
test_client = TestClient(app_instance)
|
|
|
|
providers = test_client.get("/public/providers/fields").json()
|
|
|
|
mainland = next(p for p in providers if p["litellm_provider"] == "qwen_ai_platform")
|
|
international = next(p for p in providers if p["litellm_provider"] == "qwencloud")
|
|
|
|
assert mainland["provider_display_name"] == "Qianwen AI Platform"
|
|
assert international["provider_display_name"] == "QwenCloud"
|
|
|
|
mainland_fields = {f["key"]: f for f in mainland["credential_fields"]}
|
|
assert mainland_fields["api_key"]["label"] == "Qianwen AI Platform API Key"
|
|
assert "Qianwen AI Platform" in mainland_fields["api_base"]["tooltip"]
|
|
assert "Qwen AI Platform" not in json.dumps(mainland)
|
|
|
|
|
|
def test_chatgpt_provider_fields():
|
|
app_instance = FastAPI()
|
|
app_instance.include_router(router)
|
|
test_client = TestClient(app_instance)
|
|
|
|
response = test_client.get("/public/providers/fields")
|
|
assert response.status_code == 200
|
|
providers = response.json()
|
|
|
|
chatgpt = next((p for p in providers if p["provider"] == "CHATGPT"), None)
|
|
assert chatgpt is not None, "ChatGPT provider entry not found"
|
|
|
|
assert chatgpt["provider_display_name"] == "ChatGPT Subscription"
|
|
assert chatgpt["litellm_provider"] == LlmProviders.CHATGPT.value
|
|
assert chatgpt["default_model_placeholder"].startswith("chatgpt/")
|
|
assert chatgpt["credential_fields"] == []
|
|
|
|
|
|
def test_tencent_provider_fields():
|
|
app_instance = FastAPI()
|
|
app_instance.include_router(router)
|
|
test_client = TestClient(app_instance)
|
|
|
|
response = test_client.get("/public/providers/fields")
|
|
assert response.status_code == 200
|
|
providers = response.json()
|
|
|
|
tencent = next((p for p in providers if p["provider"] == "Tencent"), None)
|
|
assert tencent is not None, "Tencent provider entry not found"
|
|
|
|
assert tencent["provider_display_name"] == "Tencent"
|
|
assert tencent["litellm_provider"] == LlmProviders.TENCENT.value
|
|
assert tencent["default_model_placeholder"].startswith("tencent/")
|
|
|
|
fields_by_key = {f["key"]: f for f in tencent["credential_fields"]}
|
|
|
|
assert fields_by_key["api_key"]["required"] is True
|
|
assert fields_by_key["api_key"]["field_type"] == "password"
|
|
|
|
assert fields_by_key["api_base"]["field_type"] == "text"
|
|
assert fields_by_key["api_base"]["required"] is False
|
|
|
|
|
|
def _decisions_provider_entry(provider: str) -> ProviderCreateInfo:
|
|
app_instance: Final = FastAPI()
|
|
app_instance.include_router(router)
|
|
test_client: Final = TestClient(app_instance)
|
|
|
|
response: Final = test_client.get("/public/providers/fields")
|
|
assert response.status_code == 200
|
|
providers: Final = TypeAdapter(list[ProviderCreateInfo]).validate_python(response.json())
|
|
entry: Final = next((p for p in providers if p.provider == provider), None)
|
|
assert entry is not None, f"{provider} provider entry not found"
|
|
return entry
|
|
|
|
|
|
@pytest.mark.parametrize("display_name,provider", (("TypeSafe", LlmProviders.TYPESAFE), ("Nace", LlmProviders.NACE)))
|
|
def test_decisions_provider_fields(display_name: str, provider: LlmProviders) -> None:
|
|
entry: Final = _decisions_provider_entry(display_name)
|
|
|
|
assert entry.provider_display_name == display_name
|
|
assert entry.litellm_provider == provider.value
|
|
assert entry.default_model_placeholder is not None
|
|
assert entry.default_model_placeholder.startswith(f"{provider.value}/")
|
|
|
|
fields_by_key: Final = {f.key: f for f in entry.credential_fields}
|
|
|
|
assert fields_by_key["api_key"].required is True
|
|
assert fields_by_key["api_key"].field_type == "password"
|
|
|
|
assert fields_by_key["api_base"].required is False
|
|
assert fields_by_key["api_base"].field_type == "text"
|
|
|
|
|
|
def test_strands_decider_provider_fields():
|
|
strands: Final = _decisions_provider_entry("StrandsDecider")
|
|
|
|
assert strands.provider_display_name == "Strands Decider"
|
|
assert strands.litellm_provider == LlmProviders.STRANDS_DECIDER.value
|
|
assert strands.default_model_placeholder is not None
|
|
assert strands.default_model_placeholder.startswith("strands_decider/")
|
|
|
|
fields_by_key: Final = {f.key: f for f in strands.credential_fields}
|
|
|
|
assert fields_by_key["api_base"].required is True
|
|
assert fields_by_key["api_base"].field_type == "text"
|
|
|
|
assert fields_by_key["api_key"].required is False
|
|
assert fields_by_key["api_key"].field_type == "password"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("provider", "display_name", "litellm_provider", "model_placeholder"),
|
|
(
|
|
("Laya", "Laya", LlmProviders.LAYA.value, "laya/english"),
|
|
("Bespoke", "Bespoke Nimble", LlmProviders.BESPOKE.value, "bespoke/nimble"),
|
|
),
|
|
)
|
|
def test_oss_decisions_provider_fields(
|
|
provider: str, display_name: str, litellm_provider: str, model_placeholder: str
|
|
) -> None:
|
|
entry: Final = _decisions_provider_entry(provider)
|
|
|
|
assert entry.provider_display_name == display_name
|
|
assert entry.litellm_provider == litellm_provider
|
|
assert entry.default_model_placeholder == model_placeholder
|
|
|
|
fields_by_key: Final = {f.key: f for f in entry.credential_fields}
|
|
|
|
assert fields_by_key["api_base"].required is True
|
|
assert fields_by_key["api_base"].field_type == "text"
|
|
|
|
assert fields_by_key["api_key"].required is False
|
|
assert fields_by_key["api_key"].field_type == "password"
|
|
|
|
|
|
ADD_MODEL_UNLISTED_PROVIDERS: Final = frozenset(
|
|
{
|
|
"a2a",
|
|
"a2a_agent",
|
|
"amazon_nova",
|
|
"apertis",
|
|
"aws_polly",
|
|
"black_forest_labs",
|
|
"charity_engine",
|
|
"chutes",
|
|
"darkbloom",
|
|
"databricks_agent",
|
|
"gdc",
|
|
"helicone",
|
|
"inception",
|
|
"langflow",
|
|
"langgraph",
|
|
"libertai",
|
|
"litellm_agent",
|
|
"manus",
|
|
"meta",
|
|
"modelscope",
|
|
"mongodb",
|
|
"nano-gpt",
|
|
"neosantara",
|
|
"parasail",
|
|
"pinstripes",
|
|
"poe",
|
|
"publicai",
|
|
"ragflow",
|
|
"reducto",
|
|
"s3_vectors",
|
|
"sagemaker_nova",
|
|
"scaleway",
|
|
"stability",
|
|
"synthetic",
|
|
"tensormesh",
|
|
"text-completion-inception",
|
|
"transcribe",
|
|
"valkey",
|
|
"xiaomi_mimo",
|
|
"zai",
|
|
}
|
|
)
|
|
|
|
|
|
def test_every_backend_provider_is_listed_in_add_model_or_frozen_as_unlisted():
|
|
app_instance = FastAPI()
|
|
app_instance.include_router(router)
|
|
test_client = TestClient(app_instance)
|
|
|
|
response = test_client.get("/public/providers/fields")
|
|
assert response.status_code == 200
|
|
listed = {p["litellm_provider"] for p in response.json()}
|
|
|
|
unlisted = {provider.value for provider in LlmProviders} - listed
|
|
assert unlisted == ADD_MODEL_UNLISTED_PROVIDERS, (
|
|
"Add Model dropdown drift: give the new provider an entry in provider_create_fields.json "
|
|
"rather than adding it to ADD_MODEL_UNLISTED_PROVIDERS"
|
|
)
|
|
|
|
|
|
def test_google_ai_studio_provider_fields_expose_api_base():
|
|
"""The Google AI Studio (gemini) credential form must let admins set a custom
|
|
api_base so they can point at a Gemini-compatible gateway (e.g. a self-hosted
|
|
proxy at /v1beta) without env var access.
|
|
|
|
The runtime gemini provider already supports custom api_base via
|
|
`vertex_llm_base._check_custom_proxy`; the UI just needs to expose the field.
|
|
"""
|
|
app_instance = FastAPI()
|
|
app_instance.include_router(router)
|
|
test_client = TestClient(app_instance)
|
|
|
|
response = test_client.get("/public/providers/fields")
|
|
assert response.status_code == 200
|
|
providers = response.json()
|
|
|
|
google_ai = next((p for p in providers if p["provider"] == "Google_AI_Studio"), None)
|
|
assert google_ai is not None, "Google_AI_Studio provider entry not found"
|
|
assert google_ai["litellm_provider"] == "gemini"
|
|
|
|
fields_by_key = {f["key"]: f for f in google_ai["credential_fields"]}
|
|
assert "api_key" in fields_by_key
|
|
assert "api_base" in fields_by_key, (
|
|
"Google_AI_Studio provider form must expose api_base so admins can "
|
|
"point at a Gemini-compatible gateway without env var access."
|
|
)
|
|
|
|
api_base_field = fields_by_key["api_base"]
|
|
assert api_base_field["required"] is False
|
|
assert api_base_field["field_type"] == "text"
|
|
# default_value MUST be null (not the canonical URL): saving it as the
|
|
# default would persist v1beta into every credential record and bypass
|
|
# `get_gemini_url`'s automatic v1alpha routing for Gemini 3+ models. The
|
|
# placeholder shows the canonical URL so users still get the visual hint.
|
|
# (See greptileai threads on PR #30419.)
|
|
assert api_base_field["default_value"] is None
|
|
assert api_base_field["placeholder"] == "https://generativelanguage.googleapis.com/v1beta"
|
|
|
|
# UI forms render fields in credential_fields order; api_base should come
|
|
# first so an admin sees the URL override before the key field (matches
|
|
# OpenAI and Anthropic conventions).
|
|
field_order = [f["key"] for f in google_ai["credential_fields"]]
|
|
assert field_order.index("api_base") < field_order.index("api_key"), (
|
|
"api_base must appear before api_key in credential_fields."
|
|
)
|
|
|
|
|
|
def test_public_model_hub_with_healthy_model():
|
|
"""Test that health information is populated for a healthy model"""
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
# Override auth dependency
|
|
app.dependency_overrides[user_api_key_auth] = lambda: MagicMock()
|
|
client = TestClient(app)
|
|
|
|
# Create mock model groups
|
|
mock_model_group = ModelGroupInfoProxy(
|
|
model_group="gpt-3.5-turbo",
|
|
providers=["openai"],
|
|
is_public_model_group=True,
|
|
)
|
|
|
|
# Create mock health check
|
|
mock_health_check = MagicMock()
|
|
mock_health_check.model_id = None
|
|
mock_health_check.model_name = "gpt-3.5-turbo"
|
|
mock_health_check.status = "healthy"
|
|
mock_health_check.response_time_ms = 150.5
|
|
mock_health_check.checked_at = datetime.now(timezone.utc)
|
|
|
|
mock_llm_router = MagicMock()
|
|
mock_prisma = MagicMock()
|
|
mock_prisma.get_all_latest_health_checks = AsyncMock(return_value=[mock_health_check])
|
|
|
|
with (
|
|
patch("litellm.public_model_groups", ["gpt-3.5-turbo"]),
|
|
patch("litellm.proxy.proxy_server.get_model_group_info") as mock_get_info,
|
|
patch("litellm.proxy.proxy_server.llm_router", mock_llm_router),
|
|
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
|
|
patch("litellm.proxy.health_endpoints._health_endpoints.convert_health_check_to_dict") as mock_convert,
|
|
):
|
|
mock_get_info.return_value = [mock_model_group]
|
|
mock_convert.return_value = {
|
|
"status": "healthy",
|
|
"response_time_ms": 150.5,
|
|
"checked_at": mock_health_check.checked_at.isoformat(),
|
|
}
|
|
|
|
response = client.get(
|
|
"/public/model_hub",
|
|
headers={"Authorization": "Bearer test-key"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert len(data) == 1
|
|
assert data[0]["model_group"] == "gpt-3.5-turbo"
|
|
assert data[0]["health_status"] == "healthy"
|
|
assert data[0]["health_response_time"] == 150.5
|
|
assert data[0]["health_checked_at"] is not None
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
def test_public_model_hub_with_unhealthy_model():
|
|
"""Test that health information is populated for an unhealthy model"""
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
app.dependency_overrides[user_api_key_auth] = lambda: MagicMock()
|
|
client = TestClient(app)
|
|
|
|
mock_model_group = ModelGroupInfoProxy(
|
|
model_group="gpt-4",
|
|
providers=["openai"],
|
|
is_public_model_group=True,
|
|
)
|
|
|
|
mock_health_check = MagicMock()
|
|
mock_health_check.model_id = None
|
|
mock_health_check.model_name = "gpt-4"
|
|
mock_health_check.status = "unhealthy"
|
|
mock_health_check.response_time_ms = None
|
|
mock_health_check.checked_at = datetime.now(timezone.utc)
|
|
|
|
mock_llm_router = MagicMock()
|
|
mock_prisma = MagicMock()
|
|
mock_prisma.get_all_latest_health_checks = AsyncMock(return_value=[mock_health_check])
|
|
|
|
with (
|
|
patch("litellm.public_model_groups", ["gpt-4"]),
|
|
patch("litellm.proxy.proxy_server.get_model_group_info") as mock_get_info,
|
|
patch("litellm.proxy.proxy_server.llm_router", mock_llm_router),
|
|
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
|
|
patch("litellm.proxy.health_endpoints._health_endpoints.convert_health_check_to_dict") as mock_convert,
|
|
):
|
|
mock_get_info.return_value = [mock_model_group]
|
|
mock_convert.return_value = {
|
|
"status": "unhealthy",
|
|
"response_time_ms": None,
|
|
"checked_at": mock_health_check.checked_at.isoformat(),
|
|
}
|
|
|
|
response = client.get(
|
|
"/public/model_hub",
|
|
headers={"Authorization": "Bearer test-key"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert len(data) == 1
|
|
assert data[0]["model_group"] == "gpt-4"
|
|
assert data[0]["health_status"] == "unhealthy"
|
|
assert data[0]["health_response_time"] is None
|
|
assert data[0]["health_checked_at"] is not None
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
def test_public_model_hub_without_health_check():
|
|
"""Test that health information is null when no health check exists"""
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
app.dependency_overrides[user_api_key_auth] = lambda: MagicMock()
|
|
client = TestClient(app)
|
|
|
|
mock_model_group = ModelGroupInfoProxy(
|
|
model_group="claude-3",
|
|
providers=["anthropic"],
|
|
is_public_model_group=True,
|
|
)
|
|
|
|
mock_llm_router = MagicMock()
|
|
mock_prisma = MagicMock()
|
|
mock_prisma.get_all_latest_health_checks = AsyncMock(return_value=[])
|
|
|
|
with (
|
|
patch("litellm.public_model_groups", ["claude-3"]),
|
|
patch("litellm.proxy.proxy_server.get_model_group_info") as mock_get_info,
|
|
patch("litellm.proxy.proxy_server.llm_router", mock_llm_router),
|
|
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
|
|
):
|
|
mock_get_info.return_value = [mock_model_group]
|
|
|
|
response = client.get(
|
|
"/public/model_hub",
|
|
headers={"Authorization": "Bearer test-key"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert len(data) == 1
|
|
assert data[0]["model_group"] == "claude-3"
|
|
assert data[0]["health_status"] is None
|
|
assert data[0]["health_response_time"] is None
|
|
assert data[0]["health_checked_at"] is None
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
def test_public_model_hub_mixed_health_statuses():
|
|
"""Test multiple models with different health statuses"""
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
app.dependency_overrides[user_api_key_auth] = lambda: MagicMock()
|
|
client = TestClient(app)
|
|
|
|
healthy_model = ModelGroupInfoProxy(
|
|
model_group="gpt-3.5-turbo",
|
|
providers=["openai"],
|
|
is_public_model_group=True,
|
|
)
|
|
unhealthy_model = ModelGroupInfoProxy(
|
|
model_group="gpt-4",
|
|
providers=["openai"],
|
|
is_public_model_group=True,
|
|
)
|
|
no_health_model = ModelGroupInfoProxy(
|
|
model_group="claude-3",
|
|
providers=["anthropic"],
|
|
is_public_model_group=True,
|
|
)
|
|
|
|
healthy_check = MagicMock()
|
|
healthy_check.model_id = None
|
|
healthy_check.model_name = "gpt-3.5-turbo"
|
|
healthy_check.status = "healthy"
|
|
healthy_check.response_time_ms = 120.0
|
|
healthy_check.checked_at = datetime.now(timezone.utc)
|
|
|
|
unhealthy_check = MagicMock()
|
|
unhealthy_check.model_id = None
|
|
unhealthy_check.model_name = "gpt-4"
|
|
unhealthy_check.status = "unhealthy"
|
|
unhealthy_check.response_time_ms = None
|
|
unhealthy_check.checked_at = datetime.now(timezone.utc)
|
|
|
|
mock_llm_router = MagicMock()
|
|
mock_prisma = MagicMock()
|
|
mock_prisma.get_all_latest_health_checks = AsyncMock(return_value=[healthy_check, unhealthy_check])
|
|
|
|
def convert_side_effect(check):
|
|
if check.model_name == "gpt-3.5-turbo":
|
|
return {
|
|
"status": "healthy",
|
|
"response_time_ms": 120.0,
|
|
"checked_at": check.checked_at.isoformat(),
|
|
}
|
|
elif check.model_name == "gpt-4":
|
|
return {
|
|
"status": "unhealthy",
|
|
"response_time_ms": None,
|
|
"checked_at": check.checked_at.isoformat(),
|
|
}
|
|
return {}
|
|
|
|
with (
|
|
patch("litellm.public_model_groups", ["gpt-3.5-turbo", "gpt-4", "claude-3"]),
|
|
patch("litellm.proxy.proxy_server.get_model_group_info") as mock_get_info,
|
|
patch("litellm.proxy.proxy_server.llm_router", mock_llm_router),
|
|
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
|
|
patch("litellm.proxy.health_endpoints._health_endpoints.convert_health_check_to_dict") as mock_convert,
|
|
):
|
|
mock_get_info.return_value = [
|
|
healthy_model,
|
|
unhealthy_model,
|
|
no_health_model,
|
|
]
|
|
mock_convert.side_effect = convert_side_effect
|
|
|
|
response = client.get(
|
|
"/public/model_hub",
|
|
headers={"Authorization": "Bearer test-key"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert len(data) == 3
|
|
|
|
# Find each model and verify health status
|
|
gpt35 = next(m for m in data if m["model_group"] == "gpt-3.5-turbo")
|
|
assert gpt35["health_status"] == "healthy"
|
|
assert gpt35["health_response_time"] == 120.0
|
|
assert gpt35["health_checked_at"] is not None
|
|
|
|
gpt4 = next(m for m in data if m["model_group"] == "gpt-4")
|
|
assert gpt4["health_status"] == "unhealthy"
|
|
assert gpt4["health_response_time"] is None
|
|
assert gpt4["health_checked_at"] is not None
|
|
|
|
claude = next(m for m in data if m["model_group"] == "claude-3")
|
|
assert claude["health_status"] is None
|
|
assert claude["health_response_time"] is None
|
|
assert claude["health_checked_at"] is None
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
def _stored_health_check(model_id: str | None, status: str, checked_at: datetime) -> LatestHealthCheckRow:
|
|
return LatestHealthCheckRow(
|
|
health_check_id=f"hc-{model_id}",
|
|
model_name="gpt-4",
|
|
model_id=model_id,
|
|
status=status,
|
|
checked_at=checked_at,
|
|
created_at=checked_at,
|
|
updated_at=checked_at,
|
|
)
|
|
|
|
|
|
def test_public_model_hub_reports_the_newest_check_of_a_group_not_the_last_row_read():
|
|
"""Rows come back in model_id order with NULL last, so a stale name-probe row used to overwrite the fresh one."""
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
app.dependency_overrides[user_api_key_auth] = lambda: MagicMock()
|
|
client = TestClient(app)
|
|
fresh = _stored_health_check("deployment-a", "healthy", datetime(2026, 10, 16, 11, 10, tzinfo=timezone.utc))
|
|
stale = _stored_health_check(None, "unhealthy", datetime(2026, 9, 4, 11, 20, tzinfo=timezone.utc))
|
|
mock_prisma = MagicMock()
|
|
mock_prisma.get_all_latest_health_checks = AsyncMock(return_value=(fresh, stale))
|
|
|
|
with (
|
|
patch("litellm.public_model_groups", ["gpt-4"]),
|
|
patch("litellm.proxy.proxy_server.get_model_group_info") as mock_get_info,
|
|
patch("litellm.proxy.proxy_server.llm_router", MagicMock()),
|
|
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
|
|
):
|
|
mock_get_info.return_value = [
|
|
ModelGroupInfoProxy(model_group="gpt-4", providers=["openai"], is_public_model_group=True)
|
|
]
|
|
response = client.get("/public/model_hub", headers={"Authorization": "Bearer test-key"})
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()[0]["health_status"] == "healthy"
|
|
assert response.json()[0]["health_checked_at"] == "2026-10-16T11:10:00+00:00"
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /public/agent_hub
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_public_agent_hub_rewrites_upstream_url_to_proxy():
|
|
"""Public agent hub must not leak the upstream backend URL retained on the
|
|
stored card. The ``url`` field has to be overwritten with the proxy
|
|
``/a2a/{agent_id}`` entrypoint, matching the well-known card endpoint, so
|
|
an unauthenticated client cannot call the backend directly."""
|
|
from litellm.types.agents import AgentResponse
|
|
|
|
upstream_url = "https://upstream.internal.example.com/a2a"
|
|
agent = AgentResponse(
|
|
agent_id="agent-123",
|
|
agent_name="public-agent",
|
|
agent_card_params={"name": "public-agent", "url": upstream_url},
|
|
)
|
|
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
client = TestClient(app)
|
|
|
|
mock_registry = MagicMock()
|
|
mock_registry.get_public_agent_list.return_value = [agent]
|
|
mock_registry.ids_for_agent = MagicMock(side_effect=lambda agent_id: frozenset({agent_id}))
|
|
|
|
with (
|
|
patch("litellm.public_agent_groups", ["agent-123"]),
|
|
patch(
|
|
"litellm.proxy.agent_endpoints.agent_registry.global_agent_registry",
|
|
mock_registry,
|
|
),
|
|
):
|
|
response = client.get("/public/agent_hub")
|
|
|
|
assert response.status_code == 200, response.text
|
|
payload = response.json()
|
|
assert len(payload) == 1
|
|
card = payload[0]
|
|
assert upstream_url not in card.get("url", "")
|
|
assert card["url"].endswith("/a2a/agent-123")
|
|
|
|
|
|
def test_public_agent_hub_serializes_http_security_scheme_without_bearer_format():
|
|
"""Regression: agents created through the UI carry an auto-generated
|
|
``securitySchemes.LiteLLMKey`` of ``{"type": "http", "scheme": "bearer"}``
|
|
with no ``bearerFormat``. The endpoint response_model must accept this
|
|
optional-field-omitted scheme; otherwise response validation raises and
|
|
/public/agent_hub returns 500, which the frontend swallows into an empty
|
|
list and hides the Agent Hub tab."""
|
|
from litellm.types.agents import AgentResponse
|
|
|
|
agent = AgentResponse(
|
|
agent_id="agent-123",
|
|
agent_name="public-agent",
|
|
agent_card_params={
|
|
"name": "public-agent",
|
|
"url": "https://upstream.internal.example.com/a2a",
|
|
"securitySchemes": {
|
|
"LiteLLMKey": {
|
|
"type": "http",
|
|
"scheme": "bearer",
|
|
"description": "LiteLLM virtual key",
|
|
}
|
|
},
|
|
},
|
|
)
|
|
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
client = TestClient(app)
|
|
|
|
mock_registry = MagicMock()
|
|
mock_registry.get_public_agent_list.return_value = [agent]
|
|
mock_registry.ids_for_agent = MagicMock(side_effect=lambda agent_id: frozenset({agent_id}))
|
|
|
|
with (
|
|
patch("litellm.public_agent_groups", ["agent-123"]),
|
|
patch(
|
|
"litellm.proxy.agent_endpoints.agent_registry.global_agent_registry",
|
|
mock_registry,
|
|
),
|
|
):
|
|
response = client.get("/public/agent_hub")
|
|
|
|
assert response.status_code == 200, response.text
|
|
payload = response.json()
|
|
assert len(payload) == 1
|
|
scheme = payload[0]["securitySchemes"]["LiteLLMKey"]
|
|
assert scheme["type"] == "http"
|
|
assert scheme["scheme"] == "bearer"
|
|
assert "bearerFormat" not in scheme
|
|
|
|
|
|
def test_public_agent_hub_returns_empty_when_no_public_groups():
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
client = TestClient(app)
|
|
|
|
mock_registry = MagicMock()
|
|
mock_registry.get_public_agent_list.return_value = []
|
|
|
|
with (
|
|
patch("litellm.public_agent_groups", None),
|
|
patch(
|
|
"litellm.proxy.agent_endpoints.agent_registry.global_agent_registry",
|
|
mock_registry,
|
|
),
|
|
):
|
|
response = client.get("/public/agent_hub")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /public/agents/fields
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_bedrock_agentcore_runtime_arn_validation_pattern_accepts_full_resource_path():
|
|
"""Regression for LIT-6737: the AgentCore agent_runtime_arn field's
|
|
validation_pattern must accept a complete runtime ARN whose resource part
|
|
is itself multi-segment (``runtime/<runtime-id>``), and reject the exact
|
|
truncated shape a naive split("/")-by-position parse used to produce (the
|
|
ARN cut off right after the ``runtime`` resource type).
|
|
"""
|
|
app_instance = FastAPI()
|
|
app_instance.include_router(router)
|
|
test_client = TestClient(app_instance)
|
|
|
|
response = test_client.get("/public/agents/fields")
|
|
assert response.status_code == 200
|
|
agents = response.json()
|
|
|
|
bedrock_agentcore = next((a for a in agents if a["agent_type"] == "bedrock_agentcore"), None)
|
|
assert bedrock_agentcore is not None, "bedrock_agentcore agent type not found"
|
|
assert bedrock_agentcore["model_template"] == "bedrock/agentcore/{agent_runtime_arn}"
|
|
|
|
fields_by_key = {f["key"]: f for f in bedrock_agentcore["credential_fields"]}
|
|
arn_field = fields_by_key["agent_runtime_arn"]
|
|
assert arn_field["required"] is True
|
|
assert arn_field["include_in_litellm_params"] is False
|
|
|
|
pattern = arn_field.get("validation_pattern")
|
|
assert pattern, "agent_runtime_arn must ship a validation_pattern so the UI can reject a truncated ARN"
|
|
|
|
full_arn = "arn:aws:bedrock-agentcore:eu-central-1:123456789012:runtime/hosted_agent_4vm3i-BaTdfOELAs"
|
|
truncated_arn = "arn:aws:bedrock-agentcore:eu-central-1:123456789012:runtime"
|
|
|
|
assert re.match(pattern, full_arn), "the validator must accept a complete runtime ARN"
|
|
assert not re.match(pattern, truncated_arn), "the validator must reject the truncated ARN"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /public/endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
import litellm.proxy.public_endpoints.public_endpoints as _pe_module
|
|
from litellm.proxy.public_endpoints.public_endpoints import (
|
|
_build_endpoints,
|
|
_clean_display_name,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=False)
|
|
def reset_endpoints_cache():
|
|
"""Reset the module-level cache before and after each cache-related test."""
|
|
original = _pe_module._cached_endpoints
|
|
_pe_module._cached_endpoints = None
|
|
yield
|
|
_pe_module._cached_endpoints = original
|
|
|
|
|
|
def _make_client():
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
return TestClient(app)
|
|
|
|
|
|
def test_get_supported_endpoints_returns_200(reset_endpoints_cache):
|
|
response = _make_client().get("/public/endpoints")
|
|
assert response.status_code == 200
|
|
|
|
|
|
def test_get_supported_endpoints_response_shape(reset_endpoints_cache):
|
|
data = _make_client().get("/public/endpoints").json()
|
|
assert "endpoints" in data
|
|
assert isinstance(data["endpoints"], list)
|
|
assert len(data["endpoints"]) > 0
|
|
|
|
|
|
def test_get_supported_endpoints_item_fields(reset_endpoints_cache):
|
|
endpoints = _make_client().get("/public/endpoints").json()["endpoints"]
|
|
for item in endpoints:
|
|
assert "key" in item
|
|
assert "label" in item
|
|
assert "endpoint" in item
|
|
assert "providers" in item
|
|
assert isinstance(item["providers"], list)
|
|
|
|
|
|
def test_get_supported_endpoints_provider_fields(reset_endpoints_cache):
|
|
endpoints = _make_client().get("/public/endpoints").json()["endpoints"]
|
|
for item in endpoints:
|
|
for provider in item["providers"]:
|
|
assert "slug" in provider
|
|
assert "display_name" in provider
|
|
|
|
|
|
def test_get_supported_endpoints_paths_start_with_slash(reset_endpoints_cache):
|
|
endpoints = _make_client().get("/public/endpoints").json()["endpoints"]
|
|
for item in endpoints:
|
|
assert item["endpoint"].startswith("/"), f"Expected path starting with /, got: {item['endpoint']}"
|
|
|
|
|
|
def test_get_supported_endpoints_chat_completions_present(reset_endpoints_cache):
|
|
endpoints = _make_client().get("/public/endpoints").json()["endpoints"]
|
|
keys = [item["key"] for item in endpoints]
|
|
assert "chat_completions" in keys
|
|
|
|
chat = next(item for item in endpoints if item["key"] == "chat_completions")
|
|
assert chat["endpoint"] == "/chat/completions"
|
|
assert chat["label"] == "Chat Completions"
|
|
assert len(chat["providers"]) > 0
|
|
|
|
|
|
def test_get_supported_endpoints_display_names_have_no_slug_suffix(
|
|
reset_endpoints_cache,
|
|
):
|
|
"""Provider display_names must not contain the raw `` (`slug`) `` suffix."""
|
|
import re
|
|
|
|
suffix_re = re.compile(r"\(`[^`]+`\)")
|
|
endpoints = _make_client().get("/public/endpoints").json()["endpoints"]
|
|
for item in endpoints:
|
|
for provider in item["providers"]:
|
|
assert not suffix_re.search(provider["display_name"]), (
|
|
f"display_name still contains slug suffix: {provider['display_name']!r}"
|
|
)
|
|
|
|
|
|
def test_get_supported_endpoints_is_cached(reset_endpoints_cache):
|
|
"""`_load_endpoints` is called only once; subsequent requests use the cache."""
|
|
client = _make_client()
|
|
with patch(
|
|
"litellm.proxy.public_endpoints.public_endpoints._load_endpoints",
|
|
wraps=_pe_module._load_endpoints,
|
|
) as mock_load:
|
|
client.get("/public/endpoints")
|
|
client.get("/public/endpoints")
|
|
client.get("/public/endpoints")
|
|
|
|
mock_load.assert_called_once()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _build_endpoints unit tests (transformation logic)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_MINIMAL_RAW = {
|
|
"providers": {
|
|
"openai": {
|
|
"display_name": "OpenAI (`openai`)",
|
|
"url": "https://example.com",
|
|
"endpoints": {
|
|
"chat_completions": True,
|
|
"embeddings": True,
|
|
"images": False,
|
|
},
|
|
},
|
|
"anthropic": {
|
|
"display_name": "Anthropic (`anthropic`)",
|
|
"url": "https://example.com",
|
|
"endpoints": {
|
|
"chat_completions": True,
|
|
"embeddings": False,
|
|
"images": False,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
|
|
def test_build_endpoints_known_key_uses_metadata():
|
|
result = _build_endpoints(_MINIMAL_RAW)
|
|
chat = next(e for e in result if e["key"] == "chat_completions")
|
|
assert chat["label"] == "Chat Completions"
|
|
assert chat["endpoint"] == "/chat/completions"
|
|
|
|
|
|
def test_build_endpoints_only_includes_supporting_providers():
|
|
result = _build_endpoints(_MINIMAL_RAW)
|
|
embeddings = next(e for e in result if e["key"] == "embeddings")
|
|
slugs = [p["slug"] for p in embeddings["providers"]]
|
|
assert slugs == ["openai"]
|
|
|
|
|
|
def test_build_endpoints_unknown_key_derives_label_and_path():
|
|
raw = {
|
|
"providers": {
|
|
"someprovider": {
|
|
"display_name": "Some Provider (`someprovider`)",
|
|
"endpoints": {"my_custom_endpoint": True},
|
|
}
|
|
}
|
|
}
|
|
result = _build_endpoints(raw)
|
|
item = result[0]
|
|
assert item["key"] == "my_custom_endpoint"
|
|
assert item["label"] == "My Custom Endpoint"
|
|
assert item["endpoint"].startswith("/")
|
|
|
|
|
|
def test_build_endpoints_empty_providers_returns_empty():
|
|
result = _build_endpoints({"providers": {}})
|
|
assert result == []
|
|
|
|
|
|
def test_clean_display_name_strips_suffix():
|
|
assert _clean_display_name("OpenAI (`openai`)") == "OpenAI"
|
|
assert _clean_display_name("AI/ML API (`aiml`)") == "AI/ML API"
|
|
assert _clean_display_name("A2A (Agent-to-Agent) (`a2a`)") == "A2A (Agent-to-Agent)"
|
|
|
|
|
|
def test_clean_display_name_passthrough_when_no_suffix():
|
|
assert _clean_display_name("OpenAI") == "OpenAI"
|
|
assert _clean_display_name("") == ""
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"strict,explicit,expected_listed",
|
|
((True, True, True), (True, False, False), (False, True, True), (False, False, True)),
|
|
)
|
|
@pytest.mark.parametrize("stored_public", (None, False, True))
|
|
def test_public_mcp_hub_derives_publication_metadata_without_mutating_registry(
|
|
strict: bool,
|
|
explicit: bool,
|
|
expected_listed: bool,
|
|
stored_public: bool | None,
|
|
) -> None:
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.mcp_server_manager import MCPServerManager
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
app: Final = FastAPI()
|
|
app.include_router(router)
|
|
client: Final = TestClient(app)
|
|
|
|
server: Final = MCPServer(
|
|
server_id="listed",
|
|
name="listed",
|
|
server_name="listed",
|
|
transport=MCPTransport.http,
|
|
available_on_public_internet=True,
|
|
mcp_info=(
|
|
{
|
|
"is_public": stored_public,
|
|
"is_public_explicit": not explicit,
|
|
"description": "Preserve custom metadata",
|
|
}
|
|
if stored_public is not None
|
|
else None
|
|
),
|
|
)
|
|
unlisted: Final = MCPServer(
|
|
server_id="unlisted",
|
|
name="unlisted",
|
|
transport=MCPTransport.http,
|
|
available_on_public_internet=False,
|
|
mcp_info={"is_public": True, "is_public_explicit": True},
|
|
)
|
|
manager: Final = MCPServerManager()
|
|
manager.config_mcp_servers = {server.server_id: server}
|
|
manager.registry = {unlisted.server_id: unlisted}
|
|
original_registry: Final = {key: value.model_dump() for key, value in manager.get_registry().items()}
|
|
|
|
with (
|
|
patch("litellm.public_mcp_servers", [server.server_id] if explicit else []),
|
|
patch("litellm.public_mcp_hub_strict_whitelist", strict),
|
|
patch(
|
|
"litellm.proxy.mcp_server.mcp_server_manager.global_mcp_server_manager",
|
|
manager,
|
|
),
|
|
):
|
|
response: Final = client.get("/public/mcp_hub")
|
|
|
|
assert response.status_code == 200
|
|
data: Final = response.json()
|
|
assert [item["server_id"] for item in data] == ([server.server_id] if expected_listed else [])
|
|
if expected_listed:
|
|
assert data[0]["mcp_info"] == {
|
|
**({"description": "Preserve custom metadata"} if stored_public is not None else {}),
|
|
"is_public": True,
|
|
"is_public_explicit": explicit,
|
|
}
|
|
assert {key: value.model_dump() for key, value in manager.get_registry().items()} == original_registry
|
|
|
|
|
|
def test_public_mcp_hub_returns_empty_when_whitelist_unset():
|
|
"""When no servers have been published via /v1/mcp/make_public, the
|
|
hub returns an empty list (matches /public/agent_hub behavior)."""
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
app.dependency_overrides[user_api_key_auth] = lambda: MagicMock()
|
|
client = TestClient(app)
|
|
|
|
mock_manager = MagicMock()
|
|
mock_manager.get_public_mcp_servers.return_value = []
|
|
|
|
with (
|
|
patch("litellm.public_mcp_servers", None),
|
|
patch(
|
|
"litellm.proxy.mcp_server.mcp_server_manager.global_mcp_server_manager",
|
|
mock_manager,
|
|
),
|
|
):
|
|
response = client.get("/public/mcp_hub")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == []
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
def test_public_mcp_hub_does_not_expose_upstream_url():
|
|
"""Regression: /public/mcp_hub is unauthenticated, so the gateway-internal
|
|
upstream url must never appear in its response even when the server has one."""
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
app.dependency_overrides[user_api_key_auth] = lambda: MagicMock()
|
|
client = TestClient(app)
|
|
|
|
secret_url = "https://internal-only.example.com/mcp"
|
|
server = MCPServer(
|
|
server_id="listed",
|
|
name="listed",
|
|
server_name="listed",
|
|
url=secret_url,
|
|
transport=MCPTransport.http,
|
|
available_on_public_internet=True,
|
|
)
|
|
|
|
mock_manager = MagicMock()
|
|
mock_manager.get_public_mcp_servers.return_value = [server]
|
|
|
|
with (
|
|
patch("litellm.public_mcp_servers", ["listed"]),
|
|
patch(
|
|
"litellm.proxy.mcp_server.mcp_server_manager.global_mcp_server_manager",
|
|
mock_manager,
|
|
),
|
|
):
|
|
response = client.get("/public/mcp_hub")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert [item["server_id"] for item in data] == ["listed"]
|
|
assert all("url" not in item for item in data)
|
|
assert secret_url not in response.text
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
@pytest.fixture
|
|
def reset_autorouter_presets_cache():
|
|
from litellm.proxy.public_endpoints.public_endpoints import _AutoRouterPresetsCache
|
|
|
|
_AutoRouterPresetsCache.presets = None
|
|
_AutoRouterPresetsCache.lock = None
|
|
yield
|
|
_AutoRouterPresetsCache.presets = None
|
|
_AutoRouterPresetsCache.lock = None
|
|
|
|
|
|
def test_get_autorouter_presets_local_mode_serves_bundled_catalog(monkeypatch, reset_autorouter_presets_cache):
|
|
monkeypatch.setenv("LITELLM_LOCAL_AUTOROUTER_PRESETS", "True")
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
client = TestClient(app)
|
|
|
|
response = client.get("/public/autorouter_presets")
|
|
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
assert "anthropic_family" in payload
|
|
assert payload["1m_context"]["complexity_router_config"]["classifier_type"] == "heuristic_v2"
|
|
assert payload["1m_context"]["complexity_router_config"]["tiers"] == {
|
|
"SIMPLE": ["gpt-6-luna"],
|
|
"MEDIUM": ["gpt-5.6-terra"],
|
|
"COMPLEX": ["gpt-6-sol"],
|
|
"REASONING": ["claude-opus-5-5"],
|
|
}
|
|
assert payload["1m_context"]["complexity_router_config"]["tier_model_configs"] == {
|
|
"REASONING": [{"model_name": "claude-opus-5-5", "litellm_params": {"reasoning_effort": "high"}}]
|
|
}
|
|
for preset in payload.values():
|
|
assert isinstance(preset["label"], str)
|
|
assert isinstance(preset["description"], str)
|
|
assert "tiers" in preset["complexity_router_config"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_autorouter_presets_fetches_once_per_process(monkeypatch, reset_autorouter_presets_cache):
|
|
from litellm.proxy.public_endpoints.public_endpoints import (
|
|
_AUTOROUTER_PRESETS_ADAPTER,
|
|
get_autorouter_presets,
|
|
)
|
|
|
|
monkeypatch.delenv("LITELLM_LOCAL_AUTOROUTER_PRESETS", raising=False)
|
|
remote = _AUTOROUTER_PRESETS_ADAPTER.validate_python(
|
|
{
|
|
"remote_only": {
|
|
"label": "Remote Only",
|
|
"description": "from the remote catalog",
|
|
"complexity_router_config": {
|
|
"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]}
|
|
},
|
|
}
|
|
}
|
|
)
|
|
calls = []
|
|
|
|
async def fake_fetch(url):
|
|
calls.append(url)
|
|
return remote
|
|
|
|
first = await get_autorouter_presets(url="https://example.test/presets.json", fetch=fake_fetch)
|
|
second = await get_autorouter_presets(url="https://example.test/presets.json", fetch=fake_fetch)
|
|
|
|
assert first == remote
|
|
assert second == remote
|
|
assert calls == ["https://example.test/presets.json"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("flag", ["LITELLM_LOCAL_AUTOROUTER_PRESETS", "LITELLM_OFFLINE_MODE"])
|
|
async def test_get_autorouter_presets_local_flags_serve_bundled_without_fetching(
|
|
monkeypatch, reset_autorouter_presets_cache, flag
|
|
):
|
|
from litellm.proxy.public_endpoints.public_endpoints import (
|
|
_load_bundled_autorouter_presets,
|
|
get_autorouter_presets,
|
|
)
|
|
|
|
monkeypatch.delenv("LITELLM_LOCAL_AUTOROUTER_PRESETS", raising=False)
|
|
monkeypatch.setenv(flag, "True")
|
|
calls = []
|
|
|
|
async def fake_fetch(url):
|
|
calls.append(url)
|
|
raise AssertionError("no fetch when the bundled catalog is forced")
|
|
|
|
presets = await get_autorouter_presets(url="https://example.test/presets.json", fetch=fake_fetch)
|
|
|
|
assert presets == _load_bundled_autorouter_presets()
|
|
assert calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_autorouter_presets_single_flight_on_concurrent_cold_start(
|
|
monkeypatch, reset_autorouter_presets_cache
|
|
):
|
|
import asyncio
|
|
|
|
from litellm.proxy.public_endpoints.public_endpoints import (
|
|
_AUTOROUTER_PRESETS_ADAPTER,
|
|
get_autorouter_presets,
|
|
)
|
|
|
|
monkeypatch.delenv("LITELLM_LOCAL_AUTOROUTER_PRESETS", raising=False)
|
|
remote = _AUTOROUTER_PRESETS_ADAPTER.validate_python(
|
|
{
|
|
"remote_only": {
|
|
"label": "Remote Only",
|
|
"description": "from the remote catalog",
|
|
"complexity_router_config": {
|
|
"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]}
|
|
},
|
|
}
|
|
}
|
|
)
|
|
calls = []
|
|
|
|
async def slow_fetch(url):
|
|
calls.append(url)
|
|
await asyncio.sleep(0.05)
|
|
return remote
|
|
|
|
results = await asyncio.gather(
|
|
get_autorouter_presets(url="https://example.test/presets.json", fetch=slow_fetch),
|
|
get_autorouter_presets(url="https://example.test/presets.json", fetch=slow_fetch),
|
|
get_autorouter_presets(url="https://example.test/presets.json", fetch=slow_fetch),
|
|
)
|
|
|
|
assert all(result == remote for result in results)
|
|
assert len(calls) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_autorouter_presets_caches_bundled_fallback_on_remote_failure(
|
|
monkeypatch, reset_autorouter_presets_cache
|
|
):
|
|
from litellm.proxy.public_endpoints.public_endpoints import get_autorouter_presets
|
|
|
|
monkeypatch.delenv("LITELLM_LOCAL_AUTOROUTER_PRESETS", raising=False)
|
|
calls = []
|
|
|
|
async def broken_fetch(url):
|
|
calls.append(url)
|
|
raise ValueError("remote catalog unavailable")
|
|
|
|
first = await get_autorouter_presets(url="https://example.test/presets.json", fetch=broken_fetch)
|
|
second = await get_autorouter_presets(url="https://example.test/presets.json", fetch=broken_fetch)
|
|
|
|
assert "anthropic_family" in first
|
|
assert second == first
|
|
assert len(calls) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_autorouter_presets_adapter_rejects_wrong_shapes():
|
|
from pydantic import ValidationError
|
|
|
|
from litellm.proxy.public_endpoints.public_endpoints import _AUTOROUTER_PRESETS_ADAPTER
|
|
|
|
with pytest.raises(ValidationError):
|
|
_AUTOROUTER_PRESETS_ADAPTER.validate_python({"bad": {"label": "no description or config"}})
|
|
with pytest.raises(ValidationError):
|
|
_AUTOROUTER_PRESETS_ADAPTER.validate_python(["not", "a", "mapping"])
|
|
with pytest.raises(ValidationError):
|
|
_AUTOROUTER_PRESETS_ADAPTER.validate_python(
|
|
{"no_tiers": {"label": "L", "description": "D", "complexity_router_config": {}}}
|
|
)
|
|
with pytest.raises(ValidationError):
|
|
_AUTOROUTER_PRESETS_ADAPTER.validate_python(
|
|
{
|
|
"missing_builtin_tier": {
|
|
"label": "L",
|
|
"description": "D",
|
|
"complexity_router_config": {"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"]}},
|
|
}
|
|
}
|
|
)
|
|
with pytest.raises(ValidationError):
|
|
_AUTOROUTER_PRESETS_ADAPTER.validate_python(
|
|
{
|
|
"unknown_tier_name": {
|
|
"label": "L",
|
|
"description": "D",
|
|
"complexity_router_config": {
|
|
"tiers": {
|
|
"SIMPLE": ["m1"],
|
|
"MEDIUM": ["m2"],
|
|
"COMPLEX": ["m3"],
|
|
"REASONING": ["m4"],
|
|
"ULTRA": ["m5"],
|
|
}
|
|
},
|
|
}
|
|
}
|
|
)
|
|
with pytest.raises(ValidationError):
|
|
_AUTOROUTER_PRESETS_ADAPTER.validate_python(
|
|
{
|
|
"bad_tiers": {
|
|
"label": "L",
|
|
"description": "D",
|
|
"complexity_router_config": {"tiers": "not-a-mapping"},
|
|
}
|
|
}
|
|
)
|
|
|
|
|
|
def test_get_autorouter_presets_passes_unknown_catalog_fields_through(monkeypatch, reset_autorouter_presets_cache):
|
|
from litellm.proxy.public_endpoints.public_endpoints import (
|
|
_AUTOROUTER_PRESETS_ADAPTER,
|
|
_AutoRouterPresetsCache,
|
|
)
|
|
|
|
monkeypatch.delenv("LITELLM_LOCAL_AUTOROUTER_PRESETS", raising=False)
|
|
_AutoRouterPresetsCache.presets = _AUTOROUTER_PRESETS_ADAPTER.validate_python(
|
|
{
|
|
"future_preset": {
|
|
"label": "Future",
|
|
"description": "carries fields this proxy version does not know",
|
|
"complexity_router_config": {
|
|
"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]},
|
|
"future_config_knob": 3,
|
|
},
|
|
"icon": "sparkles",
|
|
}
|
|
}
|
|
)
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
client = TestClient(app)
|
|
|
|
response = client.get("/public/autorouter_presets")
|
|
|
|
assert response.status_code == 200
|
|
served = response.json()["future_preset"]
|
|
assert served["icon"] == "sparkles"
|
|
assert served["complexity_router_config"]["future_config_knob"] == 3
|
|
assert served["complexity_router_config"]["tiers"]["SIMPLE"] == ["m1"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_remote_autorouter_presets_parses_and_rejects_empty(monkeypatch):
|
|
import litellm.llms.custom_httpx.http_handler as http_handler_module
|
|
from litellm.proxy.public_endpoints.public_endpoints import _fetch_remote_autorouter_presets
|
|
|
|
catalog = {
|
|
"remote_only": {
|
|
"label": "Remote Only",
|
|
"description": "from the remote catalog",
|
|
"complexity_router_config": {
|
|
"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]}
|
|
},
|
|
}
|
|
}
|
|
response = MagicMock()
|
|
response.raise_for_status = MagicMock()
|
|
response.content = json.dumps(catalog).encode()
|
|
client = MagicMock()
|
|
client.get = AsyncMock(return_value=response)
|
|
monkeypatch.setattr(http_handler_module, "get_async_httpx_client", lambda llm_provider: client)
|
|
|
|
presets = await _fetch_remote_autorouter_presets("https://example.test/presets.json")
|
|
assert presets["remote_only"].label == "Remote Only"
|
|
response.raise_for_status.assert_called_once()
|
|
|
|
response.content = b"{}"
|
|
with pytest.raises(ValueError, match="empty"):
|
|
await _fetch_remote_autorouter_presets("https://example.test/presets.json")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /public/agents/fields inherited provider credentials
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_get_agent_fields_merge_does_not_compound_across_requests():
|
|
"""The inherited-credentials merge runs once, so credential_fields cannot grow per request.
|
|
|
|
Caching the raw file contents instead of the merged result would let the merge
|
|
append inherited fields again on every request.
|
|
"""
|
|
client = _make_client()
|
|
|
|
def counts():
|
|
return {
|
|
agent["agent_type"]: len(agent.get("credential_fields") or [])
|
|
for agent in client.get("/public/agents/fields").json()
|
|
}
|
|
|
|
first = counts()
|
|
assert first, "expected at least one agent type"
|
|
assert first == counts() == counts()
|
|
|
|
|
|
def test_get_agent_fields_appends_inherited_provider_credential_fields():
|
|
client: Final = _make_client()
|
|
providers: Final = {p["provider"]: p for p in client.get("/public/providers/fields").json()}
|
|
agents: Final = client.get("/public/agents/fields").json()
|
|
bundled: Final = json.loads(
|
|
files("litellm.proxy.public_endpoints").joinpath("agent_create_fields.json").read_text(encoding="utf-8")
|
|
)
|
|
own_counts: Final = {agent["agent_type"]: len(agent["credential_fields"]) for agent in bundled}
|
|
expected_inherited: Final = {
|
|
agent["agent_type"]: providers.get(agent.get("inherit_credentials_from_provider"), {}).get(
|
|
"credential_fields", []
|
|
)
|
|
for agent in bundled
|
|
}
|
|
inherited: Final = {
|
|
agent["agent_type"]: agent["credential_fields"][own_counts[agent["agent_type"]] :] for agent in agents
|
|
}
|
|
|
|
assert any(expected_inherited.values()), "expected at least one agent to inherit provider credential fields"
|
|
assert all("inherit_credentials_from_provider" not in agent for agent in agents)
|
|
assert {
|
|
agent_type: [
|
|
{key: field[key] for key in field if key in ProviderCredentialField.model_fields} for field in fields
|
|
]
|
|
for agent_type, fields in inherited.items()
|
|
} == expected_inherited
|
|
assert all(field["include_in_litellm_params"] is True for field in chain.from_iterable(inherited.values()))
|
|
|
|
|
|
@pytest.mark.parametrize("preference", ["auto_install", None])
|
|
def test_public_skill_hub_echoes_registered_installation_preference(preference):
|
|
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
|
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import register_plugin
|
|
from litellm.types.proxy.claude_code_endpoints import RegisterPluginRequest
|
|
|
|
table: Final = MagicMock()
|
|
table.find_unique = AsyncMock(return_value=None)
|
|
table.create = AsyncMock(side_effect=lambda data: SimpleNamespace(id="plugin-id", **data))
|
|
table.find_many = AsyncMock(
|
|
side_effect=lambda where: [SimpleNamespace(id="plugin-id", **table.create.call_args.kwargs["data"])]
|
|
)
|
|
prisma: Final = MagicMock()
|
|
prisma.db.litellm_claudecodeplugintable = table
|
|
app: Final = FastAPI()
|
|
app.include_router(router)
|
|
|
|
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
|
|
asyncio.run(
|
|
register_plugin(
|
|
request=RegisterPluginRequest(
|
|
name="security-review",
|
|
source={
|
|
"source": "archive",
|
|
"url": "https://plugins.example.com/security-review-1.0.0.zip",
|
|
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
},
|
|
installation_preference=preference,
|
|
),
|
|
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin"),
|
|
)
|
|
)
|
|
response: Final = TestClient(app).get("/public/skill_hub")
|
|
|
|
assert response.status_code == 200
|
|
assert [(p["name"], p["installation_preference"]) for p in response.json()["plugins"]] == [
|
|
("security-review", preference)
|
|
]
|