feat(proxy): publish opted-in pass-through endpoints on the Model Hub (#43905)

* feat(proxy): publish opted-in pass-through endpoints on the Model Hub

Pass-through endpoints gain display_name and show_in_model_hub. Endpoints that opt in appear on
/public/v1/model_hub and /public/model_hub as rows in mode passthrough, named by their display name
or their path, with the route in pass_through_path. The admin UI exposes both fields on the create
and edit forms and the public hub renders passthrough rows with a route-specific curl example

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(proxy): carry pass-through methods onto hub rows and key dashboard rows by route

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(proxy): keep health, cost and shell quoting correct for pass-through hub rows

Pass-through rows no longer inherit a router model's health check when they share its name, the public table shows n/a instead of Free for their token costs, and the generated curl example escapes shell characters in the route

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(ui): quote the method in the pass-through hub curl example

A configured method string is placed after curl -X inside escaped double quotes so it cannot alter the copied command

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(ui): build pass-through hub fixtures through a helper

Keeps the dashboard lint budget for inline object arguments under its limit

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): audit the pass-through Model Hub surface end to end

Cover forwarding, pagination, validation, auth, updates, duplicate names,
config-file precedence, health-name isolation, repeated reads and two chaos
cells (create/delete churn, a killed proxy worker). Build the stored
pass_through_endpoints list in one shot instead of appending to the list
the config reader handed back

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(proxy): keep concurrent pass-through creates from overwriting each other

Creating an endpoint appends to the general_settings list the proxy already holds instead of persisting a private copy, so two admins creating at the same time both land, as on main before this branch

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(proxy): serialize pass-through endpoint creates behind a process lock

Each create copies the stored list under an asyncio lock before persisting, so simultaneous creates in one worker no longer overwrite each other and a rejected write (config-owned path) never lands in the in-memory settings

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): collect paginated pass-through rows without mutating a list

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(ui): let a pass-through with no stored timeout save from the edit form

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(ui): type the pass-through edit fixture so a null timeout compiles

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* perf(proxy): read published pass-throughs from the in-memory settings store

The public Model Hub routes read the pass-through list this worker already
serves from proxy_config.settings instead of querying the config table on
every anonymous hub read. Also drops a mutable-ok suppression that no longer
guards a violation

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): list a config file pass-through beside database rows on the hub

Main merges database pass-throughs with config file ones on other paths since
#43962, so a database write beside a config entry is accepted rather than
rejected as config-owned

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(proxy): type the monkeypatch fixture in the pass-through hub tests

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(proxy): validate pass-through config entries instead of splatting unknown dicts

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(proxy): list model hub rows as a tagged union of model groups and pass-throughs

Health enrichment and the no-router check now match on ModelGroupRow or
PassThroughRow instead of testing pass_through_path in each route. Both hub
routes share published_hub_rows, the v1 row helper is sync again, and the
config list route goes back to main's inline merge since the hub no longer
needs configured_pass_through_endpoints

* test(ui): find the pass-through hub row by role to stay under the no-node-access budget

* fix(proxy): keep database pass-through endpoints in the settings store across config reloads

A config reload cleared the stored database row before rebuilding the
pass-through list, so without store_model_in_db the list fell back to the
config file entries alone. The Admin UI triggers that reload on page load
through /get/config/callbacks, which dropped UI-created endpoints from the
Model Hub until the next write

* refactor(proxy): make the hub row matches exhaustive with assert_never

* refactor(proxy): return the exhaustive match fallthrough and build hub test inputs in one shot

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(proxy): drop the in-process create lock from pass-through endpoints

The lock only covered one worker, and the read-modify-write on the general_settings blob is a separate storage problem tracked as tech debt. Create goes back to main's behavior.

* fix: align pass-through model hub with current lint rules

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test: share worker state for pass-through hub restart coverage

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: show pass-through curl examples in model hub inspector

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: skip invalid pass-through hub entries and await peer refresh

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: ryan <ryan@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: mateo <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-11-12 17:46:38 +00:00 • committed by GitHub
parent f10b42b1ce
commit 382588a365
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1256 additions and 148 deletions

View file

@ -2348,6 +2348,14 @@ class PassThroughGenericEndpoint(LiteLLMPydanticObjectBase):
default=None,
description="List of HTTP methods this endpoint handles (e.g., ['GET', 'POST']). If None or empty, all methods (GET, POST, PUT, DELETE, PATCH) are supported for backward compatibility. This allows the same path to have different targets for different HTTP methods.",
)
display_name: str | None = Field(
default=None,
description="Human-readable name for this endpoint. The Model Hub lists the endpoint under this name, falling back to the path when unset.",
)
show_in_model_hub: bool = Field(
default=False,
description="If True, this endpoint is published on the public Model Hub as a row in mode 'passthrough'.",
)
class PassThroughEndpointResponse(LiteLLMPydanticObjectBase):

View file

@ -4748,7 +4748,7 @@ async def update_pass_through_endpoints(
# Find the index for updating the list
endpoint_index = None
for idx, endpoint in enumerate(pass_through_endpoint_data):
_endpoint = PassThroughGenericEndpoint(**endpoint) if isinstance(endpoint, dict) else endpoint
_endpoint = PassThroughGenericEndpoint.model_validate(endpoint) if isinstance(endpoint, dict) else endpoint
if _endpoint.id == endpoint_id:
endpoint_index = idx
break
@ -4981,7 +4981,7 @@ async def delete_pass_through_endpoints(
# Find the index for deleting from the list
endpoint_index = None
for idx, endpoint in enumerate(pass_through_endpoint_data):
_endpoint = PassThroughGenericEndpoint(**endpoint) if isinstance(endpoint, dict) else endpoint
_endpoint = PassThroughGenericEndpoint.model_validate(endpoint) if isinstance(endpoint, dict) else endpoint
if _endpoint.id == endpoint_id:
endpoint_index = idx
break
@ -5029,7 +5029,7 @@ def _find_endpoint_by_id(
for endpoint in endpoints_data:
_endpoint: PassThroughGenericEndpoint | None = None
if isinstance(endpoint, dict):
_endpoint = PassThroughGenericEndpoint(**endpoint)
_endpoint = PassThroughGenericEndpoint.model_validate(endpoint)
elif isinstance(endpoint, PassThroughGenericEndpoint):
_endpoint = endpoint

View file

@ -5858,6 +5858,16 @@ def _reload_settings_store(section: Section, store: SettingsStore, section_confi
store["pass_through_endpoints"] = serving_pass_throughs
def _clear_db_settings_beside_pass_through_endpoints(section: Section, store: SettingsStore) -> None:
stored_pass_throughs: Final = store.db_value("pass_through_endpoints")
store.apply_db_row(
section,
MappingProxyType({"pass_through_endpoints": stored_pass_throughs})
if isinstance(stored_pass_throughs, list)
else _EMPTY_SETTINGS_MAPPING,
)
def _general_settings_row_is_readable(row: _ConfigParamValueRow | None) -> bool:
return row is None or row.param_value is None or isinstance(row.param_value, Mapping)
@ -6708,7 +6718,7 @@ class ProxyConfig:
)
else:
for section, store in self._settings_stores.items():
store.apply_db_row(section, _EMPTY_SETTINGS_MAPPING)
_clear_db_settings_beside_pass_through_endpoints(section, store)
config = self._config_with_resolved_settings(config)

View file

@ -0,0 +1,86 @@
"""The rows the public Model Hub lists: published model groups and opted-in pass-through endpoints."""
from collections.abc import Iterator
from dataclasses import dataclass
from typing import Final, TypeAlias
from pydantic import TypeAdapter, ValidationError
import litellm
from litellm.proxy._types import PassThroughGenericEndpoint
from litellm.types.proxy.management_endpoints.model_management_endpoints import ModelGroupInfoProxy
PASS_THROUGH_MODE: Final = "passthrough"
_SERVING_ENTRY: Final = TypeAdapter(dict[str, object])
@dataclass(frozen=True, slots=True)
class ModelGroupRow:
info: ModelGroupInfoProxy
@dataclass(frozen=True, slots=True)
class PassThroughRow:
info: ModelGroupInfoProxy
HubRow: TypeAlias = ModelGroupRow | PassThroughRow
@dataclass(frozen=True, slots=True)
class NoModelsConfigured:
"""Neither a router nor a published pass-through, so the hub has nothing to list."""
def pass_through_row(endpoint: PassThroughGenericEndpoint) -> PassThroughRow:
return PassThroughRow(
info=ModelGroupInfoProxy(
model_group=endpoint.display_name or endpoint.path,
providers=[],
mode=PASS_THROUGH_MODE,
is_public_model_group=True,
pass_through_path=endpoint.path,
pass_through_methods=tuple(endpoint.methods) if endpoint.methods is not None else None,
)
)
def _serving_pass_through_endpoints() -> Iterator[PassThroughGenericEndpoint]:
"""The endpoints this worker serves, from the in-memory general settings the config sync keeps current."""
from litellm.proxy.proxy_server import proxy_config
configured: Final = proxy_config.settings.get("pass_through_endpoints")
for entry in configured if isinstance(configured, list) else ():
try:
yield PassThroughGenericEndpoint.model_validate(_SERVING_ENTRY.validate_python(entry))
except ValidationError:
continue
def _published_model_group_rows() -> tuple[ModelGroupRow, ...] | None:
"""None when there is no router, which differs from a router that publishes nothing."""
from litellm.proxy.proxy_server import (
get_model_group_info,
llm_router,
)
if llm_router is None:
return None
if litellm.public_model_groups is None:
return ()
return tuple(
ModelGroupRow(info=info)
for info in get_model_group_info(
llm_router=llm_router, all_models_str=litellm.public_model_groups, model_group=None
)
)
def published_hub_rows() -> tuple[HubRow, ...] | NoModelsConfigured:
pass_through_rows: Final = tuple(
pass_through_row(endpoint) for endpoint in _serving_pass_through_endpoints() if endpoint.show_in_model_hub
)
model_group_rows: Final = _published_model_group_rows()
if model_group_rows is None and not pass_through_rows:
return NoModelsConfigured()
return (*(model_group_rows or ()), *pass_through_rows)

View file

@ -5,6 +5,7 @@ import re
from collections.abc import Awaitable, Callable, Mapping, Sequence
from functools import cache
from importlib.resources import files
from types import MappingProxyType
from typing import TYPE_CHECKING, Final, Protocol
from fastapi import APIRouter, HTTPException, Request
@ -25,6 +26,13 @@ from litellm.proxy._types import (
)
from litellm.proxy.db.health_check_latest import latest_by_model_name
from litellm.proxy.deps.repositories import PluginUploadRepositoryDep
from litellm.proxy.public_endpoints.model_hub_rows import (
HubRow,
ModelGroupRow,
NoModelsConfigured,
PassThroughRow,
published_hub_rows,
)
from litellm.proxy.utils import get_custom_url
from litellm.repositories.plugin_publication import read_plugin_publisher, read_plugin_review
from litellm.repositories.table_repositories import ClaudeCodePluginRepository
@ -276,45 +284,52 @@ def _agent_create_fields() -> tuple[AgentCreateInfo, ...]:
response_model=list[ModelGroupInfoProxy],
)
async def public_model_hub():
import litellm
from litellm.proxy.health_endpoints._health_endpoints import (
convert_health_check_to_dict,
)
from litellm.proxy.proxy_server import (
get_model_group_info,
llm_router,
prisma_client,
)
if llm_router is None:
rows: Final = published_hub_rows()
if isinstance(rows, NoModelsConfigured):
raise HTTPException(status_code=400, detail=CommonProxyErrors.no_llm_router.value)
model_groups: list[ModelGroupInfoProxy] = []
if litellm.public_model_groups is not None:
model_groups = get_model_group_info( # rebind-ok: pre-existing rebinding on a rename-only line
llm_router=llm_router,
all_models_str=litellm.public_model_groups,
model_group=None,
health_checks_map: Final = await _legacy_health_checks()
return [_with_legacy_health(row, health_checks_map) for row in rows]
async def _legacy_health_checks() -> Mapping[str, Mapping[str, object]]:
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
return MappingProxyType({})
try:
latest_checks: Final = await prisma_client.get_all_latest_health_checks()
return MappingProxyType(
{
model_name: MappingProxyType(
{
"status": check.status,
"response_time_ms": check.response_time_ms,
"checked_at": check.checked_at.isoformat() if check.checked_at else None,
}
)
for model_name, check in latest_by_model_name(latest_checks).items()
}
)
except Exception:
return MappingProxyType({})
# Fetch health check information if available
health_checks_map: Final = {}
if prisma_client is not None:
try:
latest_checks: Final = await prisma_client.get_all_latest_health_checks()
for model_name, check in latest_by_model_name(latest_checks).items():
health_checks_map[model_name] = convert_health_check_to_dict(check)
except Exception:
pass
for model_group in model_groups:
health_info = health_checks_map.get(model_group.model_group)
if health_info:
model_group.health_status = health_info.get("status")
model_group.health_response_time = health_info.get("response_time_ms")
model_group.health_checked_at = health_info.get("checked_at")
return model_groups
def _with_legacy_health(row: HubRow, health_checks_map: Mapping[str, Mapping[str, object]]) -> ModelGroupInfoProxy:
match row:
case PassThroughRow():
return row.info
case ModelGroupRow():
health_info: Final = health_checks_map.get(row.info.model_group)
if not health_info:
return row.info
return row.info.model_copy(
update={
"health_status": health_info.get("status"),
"health_response_time": health_info.get("response_time_ms"),
"health_checked_at": health_info.get("checked_at"),
}
)
@router.get(

View file

@ -8,7 +8,6 @@ from typing import Annotated, Final, Literal, Protocol
from fastapi import APIRouter, Depends, Request
from typing_extensions import ReadOnly, TypedDict
import litellm
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
@ -24,6 +23,13 @@ from litellm.proxy.list_api.list_framework import (
handle_facet,
handle_list,
)
from litellm.proxy.public_endpoints.model_hub_rows import (
HubRow,
ModelGroupRow,
NoModelsConfigured,
PassThroughRow,
published_hub_rows,
)
from litellm.proxy.utils import PrismaClient
from litellm.types.proxy.management_endpoints.management_v1 import (
FacetListResponse,
@ -93,9 +99,19 @@ class HealthEnricher:
lookup: HealthSnapshotLookup
async def __call__(self, rows: Sequence[ModelGroupInfoProxy]) -> Sequence[ModelGroupInfoProxy]:
health: Final = await self.lookup.latest_for(tuple(row.model_group for row in rows))
return tuple(_with_health(row, health.get(row.model_group)) for row in rows)
async def __call__(self, rows: Sequence[HubRow]) -> Sequence[HubRow]:
health: Final = await self.lookup.latest_for(
tuple(row.info.model_group for row in rows if isinstance(row, ModelGroupRow))
)
return tuple(_row_with_health(row, health) for row in rows)
def _row_with_health(row: HubRow, health: Mapping[str, HealthSnapshot]) -> HubRow:
match row:
case PassThroughRow():
return row
case ModelGroupRow():
return ModelGroupRow(info=_with_health(row.info, health.get(row.info.model_group)))
FEATURE_PREFIX: Final = "supports_"
@ -120,26 +136,26 @@ def _enabled_features(fields: Mapping[str, object]) -> tuple[str, ...]:
)
def _cells(row: ModelGroupInfoProxy) -> Cells:
def _cells(row: HubRow) -> Cells:
info: Final = row.info
return MappingProxyType(
{
"model_group": row.model_group,
"mode": row.mode,
"providers": tuple(row.providers),
"features": _features(row),
"max_input_tokens": row.max_input_tokens,
"max_output_tokens": row.max_output_tokens,
"input_cost_per_token": row.input_cost_per_token,
"output_cost_per_token": row.output_cost_per_token,
"rpm": row.rpm,
"tpm": row.tpm,
"model_group": info.model_group,
"mode": info.mode,
"providers": tuple(info.providers),
"features": _features(info),
"max_input_tokens": info.max_input_tokens,
"max_output_tokens": info.max_output_tokens,
"input_cost_per_token": info.input_cost_per_token,
"output_cost_per_token": info.output_cost_per_token,
"rpm": info.rpm,
"tpm": info.tpm,
}
)
def _serialize(row: ModelGroupInfoProxy) -> ModelGroupInfoProxy:
"""The row shape is the wire shape: the rows served are the router's own model group records."""
return row
def _serialize(row: HubRow) -> ModelGroupInfoProxy:
return row.info
def _scope(_caller: UserAPIKeyAuth) -> Scope:
@ -163,7 +179,7 @@ MODEL_HUB_FACETS: Final[Mapping[str, str]] = MappingProxyType(
{"providers": "providers", "modes": "mode", "features": "features"}
)
MODEL_HUB_LIST_SPEC: Final[ListSpec[ModelGroupInfoProxy, ModelGroupInfoProxy]] = ListSpec(
MODEL_HUB_LIST_SPEC: Final[ListSpec[HubRow, ModelGroupInfoProxy]] = ListSpec(
resource="model groups",
sortable=frozenset(
(
@ -189,13 +205,9 @@ MODEL_HUB_LIST_SPEC: Final[ListSpec[ModelGroupInfoProxy, ModelGroupInfoProxy]] =
)
def _published_rows() -> Sequence[ModelGroupInfoProxy]:
from litellm.proxy.proxy_server import (
get_model_group_info, # pyright: ignore[reportPrivateUsage] # /public/model_hub imports it the same way
llm_router,
)
if llm_router is None:
def _published_rows() -> Sequence[HubRow]:
rows: Final = published_hub_rows()
if isinstance(rows, NoModelsConfigured):
raise ManagementProblem(
ProblemDetail(
type=f"{PROBLEM_TYPE_BASE}no-llm-router",
@ -204,21 +216,13 @@ def _published_rows() -> Sequence[ModelGroupInfoProxy]:
detail=CommonProxyErrors.no_llm_router.value,
)
)
if litellm.public_model_groups is None:
return ()
return tuple(
get_model_group_info(
llm_router=llm_router,
all_models_str=litellm.public_model_groups,
model_group=None,
)
)
return rows
def _executor(
rows: Sequence[ModelGroupInfoProxy],
rows: Sequence[HubRow],
prisma_client: PrismaClient | None,
) -> InMemoryListExecutor[ModelGroupInfoProxy]:
) -> InMemoryListExecutor[HubRow]:
if prisma_client is None:
return InMemoryListExecutor(rows=rows, cells=_cells)
return InMemoryListExecutor(

View file

@ -42,6 +42,8 @@ class ModelGroupInfoProxy(ModelGroupInfo):
health_status: str | None = Field(default=None)
health_response_time: float | None = Field(default=None)
health_checked_at: str | None = Field(default=None)
pass_through_path: str | None = Field(default=None)
pass_through_methods: tuple[str, ...] | None = Field(default=None)
custom_tags: tuple[str, ...] | None = None
custom_metadata: Mapping[str, str | int | float | bool] | None = None

View file

@ -30,6 +30,8 @@ export interface ModelGroupInfo {
health_status?: string;
health_response_time?: number;
health_checked_at?: string;
pass_through_path?: string | null;
pass_through_methods?: string[] | null;
description?: string;
custom_tags?: string[] | null;
custom_metadata?: Record<string, ModelCustomMetadataValue> | null;
@ -90,6 +92,11 @@ const formatCapabilityName = (key: string) =>
const formatCost = (cost: number) => `$${(cost * 1_000_000).toFixed(4)}`;
const tokenCostLabel = (model: ModelGroupInfo, cost: number | null | undefined): string => {
if (model.pass_through_path) return "n/a";
return cost ? formatCost(cost) : i18n.t("aiHub.publicModelHubColumns.free");
};
const formatTokens = (tokens: number | undefined) => {
if (!tokens) return i18n.t("aiHub.publicModelHubColumns.notAvailable");
if (tokens >= 1000) return `${(tokens / 1000).toFixed(0)}K`;
@ -112,6 +119,8 @@ const getModeIcon = (mode: string) => {
return "🔄";
case "embedding":
return "📄";
case "passthrough":
return "🔀";
default:
return "🤖";
}
@ -304,25 +313,17 @@ export const getPublicModelHubColumns = ({
<div className="flex flex-col gap-0.5 text-xs tabular-nums">
<span>
{i18n.t("aiHub.publicModelHubColumns.inputCost", {
cost: row.original.input_cost_per_token
? formatCost(row.original.input_cost_per_token)
: i18n.t("aiHub.publicModelHubColumns.free"),
cost: tokenCostLabel(row.original, row.original.input_cost_per_token),
})}
</span>
<span className="text-muted-foreground">
{i18n.t("aiHub.publicModelHubColumns.outputCost", {
cost: row.original.output_cost_per_token
? formatCost(row.original.output_cost_per_token)
: i18n.t("aiHub.publicModelHubColumns.free"),
cost: tokenCostLabel(row.original, row.original.output_cost_per_token),
})}
</span>
</div>
) : (
<span className="text-sm">
{row.original.input_cost_per_token
? formatCost(row.original.input_cost_per_token)
: i18n.t("aiHub.publicModelHubColumns.free")}
</span>
<span className="text-sm">{tokenCostLabel(row.original, row.original.input_cost_per_token)}</span>
),
},
{
@ -334,11 +335,7 @@ export const getPublicModelHubColumns = ({
),
size: 110,
cell: ({ row }) => (
<span className="text-sm">
{row.original.output_cost_per_token
? formatCost(row.original.output_cost_per_token)
: i18n.t("aiHub.publicModelHubColumns.free")}
</span>
<span className="text-sm">{tokenCostLabel(row.original, row.original.output_cost_per_token)}</span>
),
},
{

View file

@ -80,6 +80,8 @@ const reopenedPayload = {
auth: true,
timeout: undefined,
cost_per_request: undefined,
display_name: undefined,
show_in_model_hub: false,
};
describe("add_pass_through submit payload", () => {
@ -112,9 +114,26 @@ describe("add_pass_through submit payload", () => {
auth: true,
timeout: "900",
cost_per_request: "1.5",
display_name: undefined,
show_in_model_hub: false,
});
});
it("sends the display name and the Model Hub opt-in when they are set", async () => {
const user = setup();
renderForm();
await openModal(user);
await fillRequiredFields(user);
fireEvent.change(screen.getByPlaceholderText("Clinical NER"), { target: { value: "Clinical NER" } });
await user.click(screen.getByRole("switch", { name: "Show on Model Hub" }));
await submit(user);
await waitFor(() => expect(createPassThroughEndpoint).toHaveBeenCalled());
expect(lastPayload().display_name).toBe("Clinical NER");
expect(lastPayload().show_in_model_hub).toBe(true);
});
it("submits numeric fields as strings, not numbers", async () => {
const user = setup();
renderForm();

View file

@ -52,6 +52,8 @@ const passThroughFormSchema = z.object({
auth: z.boolean(),
timeout: z.string().optional(),
cost_per_request: z.string().optional(),
display_name: z.string().optional(),
show_in_model_hub: z.boolean().optional(),
});
type PassThroughFormValues = z.output<typeof passThroughFormSchema>;
@ -66,6 +68,8 @@ const emptyFormValues = {
auth: true,
timeout: undefined,
cost_per_request: undefined,
display_name: undefined,
show_in_model_hub: false,
} as unknown as PassThroughFormValues;
const labelWithHint = (label: React.ReactNode, hint: string): React.ReactNode => (
@ -129,6 +133,8 @@ const AddPassThroughEndpoint: React.FC<AddFallbacksProps> = ({
auth: values.auth,
timeout: values.timeout,
cost_per_request: values.cost_per_request,
display_name: optionalText(values.display_name ?? ""),
show_in_model_hub: values.show_in_model_hub ?? false,
...(Object.keys(guardrails).length > 0 ? { guardrails } : {}),
};
@ -336,6 +342,45 @@ const AddPassThroughEndpoint: React.FC<AddFallbacksProps> = ({
</FormField>
</Card>
<Card className="block p-6">
<h3 className="mb-2 text-lg font-semibold text-foreground">{t("passThroughModelHub.title")}</h3>
<p className="mb-6 text-sm text-muted-foreground">{t("passThroughModelHub.description")}</p>
<div className="space-y-5">
<FormField
control={form.control}
name="display_name"
label={t("passThroughModelHub.displayName")}
description={t("passThroughModelHub.displayNameCreateHint")}
>
{({ value, ...field }) => (
<Input
{...field}
placeholder={t("passThroughModelHub.displayNamePlaceholder")}
value={value ?? ""}
/>
)}
</FormField>
<div className="flex items-center justify-between py-3">
<div>
<div className="text-sm font-medium text-foreground">{t("passThroughModelHub.show")}</div>
<div className="mt-0.5 text-xs text-muted-foreground">{t("passThroughModelHub.showHint")}</div>
</div>
<FormField control={form.control} name="show_in_model_hub">
{({ value, onChange, ref: _ref, ...field }) => (
<Switch
{...field}
aria-label={t("passThroughModelHub.show")}
checked={value ?? false}
onCheckedChange={onChange}
/>
)}
</FormField>
</div>
</div>
</Card>
<Card className="block p-6">
<h3 className="mb-2 text-lg font-semibold text-foreground">{t("addPassThrough.billing.title")}</h3>
<p className="mb-6 text-sm text-muted-foreground">{t("addPassThrough.billing.description")}</p>

View file

@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { ComponentProps } from "react";
import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
import { renderWithProviders, screen, waitFor } from "../../tests/test-utils";
import { fireEvent, renderWithProviders, screen, waitFor } from "../../tests/test-utils";
import PassThroughInfoView from "./pass_through_info";
const updatePassThroughEndpoint = vi.fn();
@ -31,7 +32,9 @@ const endpoint = {
methods: ["GET"],
};
const renderView = (data = endpoint) =>
type EndpointData = ComponentProps<typeof PassThroughInfoView>["endpointData"];
const renderView = (data: EndpointData = endpoint) =>
renderWithProviders(<PassThroughInfoView endpointData={data} onClose={vi.fn()} accessToken="test-token" isAdmin />);
const openEditForm = async (user: User) => {
@ -72,9 +75,34 @@ describe("pass_through_info update payload", () => {
auth: false,
methods: ["GET"],
guardrails: undefined,
display_name: "",
show_in_model_hub: false,
});
});
it("sends the edited display name and Model Hub opt-in", async () => {
const user = setup();
renderWithProviders(
<PassThroughInfoView
endpointData={{ ...endpoint, display_name: "Clinical NER", show_in_model_hub: true }}
onClose={vi.fn()}
accessToken="test-token"
isAdmin
/>,
);
await openEditForm(user);
expect(screen.getByLabelText("Display Name")).toHaveValue("Clinical NER");
expect(screen.getByRole("switch", { name: "Show on Model Hub" })).toBeChecked();
fireEvent.change(screen.getByLabelText("Display Name"), { target: { value: " Clinical NER v2 " } });
await user.click(screen.getByRole("switch", { name: "Show on Model Hub" }));
await save(user);
await waitFor(() => expect(updatePassThroughEndpoint).toHaveBeenCalled());
expect(lastPayload().display_name).toBe("Clinical NER v2");
expect(lastPayload().show_in_model_hub).toBe(false);
});
it("submits numeric fields as numbers, not strings", async () => {
const user = setup();
renderView();
@ -87,6 +115,18 @@ describe("pass_through_info update payload", () => {
expect(lastPayload().timeout).toBe(600);
});
it("saves an endpoint whose stored timeout is null without a validation error", async () => {
const user = setup();
renderView({ ...endpoint, timeout: null });
await openEditForm(user);
await save(user);
await waitFor(() => expect(updatePassThroughEndpoint).toHaveBeenCalled());
expect(screen.queryByText("Invalid input")).not.toBeInTheDocument();
expect(lastPayload().timeout).toBeUndefined();
});
it("keeps the path from the loaded endpoint rather than the form", async () => {
const user = setup();
renderView();

View file

@ -33,6 +33,8 @@ const endpointSettingsSchema = z.object({
cost_per_request: z.number().optional(),
timeout: z.number().optional(),
auth: z.boolean(),
display_name: z.string().optional(),
show_in_model_hub: z.boolean(),
});
type EndpointSettingsValues = z.output<typeof endpointSettingsSchema>;
@ -104,10 +106,12 @@ interface PassThroughEndpoint {
headers: Record<string, any>;
include_subpath?: boolean;
cost_per_request?: number;
timeout?: number;
timeout?: number | null;
auth?: boolean;
methods?: string[];
guardrails?: Record<string, { request_fields?: string[]; response_fields?: string[] } | null>;
display_name?: string | null;
show_in_model_hub?: boolean;
}
// Password field component for headers
@ -159,8 +163,10 @@ const PassThroughInfoView: React.FC<PassThroughInfoProps> = ({
methods: initialEndpointData.methods || [],
include_subpath: initialEndpointData.include_subpath || false,
cost_per_request: initialEndpointData.cost_per_request,
timeout: initialEndpointData.timeout,
timeout: initialEndpointData.timeout ?? undefined,
auth: initialEndpointData.auth ?? true,
display_name: initialEndpointData.display_name ?? undefined,
show_in_model_hub: initialEndpointData.show_in_model_hub ?? false,
},
});
@ -195,6 +201,8 @@ const PassThroughInfoView: React.FC<PassThroughInfoProps> = ({
auth: values.auth,
methods: values.methods.length > 0 ? values.methods : undefined,
guardrails: guardrails && Object.keys(guardrails).length > 0 ? guardrails : undefined,
display_name: values.display_name?.trim() ?? "",
show_in_model_hub: values.show_in_model_hub,
};
await updatePassThroughEndpoint(accessToken, endpointData.id, updateData);
@ -323,6 +331,11 @@ const PassThroughInfoView: React.FC<PassThroughInfoProps> = ({
</p>
</div>
)}
{endpointData.show_in_model_hub && (
<div>
<Badge variant="secondary">{t("passThroughModelHub.published")}</Badge>
</div>
)}
</div>
</Card>
</div>
@ -422,6 +435,27 @@ const PassThroughInfoView: React.FC<PassThroughInfoProps> = ({
)}
</FormField>
<FormField
control={form.control}
name="display_name"
label={t("passThroughModelHub.displayName")}
description={t("passThroughModelHub.displayNameHint")}
>
{({ value, ...field }) => (
<Input
{...field}
placeholder={t("passThroughModelHub.displayNamePlaceholder")}
value={value ?? ""}
/>
)}
</FormField>
<FormField control={form.control} name="show_in_model_hub" label={t("passThroughModelHub.show")}>
{({ value, onChange, ref: _ref, ...field }) => (
<Switch {...field} checked={value} onCheckedChange={onChange} />
)}
</FormField>
<FormField control={form.control} name="headers" label={t("passThroughInfo.form.headersJson")}>
{({ value, ...field }) => (
<Textarea
@ -544,6 +578,20 @@ const PassThroughInfoView: React.FC<PassThroughInfoProps> = ({
<p className="text-sm font-medium">{t("passThroughInfo.settings.targetUrl")}</p>
<div>{endpointData.target}</div>
</div>
{endpointData.display_name && (
<div>
<p className="text-sm font-medium">{t("passThroughModelHub.displayName")}</p>
<div>{endpointData.display_name}</div>
</div>
)}
<div>
<p className="text-sm font-medium">{t("passThroughModelHub.show")}</p>
<Badge variant={endpointData.show_in_model_hub ? "secondary" : "outline"}>
{endpointData.show_in_model_hub
? t("passThroughInfo.settings.yes")
: t("passThroughInfo.settings.no")}
</Badge>
</div>
<div>
<p className="text-sm font-medium">{t("passThroughInfo.settings.includeSubpath")}</p>
<Badge variant={endpointData.include_subpath ? "secondary" : "outline"}>

View file

@ -88,7 +88,7 @@ const modelFacts = (model: ModelGroupInfo): Fact[][] => {
function CallThisModel({ model }: { model: ModelGroupInfo }) {
const { t } = useTranslation();
const curl = curlSnippet(model);
if (!curl) {
if (!curl || model.mode === "passthrough") {
return (
<PaneSection title={t("publicModelHub.modelInspector.callThisModel.title")}>
<CodeBlock code={modelSnippet(model)} />

View file

@ -191,6 +191,36 @@ beforeEach(() => {
});
describe("PublicAiHub", () => {
it.each([
{ name: "Clinical NER", path: "/clinical-ner", methods: null, method: "POST", body: true },
{ name: "LPR Catalog", path: "/lpr-catalog", methods: ["GET"], method: "GET", body: false },
])("shows a route-specific curl example without the chat SDK for $name", async (endpoint) => {
failModelsWith(() =>
Promise.resolve(
page([
{
model_group: endpoint.name,
mode: "passthrough",
providers: [],
pass_through_path: endpoint.path,
pass_through_methods: endpoint.methods,
},
]),
),
);
renderWithProviders(<PublicAiHub accessToken={null} />);
fireEvent.click(await screen.findByRole("link", { name: new RegExp(endpoint.name) }));
const dialog = await screen.findByRole("dialog", { name: "Details" });
expect(dialog).toHaveTextContent(`curl -X "${endpoint.method}" "https://gateway.example.com${endpoint.path}"`);
expect(dialog).toHaveTextContent("Passthrough");
expect(dialog.textContent?.includes(`-d '{"input": "Hello, how are you?"}'`)).toBe(endpoint.body);
expect(dialog).not.toHaveTextContent("import openai");
expect(dialog).not.toHaveTextContent("chat.completions");
expect(dialog).not.toHaveTextContent("/v1/chat/completions");
});
it("shows only Models and Skills without selecting an item or reading retired hubs", async () => {
const user = userEvent.setup();
renderWithProviders(<PublicAiHub accessToken={null} />);

View file

@ -33,11 +33,50 @@ describe("model snippets", () => {
expect(snippet).not.toContain("ui.example.com");
});
it("offers cURL only for chat models", () => {
it("offers cURL for chat models but not embeddings", () => {
expect(curlSnippet(model({ mode: "chat" }))).toContain("curl https://gateway.example.com/v1/chat/completions");
expect(curlSnippet(model({ mode: undefined }))).not.toBeNull();
expect(curlSnippet(model({ mode: "embedding" }))).toBeNull();
});
it.each([null, undefined, [], ["POST"], ["GET", "POST"]])(
"uses the pass-through route with a JSON body when its methods are %j",
(methods) => {
const endpoint = model({
mode: "passthrough",
pass_through_path: "/clinical-ner",
pass_through_methods: methods,
});
const expected = [
'curl -X "POST" "https://gateway.example.com/clinical-ner" \\',
' -H "Authorization: Bearer your_api_key" \\',
' -H "Content-Type: application/json" \\',
` -d '{"input": "Hello, how are you?"}'`,
].join("\n");
expect(curlSnippet(endpoint)).toBe(expected);
expect(modelSnippet(endpoint)).toBe(expected);
},
);
it.each([["GET"], ["PUT", "GET"]])("uses the first non-POST method with no body for %j", (...methods) => {
const endpoint = model({
mode: "passthrough",
pass_through_path: "/lpr-catalog",
pass_through_methods: methods,
});
const expected = [
`curl -X "${methods[0]}" "https://gateway.example.com/lpr-catalog" \\`,
' -H "Authorization: Bearer your_api_key"',
].join("\n");
expect(curlSnippet(endpoint)).toBe(expected);
expect(modelSnippet(endpoint)).toBe(expected);
});
it("does not offer a chat SDK example for a pass-through row without its route", () => {
const endpoint = model({ mode: "passthrough" });
expect(curlSnippet(endpoint)).toBeNull();
expect(modelSnippet(endpoint)).toBe("");
});
});
describe("MCP and skill snippets", () => {

View file

@ -12,7 +12,28 @@ import { gatewayUrl, wildcardExample } from "./hubFormat";
const GREETING = "Hello, how are you?";
const passThroughExampleMethod = (methods: string[] | null | undefined): string =>
!methods?.length || methods.includes("POST") ? "POST" : methods[0];
const shellDoubleQuoted = (value: string): string => value.replace(/[\\"$`]/g, "\\$&");
export const passThroughSnippet = (model: ModelGroupInfo): string | null => {
if (!model.pass_through_path) return null;
const method = passThroughExampleMethod(model.pass_through_methods);
const quotedMethod = shellDoubleQuoted(method);
const url = shellDoubleQuoted(`${gatewayUrl()}${model.pass_through_path}`);
return method === "POST"
? [
`curl -X "${quotedMethod}" "${url}" \\`,
` -H "Authorization: Bearer your_api_key" \\`,
` -H "Content-Type: application/json" \\`,
` -d '{"input": "Hello, how are you?"}'`,
].join("\n")
: [`curl -X "${quotedMethod}" "${url}" \\`, ` -H "Authorization: Bearer your_api_key"`].join("\n");
};
export const modelSnippet = (model: ModelGroupInfo): string => {
if (model.mode === "passthrough" || model.pass_through_path) return passThroughSnippet(model) ?? "";
const name = wildcardExample(model.model_group);
if (isDecisionMode(model.mode)) {
return modelUsageExample(model.mode, gatewayUrl(), name, getGlobalLitellmHeaderName());
@ -37,6 +58,7 @@ export const modelSnippet = (model: ModelGroupInfo): string => {
};
export const curlSnippet = (model: ModelGroupInfo): string | null => {
if (model.pass_through_path) return passThroughSnippet(model);
if (model.mode && model.mode !== "chat") return null;
return `curl ${gatewayUrl()}/v1/chat/completions \\
-H "Authorization: Bearer $LITELLM_API_KEY" \\

View file

@ -62,6 +62,17 @@ const model = (overrides: Partial<ModelGroupInfo> & { model_group: string }): Mo
...overrides,
});
const passThrough = (name: string, path: string, methods?: string[]): ModelGroupInfo => {
const spec: Partial<ModelGroupInfo> & { model_group: string } = {
model_group: name,
providers: [],
mode: "passthrough",
pass_through_path: path,
pass_through_methods: methods,
};
return model(spec);
};
const DEFAULT_MODELS = [model({ model_group: "gpt-4" }), model({ model_group: "claude-3", providers: ["anthropic"] })];
const respondWith = (
@ -438,6 +449,60 @@ describe("PublicModelHub", () => {
});
});
it("lists a published pass-through endpoint under its display name and opens its route", async () => {
respondWith([passThrough("Clinical NER", "/clinical-ner")]);
renderHub();
const row = await screen.findByRole("row", { name: /Clinical NER/ });
expect(row).toHaveTextContent("passthrough");
expect(row).toHaveTextContent("n/a");
expect(row).not.toHaveTextContent("Free");
fireEvent.click(screen.getByRole("button", { name: "Clinical NER" }));
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByText("/clinical-ner")).toBeInTheDocument();
expect(
within(dialog).getByText(/curl -X "POST" "https:\/\/backend.example.com\/clinical-ner"/),
).toBeInTheDocument();
expect(within(dialog).queryByText("Token & Cost Information")).not.toBeInTheDocument();
});
it("quotes a hostile configured method in the usage example", async () => {
respondWith([passThrough("Odd", "/odd", ['GET" ; touch /tmp/pwned ; "'])]);
renderHub();
fireEvent.click(await screen.findByRole("button", { name: "Odd" }));
const dialog = await screen.findByRole("dialog");
expect(
within(dialog).getByText(/curl -X "GET\\" ; touch \/tmp\/pwned ; \\"" "https:\/\/backend.example.com\/odd"/),
).toBeInTheDocument();
});
it("escapes shell characters in the pass-through route of the usage example", async () => {
respondWith([passThrough("Tenant", "/tenant/$acct")]);
renderHub();
fireEvent.click(await screen.findByRole("button", { name: "Tenant" }));
const dialog = await screen.findByRole("dialog");
expect(
within(dialog).getByText(/curl -X "POST" "https:\/\/backend.example.com\/tenant\/\\\$acct"/),
).toBeInTheDocument();
});
it("writes the usage example with a method the pass-through route accepts", async () => {
respondWith([passThrough("Catalog", "/catalog", ["GET"])]);
renderHub();
fireEvent.click(await screen.findByRole("button", { name: "Catalog" }));
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByText(/curl -X "GET" "https:\/\/backend.example.com\/catalog"/)).toBeInTheDocument();
expect(within(dialog).queryByText(/-d '\{"input"/)).not.toBeInTheDocument();
});
it("shows no models when the search has no matches (LIT-5230 regression)", async () => {
renderHub();
expect(await screen.findByText("gpt-4")).toBeInTheDocument();

View file

@ -26,6 +26,7 @@ import { MultiSelect } from "./shared/MultiSelect";
import { featureLabel } from "./publicModelHub/publicModelHubFilters";
import { usePublicModelHubFacets } from "./publicModelHub/usePublicModelHubFacets";
import { usePublicModelHubList } from "./publicModelHub/usePublicModelHubList";
import { passThroughSnippet } from "./publicModelHub/hubSnippets";
import { DataTable } from "./shared/DataTable";
import { toast } from "@/lib/toast";
import Navbar from "./navbar";
@ -299,6 +300,8 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
return `$${(cost * 1_000_000).toFixed(4)}`;
};
const usageSnippet = (model: ModelGroupInfo): string => passThroughSnippet(model) ?? usageExample(model);
const models = usePublicModelHubList(proxyConfigured);
const modelFacets = usePublicModelHubFacets(proxyConfigured);
const modeOptions = useMemo(
@ -611,7 +614,11 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
<DataTable
data={models.rows}
columns={modelColumns}
getRowId={(model, index) => model.model_group || String(index)}
getRowId={(model, index) =>
model.pass_through_path
? `passthrough:${model.pass_through_path}:${index}`
: model.model_group || String(index)
}
sortingMode="server"
sorting={models.sorting}
onSortingChange={models.onSortingChange}
@ -845,6 +852,12 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
<p className="font-medium">{t("aiHub.publicModelHub.modelDialog.mode")}</p>
<p>{displayMode(selectedModel.mode) ?? t("aiHub.publicModelHub.modelDialog.notSpecified")}</p>
</div>
{selectedModel.pass_through_path && (
<div>
<p className="font-medium">{t("passThroughModelHub.endpoint")}</p>
<p className="font-mono text-sm">{selectedModel.pass_through_path}</p>
</div>
)}
<div>
<p className="font-medium">{t("aiHub.publicModelHub.modelDialog.providers")}</p>
<div className="flex flex-wrap gap-1 mt-1">
@ -920,49 +933,50 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
)}
</div>
{/* Token and Cost Information */}
<div>
<p className="text-lg font-semibold mb-4">{t("aiHub.publicModelHub.modelDialog.tokenCost")}</p>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="font-medium">{t("aiHub.publicModelHub.modelDialog.maxInputTokens")}</p>
<p>
{selectedModel.max_input_tokens?.toLocaleString() ||
t("aiHub.publicModelHub.modelDialog.notSpecified")}
</p>
</div>
<div>
<p className="font-medium">{t("aiHub.publicModelHub.modelDialog.maxOutputTokens")}</p>
<p>
{selectedModel.max_output_tokens?.toLocaleString() ||
t("aiHub.publicModelHub.modelDialog.notSpecified")}
</p>
</div>
<div>
<p className="font-medium">{t("aiHub.publicModelHub.modelDialog.inputCost")}</p>
<p>
{selectedModel.input_cost_per_token
? formatCost(selectedModel.input_cost_per_token)
: t("aiHub.publicModelHub.modelDialog.notSpecified")}
</p>
</div>
<div>
<p className="font-medium">{t("aiHub.publicModelHub.modelDialog.outputCost")}</p>
<p>
{selectedModel.output_cost_per_token
? formatCost(selectedModel.output_cost_per_token)
: t("aiHub.publicModelHub.modelDialog.notSpecified")}
</p>
{!selectedModel.pass_through_path && (
<div>
<p className="text-lg font-semibold mb-4">{t("aiHub.publicModelHub.modelDialog.tokenCost")}</p>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="font-medium">{t("aiHub.publicModelHub.modelDialog.maxInputTokens")}</p>
<p>
{selectedModel.max_input_tokens?.toLocaleString() ||
t("aiHub.publicModelHub.modelDialog.notSpecified")}
</p>
</div>
<div>
<p className="font-medium">{t("aiHub.publicModelHub.modelDialog.maxOutputTokens")}</p>
<p>
{selectedModel.max_output_tokens?.toLocaleString() ||
t("aiHub.publicModelHub.modelDialog.notSpecified")}
</p>
</div>
<div>
<p className="font-medium">{t("aiHub.publicModelHub.modelDialog.inputCost")}</p>
<p>
{selectedModel.input_cost_per_token
? formatCost(selectedModel.input_cost_per_token)
: t("aiHub.publicModelHub.modelDialog.notSpecified")}
</p>
</div>
<div>
<p className="font-medium">{t("aiHub.publicModelHub.modelDialog.outputCost")}</p>
<p>
{selectedModel.output_cost_per_token
? formatCost(selectedModel.output_cost_per_token)
: t("aiHub.publicModelHub.modelDialog.notSpecified")}
</p>
</div>
</div>
<DeploymentPricingBreakdown
className="mt-4"
deployments={selectedModel.deployment_pricing}
heading={
<p className="font-medium">{t("aiHub.publicModelHub.modelDialog.pricingByDeployment")}</p>
}
/>
</div>
<DeploymentPricingBreakdown
className="mt-4"
deployments={selectedModel.deployment_pricing}
heading={
<p className="font-medium">{t("aiHub.publicModelHub.modelDialog.pricingByDeployment")}</p>
}
/>
</div>
)}
{/* Capabilities */}
<div>
@ -1027,11 +1041,11 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
<div>
<p className="text-lg font-semibold mb-4">{t("aiHub.publicModelHub.modelDialog.usageExample")}</p>
<div className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto">
<pre className="text-sm">{usageExample(selectedModel)}</pre>
<pre className="text-sm">{usageSnippet(selectedModel)}</pre>
</div>
<div className="mt-2 text-right">
<button
onClick={() => copyToClipboard(usageExample(selectedModel))}
onClick={() => copyToClipboard(usageSnippet(selectedModel))}
className="text-sm text-info hover:text-info/80 cursor-pointer"
>
{t("aiHub.publicModelHub.modelDialog.copyToClipboard")}

View file

@ -19278,6 +19278,18 @@
}
}
},
"passThroughModelHub": {
"title": "Model Hub",
"description": "Optionally list this endpoint on the public Model Hub page",
"displayName": "Display Name",
"displayNameCreateHint": "Shown on the Model Hub instead of the path. Example: Clinical NER",
"displayNameHint": "Shown on the Model Hub instead of the path",
"displayNamePlaceholder": "Clinical NER",
"show": "Show on Model Hub",
"showHint": "Publish this endpoint on the public Model Hub page",
"published": "On Model Hub",
"endpoint": "Endpoint:"
},
"addPassThrough": {
"validation": {
"pathRequired": "Path is required",

View file

@ -40285,6 +40285,10 @@ export interface components {
otpm?: number | null;
/** Output Cost Per Token */
output_cost_per_token?: number | null;
/** Pass Through Methods */
pass_through_methods?: string[] | null;
/** Pass Through Path */
pass_through_path?: string | null;
/** Providers */
providers: string[];
/** Rpm */
@ -42278,6 +42282,11 @@ export interface components {
default_query_params: {
[key: string]: unknown;
};
/**
* Display Name
* @description Human-readable name for this endpoint. The Model Hub lists the endpoint under this name, falling back to the path when unset.
*/
display_name?: string | null;
/**
* Guardrails
* @description Guardrails configuration for this passthrough endpoint. Dict keys are guardrail names, values are optional settings for field targeting. When set, all org/team/key level guardrails will also execute. Defaults to None (no guardrails execute).
@ -42320,6 +42329,12 @@ export interface components {
* @description The route to be added to the LiteLLM Proxy Server.
*/
path: string;
/**
* Show In Model Hub
* @description If True, this endpoint is published on the public Model Hub as a row in mode 'passthrough'.
* @default false
*/
show_in_model_hub: boolean;
/**
* Target
* @description The URL to which requests for this path should be forwarded.

View file

@ -0,0 +1,454 @@
"""A pass-through endpoint opted into the Model Hub is listed there under its display name.
`POST /config/pass_through_endpoint` accepts `display_name` and `show_in_model_hub`. The public hub
routes then carry one row per opted-in endpoint, named by its display name (or its path when no name
was given), in mode `passthrough`, naming the route it describes. An endpoint that did not opt in
stays off the hub. Hub rows come from the shared database, so a peer proxy lists them as well.
"""
from __future__ import annotations
import asyncio
import re
import uuid
from collections.abc import Generator
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Final
import httpx
import yaml
from pydantic import JsonValue, TypeAdapter
from tests.integration._support.client import Gateway, eventually, object_value, string_value
from tests.integration._support.database import write_rows
from tests.integration._support.process import OwnedState, owned_proxy, owned_proxy_process
PASS_THROUGH_MODE: Final = "passthrough"
STARTED_WORKER: Final = re.compile(r"Started server process \[(\d+)\]")
HUB_PARAMS: Final = {"filter[mode]": PASS_THROUGH_MODE, "page_size": "100"}
BURST: Final = 40
@dataclass(frozen=True, slots=True)
class PassThrough:
endpoint_id: str
path: str
display_name: str | None
@contextmanager
def pass_through(
gateway: Gateway,
*,
show_in_model_hub: bool,
display_name: str | None,
methods: tuple[str, ...] | None = None,
path: str | None = None,
target_path: str = "",
) -> Generator[PassThrough]:
optional: Final[dict[str, JsonValue]] = {
"display_name": display_name,
"methods": list(methods) if methods is not None else None,
}
body: Final[dict[str, JsonValue]] = {
"path": path or f"/integration-nlp-{uuid.uuid4().hex[:8]}",
"target": gateway.upstream_url + target_path,
"show_in_model_hub": show_in_model_hub,
**{key: value for key, value in optional.items() if value is not None},
}
created: Final = gateway.request("POST", "/config/pass_through_endpoint", body)
assert created.status_code == 200, created.text
endpoints: Final = object_value(created.json())["endpoints"]
assert isinstance(endpoints, list) and len(endpoints) == 1, created.text
endpoint_id: Final = string_value(object_value(endpoints[0])["id"])
try:
yield PassThrough(endpoint_id, string_value(body["path"]), display_name)
finally:
deleted = gateway.request("DELETE", "/config/pass_through_endpoint", params={"endpoint_id": endpoint_id})
assert deleted.status_code == 200, deleted.text
def configured_endpoint(gateway: Gateway, endpoint_id: str) -> dict[str, JsonValue]:
listed: Final = gateway.request("GET", "/config/pass_through_endpoint")
assert listed.status_code == 200, listed.text
endpoints: Final = object_value(listed.json())["endpoints"]
assert isinstance(endpoints, list), listed.text
matches: Final = tuple(object_value(row) for row in endpoints if object_value(row).get("id") == endpoint_id)
assert len(matches) == 1, f"{endpoint_id} appears {len(matches)} times in {listed.text}"
return matches[0]
def hub_rows(gateway: Gateway) -> tuple[dict[str, JsonValue], ...]:
listed: Final = gateway.request("GET", "/public/v1/model_hub", params=HUB_PARAMS)
assert listed.status_code == 200, listed.text
return rows_of(listed)
def rows_of(listed: httpx.Response) -> tuple[dict[str, JsonValue], ...]:
rows: Final = object_value(listed.json())["data"]
assert isinstance(rows, list), listed.text
return tuple(object_value(row) for row in rows)
def names_of(rows: tuple[dict[str, JsonValue], ...]) -> tuple[str, ...]:
return tuple(string_value(row["model_group"]) for row in rows)
def update_pass_through(gateway: Gateway, endpoint_id: str, body: dict[str, JsonValue]) -> None:
updated: Final = gateway.request("POST", f"/config/pass_through_endpoint/{endpoint_id}", body)
assert updated.status_code == 200, updated.text
def relayed_contents(gateway: Gateway) -> tuple[str, ...]:
observed: Final = httpx.get(f"{gateway.upstream_url}/__observations", timeout=30, trust_env=False)
assert observed.status_code == 200, observed.text
requests: Final = object_value(observed.json())["requests"]
assert isinstance(requests, list), observed.text
return tuple(_first_message_content(object_value(request)["body"]) for request in requests)
def _first_message_content(body: JsonValue) -> str:
messages: Final = object_value(body).get("messages")
if not isinstance(messages, list) or not messages:
return ""
return string_value(object_value(messages[0]).get("content", ""))
def legacy_hub_rows(gateway: Gateway) -> tuple[dict[str, JsonValue], ...]:
listed: Final = gateway.request("GET", "/public/model_hub")
assert listed.status_code == 200, listed.text
rows: Final = TypeAdapter(list[JsonValue]).validate_python(listed.json())
return tuple(object_value(row) for row in rows)
def hub_modes(gateway: Gateway) -> tuple[str, ...]:
listed: Final = gateway.request("GET", "/public/v1/model_hub/modes")
assert listed.status_code == 200, listed.text
modes: Final = object_value(listed.json())["data"]
assert isinstance(modes, list), listed.text
return tuple(string_value(mode) for mode in modes)
def hub_row(rows: tuple[dict[str, JsonValue], ...], model_group: str) -> dict[str, JsonValue]:
matches: Final = tuple(row for row in rows if row["model_group"] == model_group)
assert len(matches) == 1, f"{model_group} appears {len(matches)} times in {rows}"
return matches[0]
def test_opted_in_pass_through_is_listed_under_its_display_name(gateway: Gateway, peer: Gateway) -> None:
display_name: Final = f"Clinical NER {uuid.uuid4().hex[:6]}"
with (
pass_through(gateway, show_in_model_hub=True, display_name=display_name) as shown,
pass_through(gateway, show_in_model_hub=False, display_name="Kept off the hub") as hidden,
):
configured: Final = configured_endpoint(gateway, shown.endpoint_id)
assert configured["display_name"] == display_name, configured
assert configured["show_in_model_hub"] is True, configured
rows: Final = hub_rows(gateway)
row: Final = hub_row(rows, display_name)
assert row["mode"] == PASS_THROUGH_MODE, row
assert row["pass_through_path"] == shown.path, row
assert row["providers"] == [], row
assert row["is_public_model_group"] is True, row
assert not any(r["model_group"] in ("Kept off the hub", hidden.path) for r in rows), rows
assert PASS_THROUGH_MODE in hub_modes(gateway)
legacy: Final = hub_row(legacy_hub_rows(gateway), display_name)
assert legacy["mode"] == PASS_THROUGH_MODE, legacy
assert legacy["pass_through_path"] == shown.path, legacy
peer_rows: Final = eventually(
lambda: hub_rows(peer),
lambda rows: any(r["model_group"] == display_name for r in rows),
seconds=70,
)
assert hub_row(peer_rows, display_name)["pass_through_path"] == shown.path
assert not any(r["model_group"] == display_name for r in hub_rows(gateway)), "deleted endpoint still on the hub"
def test_opted_in_pass_through_without_a_display_name_is_listed_by_path(gateway: Gateway) -> None:
with pass_through(gateway, show_in_model_hub=True, display_name=None, methods=("GET",)) as shown:
row: Final = hub_row(hub_rows(gateway), shown.path)
assert row["mode"] == PASS_THROUGH_MODE, row
assert row["pass_through_path"] == shown.path, row
assert row["pass_through_methods"] == ["GET"], row
def test_a_published_pass_through_still_forwards_to_its_target(gateway: Gateway) -> None:
marker: Final = f"relay {uuid.uuid4().hex}"
with pass_through(
gateway, show_in_model_hub=True, display_name="Chat relay", target_path="/v1/chat/completions"
) as shown:
assert hub_row(hub_rows(gateway), "Chat relay")["pass_through_path"] == shown.path
relayed: Final = gateway.request(
"POST", shown.path, {"model": "relay", "messages": [{"role": "user", "content": marker}]}
)
assert relayed.status_code == 200, relayed.text
assert object_value(relayed.json())["object"] == "chat.completion", relayed.text
assert marker in relayed_contents(gateway), "the upstream never saw the relayed request"
def test_an_async_client_reads_the_same_pass_through_rows(gateway: Gateway) -> None:
display_name: Final = f"Async NER {uuid.uuid4().hex[:6]}"
async def read() -> httpx.Response:
async with httpx.AsyncClient(base_url=str(gateway.client.base_url), timeout=30, trust_env=False) as client:
return await client.get("/public/v1/model_hub", params=HUB_PARAMS)
with pass_through(gateway, show_in_model_hub=True, display_name=display_name) as shown:
listed: Final = asyncio.run(read())
assert listed.status_code == 200, listed.text
rows: Final = rows_of(listed)
assert hub_row(rows, display_name)["pass_through_path"] == shown.path
assert rows == hub_rows(gateway)
def single_row_page_names(gateway: Gateway, page: httpx.Response) -> Generator[str, None, None]:
page_rows: Final = rows_of(page)
assert len(page_rows) == 1, page.text
yield from names_of(page_rows)
following: Final = object_value(object_value(page.json())["links"])["next"]
if following is None:
return
following_page: Final = gateway.request("GET", string_value(following))
assert following_page.status_code == 200, following_page.text
yield from single_row_page_names(gateway, following_page)
def test_pass_through_rows_paginate_one_per_page(gateway: Gateway) -> None:
suffix: Final = uuid.uuid4().hex[:6]
with (
pass_through(gateway, show_in_model_hub=True, display_name=f"Pager A {suffix}"),
pass_through(gateway, show_in_model_hub=True, display_name=f"Pager B {suffix}"),
):
first: Final = gateway.request(
"GET", "/public/v1/model_hub", params={"filter[mode]": PASS_THROUGH_MODE, "page_size": "1"}
)
assert first.status_code == 200, first.text
meta: Final = object_value(object_value(first.json())["meta"])
assert meta["page_size"] == 1, meta
assert meta["total_count"] == meta["total_pages"], meta
seen: Final = tuple(single_row_page_names(gateway, first))
assert len(seen) == meta["total_count"], seen
assert {f"Pager A {suffix}", f"Pager B {suffix}"} <= set(seen), seen
def test_hub_fields_of_the_wrong_type_are_rejected(gateway: Gateway) -> None:
path: Final = f"/integration-nlp-{uuid.uuid4().hex[:8]}"
wrong: Final[tuple[tuple[str, JsonValue], ...]] = (
("display_name", 5),
("display_name", ["Clinical NER"]),
("show_in_model_hub", "maybe"),
)
for field, value in wrong:
created = gateway.request(
"POST", "/config/pass_through_endpoint", {"path": path, "target": gateway.upstream_url, field: value}
)
assert created.status_code == 422, f"{field}={value!r}: {created.status_code} {created.text}"
assert field in created.text, created.text
listed: Final = gateway.request("GET", "/config/pass_through_endpoint")
assert listed.status_code == 200, listed.text
assert path not in listed.text, listed.text
assert path not in names_of(hub_rows(gateway))
def test_a_five_kilobyte_display_name_is_listed_verbatim(gateway: Gateway) -> None:
display_name: Final = f"Long {uuid.uuid4().hex[:6]} " + "n" * 5000
with pass_through(gateway, show_in_model_hub=True, display_name=display_name) as shown:
assert configured_endpoint(gateway, shown.endpoint_id)["display_name"] == display_name
assert hub_row(hub_rows(gateway), display_name)["pass_through_path"] == shown.path
assert hub_row(legacy_hub_rows(gateway), display_name)["pass_through_path"] == shown.path
def test_unauthenticated_callers_read_the_hub_but_cannot_change_publication(gateway: Gateway) -> None:
display_name: Final = f"Guarded {uuid.uuid4().hex[:6]}"
with pass_through(gateway, show_in_model_hub=True, display_name=display_name) as shown:
anonymous: Final = gateway.client
body: Final[dict[str, JsonValue]] = {
"path": shown.path,
"target": gateway.upstream_url,
"show_in_model_hub": False,
}
assert anonymous.post("/config/pass_through_endpoint", json={**body, "path": "/intruder"}).status_code == 401
assert anonymous.post(f"/config/pass_through_endpoint/{shown.endpoint_id}", json=body).status_code == 401
assert (
anonymous.delete("/config/pass_through_endpoint", params={"endpoint_id": shown.endpoint_id}).status_code
== 401
)
public: Final = anonymous.get("/public/v1/model_hub", params=HUB_PARAMS)
assert public.status_code == 200, public.text
assert hub_row(rows_of(public), display_name)["pass_through_path"] == shown.path
legacy: Final = anonymous.get("/public/model_hub")
assert legacy.status_code == 200, legacy.text
assert display_name in legacy.text
assert configured_endpoint(gateway, shown.endpoint_id)["show_in_model_hub"] is True
def test_an_empty_display_name_falls_back_to_the_path(gateway: Gateway) -> None:
with pass_through(gateway, show_in_model_hub=True, display_name="") as shown:
assert configured_endpoint(gateway, shown.endpoint_id)["display_name"] == ""
assert hub_row(hub_rows(gateway), shown.path)["pass_through_path"] == shown.path
assert "" not in names_of(hub_rows(gateway))
def test_an_update_from_an_older_client_keeps_the_hub_fields(gateway: Gateway) -> None:
display_name: Final = f"Sticky {uuid.uuid4().hex[:6]}"
with pass_through(gateway, show_in_model_hub=True, display_name=display_name) as shown:
update_pass_through(gateway, shown.endpoint_id, {"path": shown.path, "target": gateway.upstream_url + "/v2"})
configured: Final = configured_endpoint(gateway, shown.endpoint_id)
assert configured["target"] == gateway.upstream_url + "/v2", configured
assert configured["display_name"] == display_name, configured
assert configured["show_in_model_hub"] is True, configured
assert hub_row(hub_rows(gateway), display_name)["pass_through_path"] == shown.path
def test_updates_clear_the_name_and_toggle_hub_visibility(gateway: Gateway) -> None:
display_name: Final = f"Toggled {uuid.uuid4().hex[:6]}"
with pass_through(gateway, show_in_model_hub=True, display_name=display_name) as shown:
base: Final[dict[str, JsonValue]] = {"path": shown.path, "target": gateway.upstream_url}
update_pass_through(gateway, shown.endpoint_id, {**base, "display_name": ""})
assert hub_row(hub_rows(gateway), shown.path)["pass_through_path"] == shown.path
assert display_name not in names_of(hub_rows(gateway))
update_pass_through(gateway, shown.endpoint_id, {**base, "show_in_model_hub": False})
assert shown.path not in names_of(hub_rows(gateway))
assert shown.path not in names_of(legacy_hub_rows(gateway))
update_pass_through(
gateway, shown.endpoint_id, {**base, "show_in_model_hub": True, "display_name": display_name}
)
assert hub_row(hub_rows(gateway), display_name)["pass_through_path"] == shown.path
assert hub_row(legacy_hub_rows(gateway), display_name)["pass_through_path"] == shown.path
def test_two_pass_throughs_sharing_a_display_name_are_two_rows(gateway: Gateway) -> None:
display_name: Final = f"Twin {uuid.uuid4().hex[:6]}"
with (
pass_through(gateway, show_in_model_hub=True, display_name=display_name) as first,
pass_through(gateway, show_in_model_hub=True, display_name=display_name) as second,
):
twins: Final = tuple(row for row in hub_rows(gateway) if row["model_group"] == display_name)
assert sorted(string_value(row["pass_through_path"]) for row in twins) == sorted((first.path, second.path))
def test_a_config_file_pass_through_is_listed_once_beside_database_rows(gateway: Gateway, tmp_path: Path) -> None:
display_name: Final = f"Config NLP {uuid.uuid4().hex[:6]}"
path: Final = f"/integration-config-nlp-{uuid.uuid4().hex[:8]}"
base_config: Final = object_value(yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()))
config: Final = {
**base_config,
"general_settings": {
**object_value(base_config["general_settings"]),
"pass_through_endpoints": [
{"path": path, "target": gateway.upstream_url, "display_name": display_name, "show_in_model_hub": True},
{"path": f"{path}-hidden", "target": gateway.upstream_url, "display_name": "Hidden config NLP"},
],
},
}
config_path: Final = tmp_path / "pass_through_hub.yaml"
config_path.write_text(yaml.safe_dump(config))
with owned_proxy(gateway, tmp_path, {}, config=config_path) as owned:
rows: Final = hub_rows(owned)
assert names_of(rows).count(display_name) == 1, rows
assert hub_row(rows, display_name)["pass_through_path"] == path
assert "Hidden config NLP" not in names_of(rows), rows
assert hub_row(legacy_hub_rows(owned), display_name)["pass_through_path"] == path
with pass_through(owned, show_in_model_hub=True, display_name=f"{display_name} DB", path=f"{path}-db"):
beside: Final = eventually(lambda: hub_rows(owned), lambda listed: f"{display_name} DB" in names_of(listed))
assert names_of(beside).count(display_name) == 1, beside
assert hub_row(beside, f"{display_name} DB")["pass_through_path"] == f"{path}-db"
with pass_through(owned, show_in_model_hub=True, display_name=f"{display_name} DB", path=path):
replaced: Final = eventually(lambda: hub_rows(owned), lambda listed: display_name not in names_of(listed))
assert [string_value(row["pass_through_path"]) for row in replaced if row["pass_through_path"] == path] == [
path
], replaced
assert hub_row(replaced, f"{display_name} DB")["pass_through_path"] == path
restored: Final = eventually(lambda: hub_rows(owned), lambda listed: display_name in names_of(listed))
assert hub_row(restored, display_name)["pass_through_path"] == path
assert display_name not in names_of(hub_rows(gateway)), "the config row leaked into the shared proxy"
def test_a_health_row_under_the_pass_through_name_is_not_borrowed(gateway: Gateway) -> None:
display_name: Final = f"gpt-shadow-{uuid.uuid4().hex[:6]}"
health_check_id: Final = str(uuid.uuid4())
write_rows(
'INSERT INTO "LiteLLM_HealthCheckTable" (health_check_id, model_name, status, updated_at) '
"VALUES (%s, %s, %s, NOW())",
(health_check_id, display_name, "unhealthy"),
)
try:
with pass_through(gateway, show_in_model_hub=True, display_name=display_name) as shown:
row: Final = hub_row(hub_rows(gateway), display_name)
assert (row["pass_through_path"], row["health_status"]) == (shown.path, None), row
legacy: Final = hub_row(legacy_hub_rows(gateway), display_name)
assert (legacy["pass_through_path"], legacy["health_status"]) == (shown.path, None), legacy
finally:
write_rows('DELETE FROM "LiteLLM_HealthCheckTable" WHERE health_check_id = %s', (health_check_id,))
def test_repeated_hub_reads_return_the_same_rows(gateway: Gateway) -> None:
display_name: Final = f"Stable {uuid.uuid4().hex[:6]}"
with pass_through(gateway, show_in_model_hub=True, display_name=display_name):
reads: Final = tuple(hub_rows(gateway) for _ in range(3))
assert reads[0] == reads[1] == reads[2], reads
assert names_of(reads[0]).count(display_name) == 1, reads[0]
assert len(legacy_hub_rows(gateway)) == len(reads[0]), "legacy and v1 disagree on the row count"
def test_hub_reads_stay_200_while_an_endpoint_is_created_and_deleted_under_load(
gateway: Gateway, peer: Gateway
) -> None:
display_name: Final = f"Churn {uuid.uuid4().hex[:6]}"
def read(index: int) -> tuple[int, int]:
listed = (gateway if index % 2 else peer).request("GET", "/public/v1/model_hub", params=HUB_PARAMS)
return listed.status_code, names_of(rows_of(listed)).count(display_name) if listed.status_code == 200 else -1
with ThreadPoolExecutor(8) as pool:
before: Final = [pool.submit(read, index) for index in range(BURST // 2)]
with pass_through(gateway, show_in_model_hub=True, display_name=display_name):
during: Final = [pool.submit(read, index) for index in range(BURST // 2, BURST)]
results: Final = [future.result() for future in (*before, *during)]
assert all(status == 200 for status, _count in results), results
assert all(count in (0, 1) for _status, count in results), results
gateway_rows: Final = eventually(lambda: hub_rows(gateway), lambda rows: display_name not in names_of(rows))
peer_rows: Final = eventually(lambda: hub_rows(peer), lambda rows: display_name not in names_of(rows))
assert display_name not in names_of(gateway_rows), gateway_rows
assert display_name not in names_of(peer_rows), peer_rows
def test_hub_reads_survive_a_killed_proxy_worker(gateway: Gateway, tmp_path: Path) -> None:
display_name: Final = f"Survivor {uuid.uuid4().hex[:6]}"
def read(side: Gateway) -> object:
try:
return side.request("GET", "/public/v1/model_hub", params=HUB_PARAMS).status_code
except httpx.HTTPError as error:
return error
with (
pass_through(gateway, show_in_model_hub=True, display_name=display_name) as shown,
owned_proxy_process(gateway, tmp_path, {}, workers=2, state=OwnedState.SHARED_WITH_WORKER) as owned,
):
workers: Final = eventually(
lambda: tuple(int(pid) for pid in STARTED_WORKER.findall(owned.log.read_text())),
lambda pids: len(pids) == 2,
seconds=30,
)
assert hub_row(hub_rows(owned.gateway), display_name)["pass_through_path"] == shown.path
with ThreadPoolExecutor(8) as pool:
futures: Final = [pool.submit(read, owned.gateway if index % 2 else gateway) for index in range(BURST)]
owned.kill_worker(workers[0])
results: Final = [future.result() for future in futures]
assert all(status == 200 for status in results[0::2]), f"shared proxy answers: {results[0::2]!r}"
answered: Final = tuple(status for status in results[1::2] if isinstance(status, int))
assert answered and all(status == 200 for status in answered), f"owned proxy answers: {results[1::2]!r}"
eventually(lambda: read(owned.gateway), lambda status: status == 200, seconds=60)
assert hub_row(hub_rows(owned.gateway), display_name)["pass_through_path"] == shown.path
assert hub_row(hub_rows(gateway), display_name)["pass_through_path"] == shown.path

View file

@ -5248,6 +5248,30 @@ async def test_ProxyConfig__update_config_from_db_resolves_through_settings_stor
assert pc.settings.source("max_parallel_requests") == "config"
@pytest.mark.asyncio
async def test_ProxyConfig_get_config_reload_keeps_db_pass_through_endpoints_without_store_model_in_db(
tmp_path, monkeypatch
):
from litellm.proxy import proxy_server
config_file: Final = tmp_path / "config.yaml"
config_file.write_text(
"general_settings:\n pass_through_endpoints:\n - path: /config\n target: http://config.test\n"
)
monkeypatch.setattr(proxy_server, "prisma_client", None)
monkeypatch.setattr(proxy_server, "store_model_in_db", False)
monkeypatch.setattr(proxy_server, "general_settings", {})
monkeypatch.setattr(proxy_server, "initialize_pass_through_endpoints", AsyncMock())
monkeypatch.delenv("LITELLM_CONFIG_BUCKET_NAME", raising=False)
pc: Final = ProxyConfig()
await pc.get_config(config_file_path=str(config_file))
await pc._update_general_settings({"pass_through_endpoints": [{"path": "/db", "target": "http://db.test"}]})
await pc.get_config(config_file_path=str(config_file))
assert [endpoint["path"] for endpoint in pc.settings["pass_through_endpoints"]] == ["/db", "/config"]
@pytest.mark.asyncio
async def test_ProxyConfig__update_config_from_db_keeps_keys_the_config_file_omits(monkeypatch):
pc = ProxyConfig()

View file

@ -10,7 +10,7 @@ from fastapi.testclient import TestClient
import litellm
from litellm.proxy._types import LiteLLMRoutes
from litellm.proxy.proxy_server import app
from litellm.proxy.proxy_server import app, proxy_config
from litellm.types.router import DeploymentTypedDict, ModelGroupInfo
from tests._master_key import MASTER_KEY
from tests.unit.proxy.router_entry_point_stubs import route_entry_points_to_stubs
@ -428,7 +428,9 @@ def test_features_filter_matches_a_model_with_any_of_the_named_features(monkeypa
def test_a_single_feature_filter_selects_only_models_with_it(monkeypatch):
_publish(monkeypatch, (_info("sees", supports_vision=True), _info("plain"), _info("reasons", supports_reasoning=True)))
_publish(
monkeypatch, (_info("sees", supports_vision=True), _info("plain"), _info("reasons", supports_reasoning=True))
)
assert _groups(_get("filter[features][in]=vision")) == ["sees"]
assert _groups(_get("filter[features][in]=reasoning")) == ["reasons"]
@ -463,11 +465,14 @@ def test_a_facet_serves_the_distinct_values_of_its_column(monkeypatch, facet):
response = _facet(facet)
assert response.status_code == 200, response.text
assert response.json()["data"] == {
"providers": ["anthropic", "mistral", "openai"],
"modes": ["chat", "embedding"],
"features": ["vision"],
}[facet]
assert (
response.json()["data"]
== {
"providers": ["anthropic", "mistral", "openai"],
"modes": ["chat", "embedding"],
"features": ["vision"],
}[facet]
)
def test_a_facet_offers_only_values_the_table_can_show(monkeypatch):
@ -509,3 +514,157 @@ def test_a_facet_rejects_a_sort_it_does_not_offer(monkeypatch):
@pytest.mark.parametrize("facet", FACET_PATHS)
def test_a_facet_is_reachable_without_a_key(facet):
assert f"{MODEL_HUB_PATH}/{facet}" in LiteLLMRoutes.public_routes.value
PASS_THROUGH_TARGET = "https://nlp.internal.example/v1"
def _configure_pass_throughs(monkeypatch, *endpoints: Mapping[str, object]) -> None:
"""Pass-through endpoints as the config sync publishes them into the in-memory settings store."""
monkeypatch.setitem(
proxy_config.settings,
"pass_through_endpoints",
[{"target": PASS_THROUGH_TARGET, **endpoint} for endpoint in endpoints],
)
def _pass_through_row(model_group: str, path: str, methods: list[str] | None = None) -> dict[str, object]:
return {
**ModelGroupInfo(model_group=model_group, providers=[], mode="passthrough").model_dump(mode="json"),
"is_public_model_group": True,
"custom_tags": None,
"custom_metadata": None,
"supports_background_chat": False,
"health_status": None,
"health_response_time": None,
"health_checked_at": None,
"pass_through_path": path,
"pass_through_methods": methods,
}
def test_a_pass_through_endpoint_opted_into_the_hub_is_listed_under_its_display_name(monkeypatch: pytest.MonkeyPatch):
_publish(monkeypatch, (_info("gpt-4o"),))
_configure_pass_throughs(
monkeypatch,
{"path": "/clinical-ner", "display_name": "Clinical NER", "show_in_model_hub": True},
{"path": "/kept-private", "display_name": "Kept private", "show_in_model_hub": False},
{"path": "/unnamed-tagger", "show_in_model_hub": True},
{"path": "/default-hidden"},
)
response = _get()
assert response.status_code == 200, response.text
assert _groups(response) == ["/unnamed-tagger", "Clinical NER", "gpt-4o"]
assert response.json()["data"][:2] == [
_pass_through_row("/unnamed-tagger", "/unnamed-tagger"),
_pass_through_row("Clinical NER", "/clinical-ner"),
]
@pytest.mark.parametrize("route", [MODEL_HUB_PATH, LEGACY_MODEL_HUB_PATH])
@pytest.mark.parametrize("invalid", [None, "invalid", 17, [], {}, {"path": "/missing-target"}])
def test_invalid_pass_through_entries_do_not_hide_valid_hub_rows(monkeypatch: pytest.MonkeyPatch, route, invalid):
_publish(monkeypatch, ())
monkeypatch.setitem(
proxy_config.settings,
"pass_through_endpoints",
[
{"path": "/first", "target": PASS_THROUGH_TARGET, "show_in_model_hub": True},
invalid,
{"path": "/last", "target": PASS_THROUGH_TARGET, "show_in_model_hub": True},
],
)
response = client.get(route)
assert response.status_code == 200, response.text
rows = response.json()["data"] if route == MODEL_HUB_PATH else response.json()
assert rows == [_pass_through_row("/first", "/first"), _pass_through_row("/last", "/last")]
def test_a_pass_through_row_carries_the_methods_its_route_accepts(monkeypatch: pytest.MonkeyPatch):
_publish(monkeypatch, (_info("gpt-4o"),))
_configure_pass_throughs(
monkeypatch, {"path": "/catalog", "display_name": "Catalog", "show_in_model_hub": True, "methods": ["GET"]}
)
response = _get()
assert response.status_code == 200, response.text
assert response.json()["data"][0] == _pass_through_row("Catalog", "/catalog", methods=["GET"])
def test_a_pass_through_row_is_filterable_by_its_mode_and_offered_by_the_modes_facet(monkeypatch: pytest.MonkeyPatch):
_publish(monkeypatch, (_info("gpt-4o"), _info("embedder", mode="embedding")))
_configure_pass_throughs(
monkeypatch, {"path": "/clinical-ner", "display_name": "Clinical NER", "show_in_model_hub": True}
)
assert _groups(_get("filter[mode]=passthrough")) == ["Clinical NER"]
assert _groups(_get("filter[mode]=chat")) == ["gpt-4o"]
assert _facet("modes").json()["data"] == ["chat", "embedding", "passthrough"]
def test_a_pass_through_endpoint_is_listed_even_when_no_model_group_is_published(monkeypatch: pytest.MonkeyPatch):
_publish(monkeypatch, ())
monkeypatch.setattr(litellm, "public_model_groups", None)
_configure_pass_throughs(
monkeypatch, {"path": "/clinical-ner", "display_name": "Clinical NER", "show_in_model_hub": True}
)
response = _get()
assert response.status_code == 200, response.text
assert response.json()["data"] == [_pass_through_row("Clinical NER", "/clinical-ner")]
def test_a_pass_through_named_like_a_model_does_not_borrow_its_health(monkeypatch: pytest.MonkeyPatch):
prisma_client, read = _recording_prisma([_health_check("gpt-4o", status="unhealthy")])
prisma_client.get_all_latest_health_checks = AsyncMock(return_value=[_health_check("gpt-4o", status="unhealthy")])
_publish(monkeypatch, (_info("gpt-4o"),), prisma_client=prisma_client)
_configure_pass_throughs(monkeypatch, {"path": "/gpt-4o", "display_name": "gpt-4o", "show_in_model_hub": True})
rows = _get().json()["data"]
legacy_rows = client.get(LEGACY_MODEL_HUB_PATH).json()
assert [(row["pass_through_path"], row["health_status"]) for row in rows] == [
(None, "unhealthy"),
("/gpt-4o", None),
]
assert _asked_about(read) == ["gpt-4o"]
assert [(row["pass_through_path"], row["health_status"]) for row in legacy_rows] == [
(None, "unhealthy"),
("/gpt-4o", None),
]
def test_a_pass_through_only_proxy_without_a_router_still_publishes_its_endpoints(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", None)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
_configure_pass_throughs(
monkeypatch, {"path": "/clinical-ner", "display_name": "Clinical NER", "show_in_model_hub": True}
)
response = _get()
legacy = client.get(LEGACY_MODEL_HUB_PATH)
assert response.status_code == 200, response.text
assert response.json()["data"] == [_pass_through_row("Clinical NER", "/clinical-ner")]
assert legacy.status_code == 200, legacy.text
assert legacy.json() == [_pass_through_row("Clinical NER", "/clinical-ner")]
def test_the_endpoint_it_supersedes_lists_pass_through_rows_after_the_model_groups(monkeypatch: pytest.MonkeyPatch):
_publish(monkeypatch, _named(2))
_configure_pass_throughs(
monkeypatch, {"path": "/clinical-ner", "display_name": "Clinical NER", "show_in_model_hub": True}
)
response = client.get(LEGACY_MODEL_HUB_PATH)
assert response.status_code == 200, response.text
body = response.json()
assert [row["model_group"] for row in body] == ["model-000", "model-001", "Clinical NER"], body
assert body[2] == _pass_through_row("Clinical NER", "/clinical-ner")