mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-21 00:21:49 +00:00
feat(ui): configure web search interception from the Admin UI
Web search interception could only be switched on by editing config.yaml and restarting the proxy, so an admin had no way to turn it on, choose which providers it covers, or pick which configured search tool runs the searches without a redeploy. Adds GET/PATCH /get|update/websearch_interception_settings backed by a WebSearchInterceptionSettings model, and an Admin Settings panel that reads and writes them. Config/database precedence comes from the existing settings store, so a key the config file declares is still refused here. The stored settings apply to a running proxy: the DB poll rebuilds the WebSearchInterceptionLogger, removing the old instance before adding the new one, because two instances with different params hash differently in the callback dedup key and the first to short-circuit would win. A proxy that activates interception the existing way, through litellm_settings.callbacks with no stored params, is left untouched.
This commit is contained in:
parent
2886b8ee27
commit
e12cbb4e13
11 changed files with 997 additions and 1 deletions
|
|
@ -4888,6 +4888,7 @@ class ProxyConfig:
|
|||
def __init__(self) -> None:
|
||||
self.config: Mapping[str, object] = MappingProxyType({})
|
||||
self._last_semantic_filter_config: dict[str, object] | None = None
|
||||
self._last_websearch_interception_config: dict[str, object] | None = None
|
||||
self._last_hashicorp_vault_config: dict[str, object] | None = None
|
||||
self._last_cyberark_config: dict[str, object] | None = None # mutable-ok: change-detection cache
|
||||
self._cyberark_boot_env: dict[str, str | None] | None = None # mutable-ok: deployment env snapshot, set once
|
||||
|
|
@ -7697,6 +7698,9 @@ class ProxyConfig:
|
|||
if self._should_load_db_object(object_type="semantic_filter_settings"):
|
||||
await self._init_semantic_filter_settings_in_db(prisma_client=prisma_client)
|
||||
|
||||
if self._should_load_db_object(object_type="websearch_interception_settings"):
|
||||
await self.init_websearch_interception_settings_in_db(prisma_client=prisma_client)
|
||||
|
||||
if self._should_load_db_object(object_type="config_overrides"):
|
||||
await self._init_hashicorp_vault_config_override(prisma_client=prisma_client)
|
||||
await self._init_cyberark_config_override(prisma_client=prisma_client)
|
||||
|
|
@ -7775,6 +7779,56 @@ class ProxyConfig:
|
|||
except Exception as e:
|
||||
verbose_proxy_logger.exception("Error initializing semantic filter settings from DB: %s", e)
|
||||
|
||||
async def init_websearch_interception_settings_in_db(self, prisma_client: PrismaClient):
|
||||
"""
|
||||
Initialize web search interception settings from database.
|
||||
Called periodically (approximately every 10 seconds) by background task to hot-reload settings across all pods.
|
||||
"""
|
||||
import json
|
||||
|
||||
import litellm
|
||||
from litellm.integrations.websearch_interception.handler import (
|
||||
WebSearchInterceptionLogger,
|
||||
)
|
||||
|
||||
try:
|
||||
config_record: Final = await get_config_param(prisma_client, "litellm_settings")
|
||||
|
||||
if config_record is None or config_record.param_value is None:
|
||||
return
|
||||
|
||||
litellm_settings = config_record.param_value
|
||||
if isinstance(litellm_settings, str):
|
||||
litellm_settings = json.loads(litellm_settings)
|
||||
|
||||
websearch_config: Final = litellm_settings.get("websearch_interception_params", None)
|
||||
|
||||
# Absent means nobody stored params, so a callbacks-list proxy keeps its callback.
|
||||
if websearch_config is None:
|
||||
return
|
||||
|
||||
enabled: Final = bool(websearch_config.get("enabled", True))
|
||||
registered: Final = bool(
|
||||
litellm.logging_callback_manager.get_custom_loggers_for_type(WebSearchInterceptionLogger)
|
||||
)
|
||||
if self._last_websearch_interception_config == websearch_config and registered == enabled:
|
||||
return
|
||||
|
||||
litellm.logging_callback_manager.remove_callbacks_by_type(litellm.callbacks, WebSearchInterceptionLogger)
|
||||
|
||||
if enabled:
|
||||
litellm.logging_callback_manager.add_litellm_callback(
|
||||
WebSearchInterceptionLogger.from_config_yaml(websearch_config)
|
||||
)
|
||||
verbose_proxy_logger.info("Web search interception reinitialized from DB")
|
||||
else:
|
||||
verbose_proxy_logger.info("Web search interception disabled")
|
||||
|
||||
self._last_websearch_interception_config = dict(websearch_config)
|
||||
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception("Error initializing web search interception settings from DB: %s", e)
|
||||
|
||||
async def _init_sso_settings_in_db(self, prisma_client: PrismaClient):
|
||||
"""
|
||||
Initialize SSO settings from database into the router on startup.
|
||||
|
|
|
|||
|
|
@ -477,6 +477,35 @@ class MCPToolSearchSettingsResponse(SettingsResponse):
|
|||
"""Response model for native MCP tool search settings"""
|
||||
|
||||
|
||||
class WebSearchInterceptionSettings(BaseModel):
|
||||
"""Configuration for server-side web search interception"""
|
||||
|
||||
enabled: bool = Field(
|
||||
default=False,
|
||||
description="Serve web search tool calls from a configured search tool instead of passing them upstream",
|
||||
)
|
||||
|
||||
enabled_providers: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="LLM providers to intercept for (e.g. 'bedrock', 'vertex_ai'). Empty intercepts Bedrock only.",
|
||||
)
|
||||
|
||||
search_tool_name: str | None = Field(
|
||||
default=None,
|
||||
description="Name of the configured search tool to run searches through. Empty uses the first one available.",
|
||||
)
|
||||
|
||||
max_agentic_loops: int | None = Field(
|
||||
default=None,
|
||||
ge=1,
|
||||
description="How many follow-up model calls one intercepted request may chain. Empty applies the default of 3.",
|
||||
)
|
||||
|
||||
|
||||
class WebSearchInterceptionSettingsResponse(SettingsResponse):
|
||||
"""Response model for web search interception settings"""
|
||||
|
||||
|
||||
@router.get(
|
||||
"/get/allowed_ips",
|
||||
tags=["Budget & Spend Tracking"],
|
||||
|
|
@ -875,7 +904,13 @@ async def update_default_team_member_budget(teams: list[NewUserRequestTeam], use
|
|||
|
||||
|
||||
async def _update_litellm_setting(
|
||||
settings: DefaultInternalUserParams | DefaultTeamSSOParams | MCPSemanticFilterSettings | MCPToolSearchSettings,
|
||||
settings: (
|
||||
DefaultInternalUserParams
|
||||
| DefaultTeamSSOParams
|
||||
| MCPSemanticFilterSettings
|
||||
| MCPToolSearchSettings
|
||||
| WebSearchInterceptionSettings
|
||||
),
|
||||
settings_key: str,
|
||||
success_message: str,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
|
|
@ -1399,6 +1434,77 @@ async def update_mcp_semantic_filter_settings(
|
|||
return result
|
||||
|
||||
|
||||
@router.get(
|
||||
"/get/websearch_interception_settings",
|
||||
tags=["Settings"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
response_model=WebSearchInterceptionSettingsResponse,
|
||||
)
|
||||
async def get_websearch_interception_settings(
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
Get web search interception configuration.
|
||||
|
||||
Returns the current settings plus their schema, for the Admin UI to render.
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client, proxy_config
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"error": "Database not connected. Please connect a database."},
|
||||
)
|
||||
|
||||
config: Final = await proxy_config.get_config()
|
||||
|
||||
return await _get_settings_with_schema(
|
||||
settings_key="websearch_interception_params",
|
||||
settings_class=WebSearchInterceptionSettings,
|
||||
config=config,
|
||||
)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/update/websearch_interception_settings",
|
||||
tags=["Settings"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
async def update_websearch_interception_settings(
|
||||
settings: WebSearchInterceptionSettings,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
Update web search interception settings in database.
|
||||
|
||||
Settings will be picked up by all pods within approximately 10 seconds via background polling.
|
||||
"""
|
||||
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Only proxy admins can update web search interception settings.",
|
||||
)
|
||||
|
||||
result: Final = await _update_litellm_setting(
|
||||
settings=settings,
|
||||
settings_key="websearch_interception_params",
|
||||
success_message=(
|
||||
"Web search interception settings updated successfully. "
|
||||
"Changes will be applied across all pods within 10 seconds."
|
||||
),
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
try:
|
||||
from litellm.proxy.proxy_server import prisma_client, proxy_config
|
||||
|
||||
if prisma_client is not None:
|
||||
await proxy_config.init_websearch_interception_settings_in_db(prisma_client=prisma_client)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.warning("Failed to reinitialize web search interception settings immediately: %s", e)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get(
|
||||
"/get/mcp_tool_search_settings",
|
||||
tags=["Settings"], # mutable-ok: FastAPI's route decorator only accepts a list
|
||||
|
|
|
|||
|
|
@ -4526,3 +4526,89 @@ async def test_add_deployment_syncs_ui_settings_even_when_the_model_reconcile_fa
|
|||
await config.add_deployment(prisma_client=prisma_client, proxy_logging_obj=MagicMock())
|
||||
|
||||
assert general_settings["allow_agents_for_team_admins"] is True
|
||||
|
||||
|
||||
def _websearch_logger_cls():
|
||||
from litellm.integrations.websearch_interception.handler import (
|
||||
WebSearchInterceptionLogger,
|
||||
)
|
||||
|
||||
return WebSearchInterceptionLogger
|
||||
|
||||
|
||||
def _run_websearch_init(monkeypatch, stored_params, starting_callbacks):
|
||||
pc = ProxyConfig()
|
||||
monkeypatch.setattr(litellm, "callbacks", list(starting_callbacks))
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.proxy_server.get_config_param",
|
||||
AsyncMock(return_value=SimpleNamespace(param_value={"websearch_interception_params": stored_params}))
|
||||
if stored_params is not None
|
||||
else AsyncMock(return_value=SimpleNamespace(param_value={})),
|
||||
)
|
||||
asyncio.run(pc.init_websearch_interception_settings_in_db(prisma_client=MagicMock()))
|
||||
return pc
|
||||
|
||||
|
||||
def test_init_websearch_interception_absent_key_leaves_callbacks_untouched(monkeypatch):
|
||||
logger_cls = _websearch_logger_cls()
|
||||
config_registered = logger_cls(search_tool_name="from-config-yaml")
|
||||
|
||||
_run_websearch_init(monkeypatch, stored_params=None, starting_callbacks=[config_registered])
|
||||
|
||||
assert litellm.callbacks == [config_registered]
|
||||
|
||||
|
||||
def test_init_websearch_interception_enables_when_enabled_key_missing(monkeypatch):
|
||||
logger_cls = _websearch_logger_cls()
|
||||
|
||||
_run_websearch_init(
|
||||
monkeypatch,
|
||||
stored_params={"search_tool_name": "stored-tool"},
|
||||
starting_callbacks=[],
|
||||
)
|
||||
|
||||
registered = [cb for cb in litellm.callbacks if isinstance(cb, logger_cls)]
|
||||
assert len(registered) == 1
|
||||
assert registered[0].search_tool_name == "stored-tool"
|
||||
|
||||
|
||||
def test_init_websearch_interception_disabled_removes_the_callback(monkeypatch):
|
||||
logger_cls = _websearch_logger_cls()
|
||||
existing = logger_cls(search_tool_name="stored-tool")
|
||||
|
||||
_run_websearch_init(
|
||||
monkeypatch,
|
||||
stored_params={"enabled": False, "search_tool_name": "stored-tool"},
|
||||
starting_callbacks=[existing],
|
||||
)
|
||||
|
||||
assert [cb for cb in litellm.callbacks if isinstance(cb, logger_cls)] == []
|
||||
|
||||
|
||||
def test_init_websearch_interception_replaces_stale_instance_on_param_change(monkeypatch):
|
||||
logger_cls = _websearch_logger_cls()
|
||||
stale = logger_cls(search_tool_name="old-tool", max_agentic_loops=2)
|
||||
|
||||
_run_websearch_init(
|
||||
monkeypatch,
|
||||
stored_params={"enabled": True, "search_tool_name": "new-tool", "max_agentic_loops": 7},
|
||||
starting_callbacks=[stale],
|
||||
)
|
||||
|
||||
registered = [cb for cb in litellm.callbacks if isinstance(cb, logger_cls)]
|
||||
assert len(registered) == 1
|
||||
assert (registered[0].search_tool_name, registered[0].max_agentic_loops) == ("new-tool", 7)
|
||||
|
||||
|
||||
def test_init_websearch_interception_honors_enabled_providers(monkeypatch):
|
||||
logger_cls = _websearch_logger_cls()
|
||||
|
||||
_run_websearch_init(
|
||||
monkeypatch,
|
||||
stored_params={"enabled": True, "enabled_providers": ["bedrock", "vertex_ai"]},
|
||||
starting_callbacks=[],
|
||||
)
|
||||
|
||||
registered = [cb for cb in litellm.callbacks if isinstance(cb, logger_cls)]
|
||||
assert len(registered) == 1
|
||||
assert registered[0].enabled_providers == ["bedrock", "vertex_ai"]
|
||||
|
|
|
|||
|
|
@ -3127,6 +3127,77 @@ class TestMcpToolSearchSettingsEndpoints:
|
|||
assert mock_proxy_config["save_call_count"]() == 0
|
||||
|
||||
|
||||
class TestWebSearchInterceptionSettingsEndpoints:
|
||||
@staticmethod
|
||||
def _override_auth(role: LitellmUserRoles):
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
|
||||
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_id="u", api_key="hashed", user_role=role
|
||||
)
|
||||
|
||||
def test_get_returns_stored_values_and_field_schema(self, mock_proxy_config, mock_auth, monkeypatch):
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", object())
|
||||
mock_proxy_config["config"]["litellm_settings"]["websearch_interception_params"] = {
|
||||
"enabled": True,
|
||||
"enabled_providers": ["bedrock", "vertex_ai"],
|
||||
"search_tool_name": "my-perplexity-search",
|
||||
}
|
||||
|
||||
resp = client.get("/get/websearch_interception_settings")
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["values"] == {
|
||||
"enabled": True,
|
||||
"enabled_providers": ["bedrock", "vertex_ai"],
|
||||
"search_tool_name": "my-perplexity-search",
|
||||
"max_agentic_loops": None,
|
||||
}
|
||||
assert resp.json()["field_schema"]["properties"]["enabled_providers"]["type"] == "array"
|
||||
|
||||
def test_update_requires_proxy_admin(self, monkeypatch):
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
|
||||
self._override_auth(LitellmUserRoles.INTERNAL_USER)
|
||||
try:
|
||||
resp = client.patch("/update/websearch_interception_settings", json={"enabled": True})
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
assert resp.status_code == 403
|
||||
assert "proxy admin" in resp.json()["detail"].lower()
|
||||
|
||||
def test_update_persists_settings(self, mock_proxy_config, monkeypatch):
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
|
||||
self._override_auth(LitellmUserRoles.PROXY_ADMIN)
|
||||
payload = {
|
||||
"enabled": True,
|
||||
"enabled_providers": ["bedrock"],
|
||||
"search_tool_name": "my-perplexity-search",
|
||||
"max_agentic_loops": 5,
|
||||
}
|
||||
try:
|
||||
resp = client.patch("/update/websearch_interception_settings", json=payload)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert mock_proxy_config["save_call_count"]() == 1
|
||||
assert mock_proxy_config["config"]["litellm_settings"]["websearch_interception_params"] == payload
|
||||
|
||||
def test_update_rejects_zero_max_agentic_loops(self, mock_proxy_config, monkeypatch):
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
|
||||
self._override_auth(LitellmUserRoles.PROXY_ADMIN)
|
||||
try:
|
||||
resp = client.patch(
|
||||
"/update/websearch_interception_settings",
|
||||
json={"enabled": True, "max_agentic_loops": 0},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
assert resp.status_code == 422
|
||||
assert mock_proxy_config["save_call_count"]() == 0
|
||||
|
||||
|
||||
def test_upload_logo_requires_proxy_admin(monkeypatch):
|
||||
"""Any authenticated key could previously write a file to the server's disk here."""
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import UserBannerSettings from "@/components/Settings/AdminSettings/UserBannerSe
|
|||
import CyberArk from "@/components/Settings/AdminSettings/CyberArk/CyberArk";
|
||||
import HashicorpVault from "@/components/Settings/AdminSettings/HashicorpVault/HashicorpVault";
|
||||
import PluginSettings from "@/components/Settings/AdminSettings/PluginSettings/PluginSettings";
|
||||
import WebSearchInterceptionSettings from "@/components/Settings/AdminSettings/WebSearchInterceptionSettings/WebSearchInterceptionSettings";
|
||||
import SSOModals from "@/components/SSOModals";
|
||||
import {
|
||||
emptySSOSettingsFormValues,
|
||||
|
|
@ -408,6 +409,11 @@ const AdminPanel: React.FC<AdminPanelProps> = ({ proxySettings }) => {
|
|||
label: "Plugins",
|
||||
children: <PluginSettings />,
|
||||
},
|
||||
{
|
||||
key: "web-search-interception",
|
||||
label: "Web Search Interception",
|
||||
children: <WebSearchInterceptionSettings />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
import { updateWebSearchInterceptionSettings } from "@/components/networking";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { createQueryKeys } from "../common/queryKeysFactory";
|
||||
|
||||
const webSearchInterceptionSettingsKeys = createQueryKeys("webSearchInterceptionSettings");
|
||||
|
||||
export const useUpdateWebSearchInterceptionSettings = (accessToken: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (settings: Record<string, any>) => {
|
||||
if (!accessToken) {
|
||||
throw new Error("Access token is required");
|
||||
}
|
||||
return updateWebSearchInterceptionSettings(accessToken, settings);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: webSearchInterceptionSettingsKeys.all,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { getWebSearchInterceptionSettings } from "@/components/networking";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createQueryKeys } from "../common/queryKeysFactory";
|
||||
import useAuthorized from "../useAuthorized";
|
||||
|
||||
const webSearchInterceptionSettingsKeys = createQueryKeys("webSearchInterceptionSettings");
|
||||
|
||||
export const useWebSearchInterceptionSettings = () => {
|
||||
const { accessToken } = useAuthorized();
|
||||
return useQuery<Record<string, any>>({
|
||||
queryKey: webSearchInterceptionSettingsKeys.list({}),
|
||||
queryFn: async () => await getWebSearchInterceptionSettings(accessToken),
|
||||
enabled: !!accessToken,
|
||||
staleTime: 60 * 60 * 1000,
|
||||
gcTime: 60 * 60 * 1000,
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import WebSearchInterceptionSettings from "./WebSearchInterceptionSettings";
|
||||
import { useWebSearchInterceptionSettings } from "@/app/(dashboard)/hooks/webSearchInterceptionSettings/useWebSearchInterceptionSettings";
|
||||
import { useUpdateWebSearchInterceptionSettings } from "@/app/(dashboard)/hooks/webSearchInterceptionSettings/useUpdateWebSearchInterceptionSettings";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/webSearchInterceptionSettings/useWebSearchInterceptionSettings", () => ({
|
||||
useWebSearchInterceptionSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/webSearchInterceptionSettings/useUpdateWebSearchInterceptionSettings", () => ({
|
||||
useUpdateWebSearchInterceptionSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
fetchSearchTools: vi.fn().mockResolvedValue({
|
||||
search_tools: [{ search_tool_name: "my-perplexity-search" }, { search_tool_name: "backup-search" }],
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockMutate = vi.fn();
|
||||
|
||||
const ENABLED_PAYLOAD = {
|
||||
enabled: true,
|
||||
enabled_providers: ["bedrock"],
|
||||
search_tool_name: "my-perplexity-search",
|
||||
max_agentic_loops: null,
|
||||
};
|
||||
|
||||
const storedSettings = {
|
||||
field_schema: {
|
||||
properties: {
|
||||
enabled: { description: "Serve web search tool calls from a configured search tool" },
|
||||
},
|
||||
},
|
||||
values: {
|
||||
enabled: false,
|
||||
enabled_providers: ["bedrock"],
|
||||
search_tool_name: "my-perplexity-search",
|
||||
max_agentic_loops: null,
|
||||
},
|
||||
};
|
||||
|
||||
async function renderSettings() {
|
||||
const result = render(<WebSearchInterceptionSettings />);
|
||||
await act(async () => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("WebSearchInterceptionSettings", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(useAuthorized).mockReturnValue({ accessToken: "test-token" } as any);
|
||||
vi.mocked(useWebSearchInterceptionSettings).mockReturnValue({
|
||||
data: storedSettings,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
vi.mocked(useUpdateWebSearchInterceptionSettings).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
isPending: false,
|
||||
error: null,
|
||||
} as any);
|
||||
});
|
||||
|
||||
it("renders the settings section", async () => {
|
||||
await renderSettings();
|
||||
expect(screen.getByText("Web Search Interception")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a login prompt when there is no access token", () => {
|
||||
vi.mocked(useAuthorized).mockReturnValue({ accessToken: null } as any);
|
||||
render(<WebSearchInterceptionSettings />);
|
||||
expect(screen.getByText(/please log in/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the settings while loading", async () => {
|
||||
vi.mocked(useWebSearchInterceptionSettings).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
await renderSettings();
|
||||
expect(screen.queryByText("Enable Web Search Interception")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces a load failure", async () => {
|
||||
vi.mocked(useWebSearchInterceptionSettings).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: new Error("boom"),
|
||||
} as any);
|
||||
await renderSettings();
|
||||
expect(screen.getByText("Could not load web search interception settings")).toBeInTheDocument();
|
||||
expect(screen.getByText("boom")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps save disabled until something changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderSettings();
|
||||
|
||||
const save = screen.getByRole("button", { name: /save settings/i });
|
||||
expect(save).toBeDisabled();
|
||||
|
||||
await user.click(save);
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("submits the stored values with the toggled enabled flag", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderSettings();
|
||||
|
||||
await user.click(screen.getByRole("switch"));
|
||||
await user.click(screen.getByRole("button", { name: /save settings/i }));
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockMutate.mock.calls[0][0]).toEqual(ENABLED_PAYLOAD);
|
||||
});
|
||||
|
||||
it("reseeds the form when the stored settings change underneath it", async () => {
|
||||
vi.mocked(useWebSearchInterceptionSettings).mockReturnValue({
|
||||
data: { ...storedSettings, values: { ...storedSettings.values, max_agentic_loops: 3 } },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
const { rerender } = await renderSettings();
|
||||
expect(screen.getByRole("spinbutton")).toHaveValue(3);
|
||||
|
||||
vi.mocked(useWebSearchInterceptionSettings).mockReturnValue({
|
||||
data: { ...storedSettings, values: { ...storedSettings.values, max_agentic_loops: 9 } },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
await act(async () => {
|
||||
rerender(<WebSearchInterceptionSettings />);
|
||||
});
|
||||
|
||||
expect(screen.getByRole("spinbutton")).toHaveValue(9);
|
||||
});
|
||||
|
||||
it("sends null rather than a number when the loop cap is cleared", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(useWebSearchInterceptionSettings).mockReturnValue({
|
||||
data: { ...storedSettings, values: { ...storedSettings.values, max_agentic_loops: 5 } },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
await renderSettings();
|
||||
|
||||
await user.clear(screen.getByRole("spinbutton"));
|
||||
await user.click(screen.getByRole("button", { name: /save settings/i }));
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockMutate.mock.calls[0][0].max_agentic_loops).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,307 @@
|
|||
"use client";
|
||||
|
||||
import { useWebSearchInterceptionSettings } from "@/app/(dashboard)/hooks/webSearchInterceptionSettings/useWebSearchInterceptionSettings";
|
||||
import { useUpdateWebSearchInterceptionSettings } from "@/app/(dashboard)/hooks/webSearchInterceptionSettings/useUpdateWebSearchInterceptionSettings";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { CircleHelp, Info, Save } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { FieldGroup } from "@/components/ui/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { Providers, provider_map } from "@/components/provider_info_helpers";
|
||||
import { fetchSearchTools } from "@/components/networking";
|
||||
|
||||
interface WebSearchInterceptionStoredValues {
|
||||
enabled?: boolean;
|
||||
enabled_providers?: string[];
|
||||
search_tool_name?: string | null;
|
||||
max_agentic_loops?: number | null;
|
||||
}
|
||||
|
||||
interface WebSearchInterceptionFieldSchema {
|
||||
properties?: {
|
||||
enabled?: { description?: string };
|
||||
enabled_providers?: { description?: string };
|
||||
search_tool_name?: { description?: string };
|
||||
max_agentic_loops?: { description?: string };
|
||||
};
|
||||
}
|
||||
|
||||
interface WebSearchInterceptionFormValues {
|
||||
enabled: boolean;
|
||||
enabled_providers: string[];
|
||||
search_tool_name: string | null;
|
||||
max_agentic_loops: number | null;
|
||||
}
|
||||
|
||||
const NO_STORED_VALUES: WebSearchInterceptionStoredValues = {};
|
||||
|
||||
const MAX_AGENTIC_LOOPS_MIN = 1;
|
||||
|
||||
const PROVIDER_OPTIONS = Object.entries(provider_map)
|
||||
.map(([enumKey, providerValue]) => ({
|
||||
label: Providers[enumKey as keyof typeof Providers] ?? providerValue,
|
||||
value: providerValue,
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
const labelWithHint = (label: string, hint: string): React.ReactNode => (
|
||||
<>
|
||||
{label}
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />} />
|
||||
<TooltipContent>{hint}</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
|
||||
const parseLoops = (raw: string, rawAsNumber: number): number | null =>
|
||||
raw === "" || Number.isNaN(rawAsNumber) ? null : rawAsNumber;
|
||||
|
||||
const toFormValues = (values: WebSearchInterceptionStoredValues): WebSearchInterceptionFormValues => ({
|
||||
enabled: values.enabled ?? false,
|
||||
enabled_providers: values.enabled_providers ?? [],
|
||||
search_tool_name: values.search_tool_name ?? null,
|
||||
max_agentic_loops: values.max_agentic_loops ?? null,
|
||||
});
|
||||
|
||||
const readSearchToolNames = (response: unknown): string[] => {
|
||||
const payload = response as { search_tools?: unknown; data?: unknown } | null;
|
||||
const tools = Array.isArray(payload?.search_tools) ? payload.search_tools : payload?.data;
|
||||
if (!Array.isArray(tools)) {
|
||||
return [];
|
||||
}
|
||||
return tools
|
||||
.map((tool: { search_tool_name?: string }) => tool?.search_tool_name)
|
||||
.filter((name: unknown): name is string => typeof name === "string" && name.length > 0);
|
||||
};
|
||||
|
||||
const useSearchToolNames = (accessToken: string) => {
|
||||
const [searchTools, setSearchTools] = useState<string[]>([]);
|
||||
const [loadingSearchTools, setLoadingSearchTools] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSearchTools = async () => {
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
setSearchTools(readSearchToolNames(await fetchSearchTools(accessToken)));
|
||||
} catch (loadError) {
|
||||
console.error("Error fetching search tools:", loadError);
|
||||
} finally {
|
||||
setLoadingSearchTools(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadSearchTools();
|
||||
}, [accessToken]);
|
||||
|
||||
return { searchTools, loadingSearchTools };
|
||||
};
|
||||
|
||||
interface WebSearchInterceptionFormProps {
|
||||
accessToken: string;
|
||||
initial: WebSearchInterceptionFormValues;
|
||||
schema: WebSearchInterceptionFieldSchema | undefined;
|
||||
}
|
||||
|
||||
function WebSearchInterceptionForm({ accessToken, initial, schema }: WebSearchInterceptionFormProps) {
|
||||
const {
|
||||
mutate: updateSettings,
|
||||
isPending: isUpdating,
|
||||
error: updateError,
|
||||
} = useUpdateWebSearchInterceptionSettings(accessToken);
|
||||
const { searchTools, loadingSearchTools } = useSearchToolNames(accessToken);
|
||||
const form = useForm<WebSearchInterceptionFormValues>({ defaultValues: initial });
|
||||
const isDirty = form.formState.isDirty;
|
||||
|
||||
const handleSave = (formValues: WebSearchInterceptionFormValues) => {
|
||||
updateSettings(formValues, {
|
||||
onSuccess: () => {
|
||||
form.reset(formValues);
|
||||
toast.success("Settings updated successfully. Changes will be applied across all pods within 10 seconds.");
|
||||
},
|
||||
onError: (saveError) => {
|
||||
toast.fromError(saveError);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{updateError && (
|
||||
<Alert variant="error" className="mb-4">
|
||||
<AlertTitle>Could not update settings</AlertTitle>
|
||||
{updateError instanceof Error && <AlertDescription>{updateError.message}</AlertDescription>}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TooltipProvider>
|
||||
<form onSubmit={(event) => event.preventDefault()} noValidate>
|
||||
<Card className="mb-4">
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
label={labelWithHint(
|
||||
"Enable Web Search Interception",
|
||||
"When enabled, web search tool calls are executed server-side through the selected search tool",
|
||||
)}
|
||||
description={schema?.properties?.enabled?.description}
|
||||
>
|
||||
{({ value, onChange, onBlur, id }) => (
|
||||
<Switch id={id} checked={value} onCheckedChange={onChange} onBlur={onBlur} disabled={isUpdating} />
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled_providers"
|
||||
label={labelWithHint(
|
||||
"Providers",
|
||||
"Which LLM providers to intercept for. Leave empty to intercept Bedrock only.",
|
||||
)}
|
||||
description={schema?.properties?.enabled_providers?.description}
|
||||
>
|
||||
{({ value, onChange, id }) => (
|
||||
<MultiSelect
|
||||
id={id}
|
||||
options={PROVIDER_OPTIONS}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
placeholder="Select providers (defaults to Bedrock)"
|
||||
allowCustomValues
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="search_tool_name"
|
||||
label={labelWithHint(
|
||||
"Search Tool",
|
||||
"Which configured search tool runs the searches. Leave empty to use the first one available.",
|
||||
)}
|
||||
description={schema?.properties?.search_tool_name?.description}
|
||||
>
|
||||
{({ value, onChange, id }) => (
|
||||
<SearchSelect
|
||||
inputId={id}
|
||||
options={searchTools.map((name) => ({ label: name, value: name }))}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
placeholder="Select a search tool (defaults to the first available)"
|
||||
disabled={isUpdating || loadingSearchTools}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="max_agentic_loops"
|
||||
label={labelWithHint(
|
||||
"Max Agentic Loops",
|
||||
"How many follow-up model calls one intercepted request may chain. Leave empty for the default of 3.",
|
||||
)}
|
||||
description={schema?.properties?.max_agentic_loops?.description}
|
||||
>
|
||||
{({ value, onChange, onBlur, id, ref }) => (
|
||||
<Input
|
||||
id={id}
|
||||
ref={ref}
|
||||
type="number"
|
||||
min={MAX_AGENTIC_LOOPS_MIN}
|
||||
value={value ?? ""}
|
||||
onChange={(event) => onChange(parseLoops(event.target.value, event.target.valueAsNumber))}
|
||||
onBlur={onBlur}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void form.handleSubmit(handleSave)()}
|
||||
disabled={!isDirty || isUpdating}
|
||||
>
|
||||
{isUpdating ? <UiLoadingSpinner className="size-4" /> : <Save />}
|
||||
Save Settings
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WebSearchInterceptionSettings() {
|
||||
const { accessToken } = useAuthorized();
|
||||
const { data, isLoading, isError, error } = useWebSearchInterceptionSettings();
|
||||
|
||||
if (!accessToken) {
|
||||
return (
|
||||
<div className="p-6 text-center text-muted-foreground">
|
||||
Please log in to configure web search interception settings.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-4 w-2/5" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert variant="error" className="mb-6">
|
||||
<AlertTitle>Could not load web search interception settings</AlertTitle>
|
||||
{error instanceof Error && <AlertDescription>{error.message}</AlertDescription>}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const values: WebSearchInterceptionStoredValues = data?.values ?? NO_STORED_VALUES;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Alert variant="info" className="mb-6">
|
||||
<Info />
|
||||
<AlertTitle>Web Search Interception</AlertTitle>
|
||||
<AlertDescription>
|
||||
Serve web search tool calls from a configured search tool instead of passing them upstream, so models without
|
||||
native web search can still answer with fresh results. Click 'Save Settings' to apply changes across
|
||||
all pods (takes effect within 10 seconds).
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<WebSearchInterceptionForm
|
||||
key={JSON.stringify(values)}
|
||||
accessToken={accessToken}
|
||||
initial={toFormValues(values)}
|
||||
schema={data?.field_schema}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3667,6 +3667,25 @@ export const updateMCPSemanticFilterSettings = async (accessToken: string, setti
|
|||
}
|
||||
};
|
||||
|
||||
export const getWebSearchInterceptionSettings = async (accessToken: string) => {
|
||||
try {
|
||||
const data = await apiClient.get(`/get/websearch_interception_settings`, { accessToken });
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Failed to get web search interception settings:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateWebSearchInterceptionSettings = async (accessToken: string, settings: Record<string, any>) => {
|
||||
try {
|
||||
return await apiClient.patch(`/update/websearch_interception_settings`, { accessToken, body: settings });
|
||||
} catch (error) {
|
||||
console.error("Failed to update web search interception settings:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const testMCPSemanticFilter = async (accessToken: string, model: string, query: string) => {
|
||||
/**
|
||||
* Test MCP semantic filter by making a responses API call
|
||||
|
|
|
|||
138
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
138
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -5430,6 +5430,28 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/get/websearch_interception_settings": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get Websearch Interception Settings
|
||||
* @description Get web search interception configuration.
|
||||
*
|
||||
* Returns the current settings plus their schema, for the Admin UI to render.
|
||||
*/
|
||||
get: operations["get_websearch_interception_settings_get_websearch_interception_settings_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/get_favicon": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -16820,6 +16842,28 @@ export interface paths {
|
|||
patch: operations["update_user_banner_update_user_banner_patch"];
|
||||
trace?: never;
|
||||
};
|
||||
"/update/websearch_interception_settings": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
/**
|
||||
* Update Websearch Interception Settings
|
||||
* @description Update web search interception settings in database.
|
||||
*
|
||||
* Settings will be picked up by all pods within approximately 10 seconds via background polling.
|
||||
*/
|
||||
patch: operations["update_websearch_interception_settings_update_websearch_interception_settings_patch"];
|
||||
trace?: never;
|
||||
};
|
||||
"/upload/logo": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -41154,6 +41198,47 @@ export interface components {
|
|||
/** Vector Store Name */
|
||||
vector_store_name?: string | null;
|
||||
};
|
||||
/**
|
||||
* WebSearchInterceptionSettings
|
||||
* @description Configuration for server-side web search interception
|
||||
*/
|
||||
WebSearchInterceptionSettings: {
|
||||
/**
|
||||
* Enabled
|
||||
* @description Serve web search tool calls from a configured search tool instead of passing them upstream
|
||||
* @default false
|
||||
*/
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Enabled Providers
|
||||
* @description LLM providers to intercept for (e.g. 'bedrock', 'vertex_ai'). Empty intercepts Bedrock only.
|
||||
*/
|
||||
enabled_providers?: string[];
|
||||
/**
|
||||
* Max Agentic Loops
|
||||
* @description How many follow-up model calls one intercepted request may chain. Empty applies the default of 3.
|
||||
*/
|
||||
max_agentic_loops?: number | null;
|
||||
/**
|
||||
* Search Tool Name
|
||||
* @description Name of the configured search tool to run searches through. Empty uses the first one available.
|
||||
*/
|
||||
search_tool_name?: string | null;
|
||||
};
|
||||
/**
|
||||
* WebSearchInterceptionSettingsResponse
|
||||
* @description Response model for web search interception settings
|
||||
*/
|
||||
WebSearchInterceptionSettingsResponse: {
|
||||
/** Field Schema */
|
||||
field_schema: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** Values */
|
||||
values: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
/** WorkerRegistryEntry */
|
||||
WorkerRegistryEntry: {
|
||||
/** Name */
|
||||
|
|
@ -49622,6 +49707,26 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
get_websearch_interception_settings_get_websearch_interception_settings_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["WebSearchInterceptionSettingsResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_favicon_get_favicon_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -62749,6 +62854,39 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
update_websearch_interception_settings_update_websearch_interception_settings_patch: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["WebSearchInterceptionSettings"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": unknown;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
upload_logo_upload_logo_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue