diff --git a/litellm/proxy/public_endpoints/public_endpoints.py b/litellm/proxy/public_endpoints/public_endpoints.py index 137f91f9b6e..8903b22c120 100644 --- a/litellm/proxy/public_endpoints/public_endpoints.py +++ b/litellm/proxy/public_endpoints/public_endpoints.py @@ -3,11 +3,12 @@ import json import os import re from collections.abc import Awaitable, Callable, Mapping, Sequence +from functools import cache from importlib.resources import files from typing import TYPE_CHECKING, Final, Protocol from fastapi import APIRouter, HTTPException, Request -from pydantic import TypeAdapter +from pydantic import ConfigDict, TypeAdapter from typing_extensions import ReadOnly, TypedDict import litellm @@ -31,6 +32,7 @@ from litellm.types.proxy.management_endpoints.model_management_endpoints import ) from litellm.types.proxy.public_endpoints.public_endpoints import ( AgentCreateInfo, + AgentCredentialField, AutoRouterPresetRecord, ComplexityScorerDefaults, ProviderCreateInfo, @@ -210,6 +212,54 @@ def _load_endpoints() -> list[_EndpointEntry]: return _build_endpoints(raw) +class _BundledAgentCreateInfo(AgentCreateInfo): + model_config = ConfigDict(frozen=True) + + inherit_credentials_from_provider: str | None = None + + +_BUNDLED_AGENTS_ADAPTER: Final = TypeAdapter(list[_BundledAgentCreateInfo]) + + +def _read_bundled_file(filename: str) -> bytes: + return files("litellm.proxy.public_endpoints").joinpath(filename).read_bytes() + + +@cache +def _provider_create_fields() -> tuple[ProviderCreateInfo, ...]: + return tuple(_PROVIDER_CREATE_FIELDS_ADAPTER.validate_json(_read_bundled_file("provider_create_fields.json"))) + + +def _inherited_credential_fields(provider: ProviderCreateInfo) -> tuple[AgentCredentialField, ...]: + return tuple( + AgentCredentialField.model_validate({**field.model_dump(), "include_in_litellm_params": True}) + for field in provider.credential_fields + ) + + +def _with_inherited_credentials( + agent: _BundledAgentCreateInfo, providers: Mapping[str, ProviderCreateInfo] +) -> AgentCreateInfo: + provider_name: Final = agent.inherit_credentials_from_provider + provider: Final = providers.get(provider_name) if provider_name else None + inherited: Final = _inherited_credential_fields(provider) if provider else () + return AgentCreateInfo.model_validate( + { + **agent.model_dump(exclude={"inherit_credentials_from_provider"}), + "credential_fields": [*agent.credential_fields, *inherited], + } + ) + + +@cache +def _agent_create_fields() -> tuple[AgentCreateInfo, ...]: + providers: Final = {provider.provider: provider for provider in _provider_create_fields()} + return tuple( + _with_inherited_credentials(agent, providers) + for agent in _BUNDLED_AGENTS_ADAPTER.validate_json(_read_bundled_file("agent_create_fields.json")) + ) + + # --------------------------------------------------------------------------- @@ -404,22 +454,15 @@ async def get_supported_providers() -> list[str]: tags=["public", "providers"], response_model=list[ProviderCreateInfo], ) -async def get_provider_fields() -> list[ProviderCreateInfo]: +async def get_provider_fields() -> tuple[ProviderCreateInfo, ...]: """ Return provider metadata required by the dashboard create-model flow. + + Reads from the bundled local file. Result is cached in-process for the + lifetime of the server process. """ - provider_create_fields_path: Final = os.path.join( - os.path.dirname(os.path.dirname(os.path.dirname(__file__))), - "proxy", - "public_endpoints", - "provider_create_fields.json", - ) - - with open(provider_create_fields_path, "r") as f: - provider_create_fields: Final = _PROVIDER_CREATE_FIELDS_ADAPTER.validate_python(json.load(f)) - - return provider_create_fields + return _provider_create_fields() @router.get( @@ -601,45 +644,14 @@ async def get_supported_endpoints() -> SupportedEndpointsResponse: tags=["public", "[beta] Agents"], response_model=list[AgentCreateInfo], ) -async def get_agent_fields() -> list[AgentCreateInfo]: +async def get_agent_fields() -> tuple[AgentCreateInfo, ...]: """ Return agent type metadata required by the dashboard create-agent flow. If an agent has `inherit_credentials_from_provider`, the provider's credential fields are automatically appended to the agent's credential_fields. + + Reads from the bundled local files. Result is cached in-process for the + lifetime of the server process. """ - base_path: Final = os.path.join( - os.path.dirname(os.path.dirname(os.path.dirname(__file__))), - "proxy", - "public_endpoints", - ) - - agent_create_fields_path: Final = os.path.join(base_path, "agent_create_fields.json") - provider_create_fields_path: Final = os.path.join(base_path, "provider_create_fields.json") - - with open(agent_create_fields_path, "r") as f: - agent_create_fields: Final = json.load(f) - - with open(provider_create_fields_path, "r") as f: - provider_create_fields: Final = json.load(f) - - # Build a lookup map for providers by name - provider_map: Final = {p["provider"]: p for p in provider_create_fields} - - # Merge inherited credential fields - for agent in agent_create_fields: - inherit_from = agent.get("inherit_credentials_from_provider") - if inherit_from and inherit_from in provider_map: - provider = provider_map[inherit_from] - # Copy provider fields and mark them for inclusion in litellm_params - inherited_fields = [] - for field in provider.get("credential_fields", []): - field_copy = field.copy() - field_copy["include_in_litellm_params"] = True - inherited_fields.append(field_copy) - # Append provider credential fields after agent's own fields - agent["credential_fields"] = agent.get("credential_fields", []) + inherited_fields - # Remove the inherit field from response (not needed by frontend) - agent.pop("inherit_credentials_from_provider", None) - - return agent_create_fields + return _agent_create_fields() diff --git a/tests/unit/proxy/public_endpoints/test_public_endpoints.py b/tests/unit/proxy/public_endpoints/test_public_endpoints.py index 9c26804a1db..b03dbede869 100644 --- a/tests/unit/proxy/public_endpoints/test_public_endpoints.py +++ b/tests/unit/proxy/public_endpoints/test_public_endpoints.py @@ -2,6 +2,8 @@ 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 @@ -18,7 +20,7 @@ from litellm.router_strategy.complexity_router.fuse_presets import get_fuse_pres from litellm.types.proxy.management_endpoints.model_management_endpoints import ( ModelGroupInfoProxy, ) -from litellm.types.proxy.public_endpoints.public_endpoints import ProviderCreateInfo +from litellm.types.proxy.public_endpoints.public_endpoints import ProviderCreateInfo, ProviderCredentialField from litellm.types.utils import LlmProviders @@ -1563,6 +1565,59 @@ async def test_fetch_remote_autorouter_presets_parses_and_rejects_empty(monkeypa 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 diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 94100646a7d..c3dde3b4d47 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -13438,6 +13438,9 @@ export interface paths { * * If an agent has `inherit_credentials_from_provider`, the provider's credential * fields are automatically appended to the agent's credential_fields. + * + * Reads from the bundled local files. Result is cached in-process for the + * lifetime of the server process. */ get: operations["get_agent_fields_public_agents_fields_get"]; put?: never; @@ -13659,6 +13662,9 @@ export interface paths { /** * Get Provider Fields * @description Return provider metadata required by the dashboard create-model flow. + * + * Reads from the bundled local file. Result is cached in-process for the + * lifetime of the server process. */ get: operations["get_provider_fields_public_providers_fields_get"]; put?: never;