diff --git a/litellm/proxy/common_utils/credential_hydration.py b/litellm/proxy/common_utils/credential_hydration.py index 294169cce68..34cf6b0861c 100644 --- a/litellm/proxy/common_utils/credential_hydration.py +++ b/litellm/proxy/common_utils/credential_hydration.py @@ -20,7 +20,18 @@ from litellm.types.router import ( server_owned_wif_fields_named, server_owned_wif_fields_present, ) -from litellm.types.utils import CredentialItem +from litellm.types.utils import CredentialItem, LlmProviders + +_LITELLM_PROVIDER_IDS: Final = frozenset(provider.value for provider in LlmProviders) + + +def stored_credential_provider(credential_provider: object) -> str | None: + """The dashboard stores its display casing (``Anthropic``) on credentials it creates, so the + provider a credential names is the lowercased value when that is a litellm provider id.""" + if not isinstance(credential_provider, str): + return None + lowered: Final = credential_provider.lower() + return lowered if lowered in _LITELLM_PROVIDER_IDS else None def decrypted_or_stored(key: str, value: str) -> str: diff --git a/litellm/proxy/credential_endpoints/endpoints.py b/litellm/proxy/credential_endpoints/endpoints.py index 06192b0ec7a..20984f3a91c 100644 --- a/litellm/proxy/credential_endpoints/endpoints.py +++ b/litellm/proxy/credential_endpoints/endpoints.py @@ -29,6 +29,7 @@ from litellm.proxy.common_utils.credential_hydration import ( hydrate_named_credential, hydrate_named_credential_authoritative, named_credential_wif_fields, + stored_credential_provider, ) from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper from litellm.proxy.utils import handle_exception_on_proxy, jsonify_object @@ -301,7 +302,12 @@ async def get_credential_internal_issuer_jwks( try: credential: Final = await hydrate_named_credential_authoritative(credential_name, prisma_client) - if credential is None or credential.credential_info.get("custom_llm_provider") != "anthropic": + credential_provider: Final = ( + None + if credential is None + else stored_credential_provider(credential.credential_info.get("custom_llm_provider")) + ) + if credential is None or credential_provider != "anthropic": raise HTTPException( status_code=404, detail={ # mutable-ok: starlette json.dumps()s HTTPException.detail raw, needs a real dict diff --git a/litellm/proxy/management_endpoints/model_management_endpoints.py b/litellm/proxy/management_endpoints/model_management_endpoints.py index 53252134069..84d0b308d45 100644 --- a/litellm/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/proxy/management_endpoints/model_management_endpoints.py @@ -61,6 +61,7 @@ from litellm.proxy.common_utils.credential_hydration import ( effective_server_owned_wif_fields, hydrate_named_credential, hydrate_named_credential_authoritative, + stored_credential_provider, ) from litellm.proxy.common_utils.encrypt_decrypt_utils import ( decrypt_value_helper, @@ -1953,7 +1954,7 @@ async def _resolve_discovery_litellm_params( "error": f"Credential {data.litellm_credential_name!r} not found." }, ) - credential_provider: Final = credential.credential_info.get("custom_llm_provider") + credential_provider: Final = stored_credential_provider(credential.credential_info.get("custom_llm_provider")) if credential_provider is not None and credential_provider != data.custom_llm_provider: raise HTTPException( status_code=400, diff --git a/tests/test_litellm/proxy/credential_endpoints/test_endpoints.py b/tests/test_litellm/proxy/credential_endpoints/test_endpoints.py index 6530de0fd2e..da679fbe6e6 100644 --- a/tests/test_litellm/proxy/credential_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/credential_endpoints/test_endpoints.py @@ -361,6 +361,30 @@ class TestCredentialJwksExport: assert "JWKS_TEST_SIGNING_KEY" not in response.text assert "PRIVATE KEY" not in response.text + def test_jwks_export_accepts_the_dashboard_provider_casing(self, restore_credential_list, monkeypatch): + monkeypatch.setenv("JWKS_TEST_SIGNING_KEY", _generate_es256_pem()) + monkeypatch.setattr( + litellm, + "credential_list", + [ + CredentialItem( + credential_name="anthropic-from-modal", + credential_values={ + "anthropic_identity_source": "internal_issuer", + "anthropic_issuer_url": "https://issuer.example.com", + "anthropic_issuer_subject": "my-workload", + "anthropic_issuer_signing_key_ref": "os.environ/JWKS_TEST_SIGNING_KEY", + }, + credential_info={"custom_llm_provider": "Anthropic"}, + ) + ], + ) + + response = _get_jwks("anthropic-from-modal") + + assert response.status_code == 200, response.text + assert response.json()["keys"][0]["kty"] == "EC" + def test_jwks_export_404s_for_a_non_anthropic_credential(self, restore_credential_list, monkeypatch): monkeypatch.setattr( litellm, diff --git a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py index 87b64af93b5..2bb0b345639 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py @@ -5437,6 +5437,47 @@ class TestDiscoverProviderModels: ) assert exc_info.value.status_code == 400 + @pytest.mark.asyncio + async def test_dashboard_cased_credential_provider_is_accepted(self, monkeypatch): + """The LLM Credentials modal stores the provider under its display casing, so a saved + ``Anthropic`` credential must still resolve for an ``anthropic`` discovery request.""" + import litellm + from litellm.proxy.management_endpoints.model_management_endpoints import ( + discover_provider_models, + ) + from litellm.types.proxy.management_endpoints.model_management_endpoints import ( + ProviderModelDiscoveryRequest, + ) + from litellm.types.utils import CredentialItem + + monkeypatch.setattr( + litellm, + "credential_list", + [ + CredentialItem( + credential_name="anthropic-modal", + credential_values={"api_key": "sk-ant-x"}, + credential_info={"custom_llm_provider": "Anthropic"}, + ) + ], + ) + with ( + patch( # test-quality-ok: the proxy wiring under test is what this patches + "litellm.proxy.proxy_server.prisma_client", _prisma_without_stored_credentials() + ), + patch( # test-quality-ok: the proxy wiring under test is what this patches + "litellm.llms.anthropic.common_utils.AnthropicModelInfo.discover_models", + return_value=["anthropic/claude-disc"], + ), + ): + result = await discover_provider_models( + data=ProviderModelDiscoveryRequest( + custom_llm_provider="anthropic", litellm_credential_name="anthropic-modal" + ), + user_api_key_dict=self._admin(), + ) + assert result.models == ["anthropic/claude-disc"] + @pytest.mark.asyncio async def test_unknown_credential_name_is_404(self): from litellm.proxy.management_endpoints.model_management_endpoints import ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx index 0c1db16fc74..b6773dfddc0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx @@ -72,22 +72,24 @@ describe("ModelsAndEndpointsPage", () => { }; }); + // Provider credentials, Anthropic WIF included, have exactly one home: the LLM Credentials tab. it("renders the admin tab bar and the All Models panel by default", () => { renderPage(); - expect(screen.getByRole("tab", { name: "All Models" })).toBeInTheDocument(); - expect(screen.getByRole("tab", { name: "LLM Credentials" })).toBeInTheDocument(); - expect(screen.getByRole("tab", { name: "Health Status" })).toBeInTheDocument(); - expect(screen.getByRole("tab", { name: "Add Provider" })).toBeInTheDocument(); + expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual([ + "All Models", + "Add Model", + "Auto-Routers Beta", + "LLM Credentials", + "Pass-Through Endpoints", + "Health Status", + "Model Retry Settings", + "Model Group Alias", + "Model Access Group Budgets Beta", + "Price Data Reload", + ]); expect(screen.getByTestId("panel-all-models")).toBeInTheDocument(); }); - it("hides the write-only Add Provider tab from a view-only admin", () => { - mockUseAuthorized.mockReturnValue(VIEW_ONLY_ADMIN); - renderPage(); - expect(screen.getByRole("tab", { name: "All Models" })).toBeInTheDocument(); - expect(screen.queryByRole("tab", { name: "Add Provider" })).not.toBeInTheDocument(); - }); - it("switches tabs in-memory, mounting only the active panel", async () => { const user = userEvent.setup(); renderPage(); @@ -114,7 +116,6 @@ describe("ModelsAndEndpointsPage", () => { renderPage(); expect(screen.queryByRole("tab", { name: "LLM Credentials" })).not.toBeInTheDocument(); expect(screen.queryByRole("tab", { name: "Health Status" })).not.toBeInTheDocument(); - expect(screen.queryByRole("tab", { name: "Add Provider" })).not.toBeInTheDocument(); }); // POST /model/new 403s a proxy_admin_viewer, so the form's tab must not render for one. diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx index 0af494c0ce1..34c9d87004e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx @@ -17,7 +17,6 @@ import { useModelDashboardData } from "@/app/(dashboard)/models-and-endpoints/us import AllModelsPanel from "@/app/(dashboard)/models-and-endpoints/panels/AllModelsPanel"; import AutoRoutersTabPanel from "@/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel"; import AddModelPanel from "@/app/(dashboard)/models-and-endpoints/panels/AddModelPanel"; -import AddProviderPanel from "@/app/(dashboard)/models-and-endpoints/panels/add-provider/AddProviderPanel"; import LlmCredentialsPanel from "@/app/(dashboard)/models-and-endpoints/panels/LlmCredentialsPanel"; import PassThroughPanel from "@/app/(dashboard)/models-and-endpoints/panels/PassThroughPanel"; import HealthStatusPanel from "@/app/(dashboard)/models-and-endpoints/panels/HealthStatusPanel"; @@ -30,7 +29,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; type ModelTabSlug = | "add" - | "add-provider" | "auto-routers" | "llm-credentials" | "pass-through" @@ -44,7 +42,6 @@ const BASE_TAB_KEY = "all-models"; const TAB_LABELS: Record = { add: "Add Model", - "add-provider": "Add Provider", "auto-routers": "Auto-Routers", "llm-credentials": "LLM Credentials", "pass-through": "Pass-Through Endpoints", @@ -63,8 +60,6 @@ const renderPanel = (key: string) => { return ; case "add": return ; - case "add-provider": - return ; case "llm-credentials": return ; case "pass-through": @@ -111,9 +106,6 @@ export default function ModelsAndEndpointsPage() { "", ...(canCreate ? (["add"] as const) : []), ...(isAdmin || canCreate ? (["auto-routers"] as const) : []), - // effectiveSessionRole reports proxy_admin_viewer as "Admin", so isAdmin alone would show - // a viewer this write-only wizard; only the raw-role isViewOnly separates them. - ...(isAdmin && !isViewOnly ? (["add-provider"] as const) : []), ...(isAdmin ? ([ "llm-credentials", @@ -126,7 +118,7 @@ export default function ModelsAndEndpointsPage() { ] as const) : []), ], - [canCreate, isAdmin, isViewOnly], + [canCreate, isAdmin], ); const allModelsLabel = isAdmin ? "All Models" : "Your Models"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/AddProviderPanel.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/AddProviderPanel.integration.test.tsx deleted file mode 100644 index bd0bdc45cd3..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/AddProviderPanel.integration.test.tsx +++ /dev/null @@ -1,621 +0,0 @@ -import { - chooseSelectOption, - fireEvent, - renderWithProviders, - screen, - waitFor, - within, -} from "../../../../../../tests/test-utils"; -import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import AddProviderPanel from "./AddProviderPanel"; - -const discoverProviderModelsCall = vi.fn(); -const credentialCreateCall = vi.fn(); -const credentialUpdateCall = vi.fn(); -const createProviderModelCall = vi.fn(); -const listAllModelsCall = vi.fn(); -const getCallbacksCall = vi.fn(); -const setCallbacksCall = vi.fn(); -const getCredentialJwksCall = vi.fn(); -const mockAuthorized = vi.fn(); - -vi.mock("@/components/networking", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - discoverProviderModelsCall: (...args: unknown[]) => discoverProviderModelsCall(...args), - credentialCreateCall: (...args: unknown[]) => credentialCreateCall(...args), - credentialUpdateCall: (...args: unknown[]) => credentialUpdateCall(...args), - createProviderModelCall: (...args: unknown[]) => createProviderModelCall(...args), - listAllModelsCall: (...args: unknown[]) => listAllModelsCall(...args), - getCallbacksCall: (...args: unknown[]) => getCallbacksCall(...args), - setCallbacksCall: (...args: unknown[]) => setCallbacksCall(...args), - getCredentialJwksCall: (...args: unknown[]) => getCredentialJwksCall(...args), - }; -}); - -vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => mockAuthorized() })); - -vi.mock("@/app/(dashboard)/hooks/credentials/useCredentials", () => ({ - useCredentials: () => ({ data: { credentials: [] } }), -})); - -vi.mock("@/app/(dashboard)/hooks/providers/useProviderFields", () => ({ - useProviderFields: () => ({ - data: [ - { - provider: "Anthropic", - provider_display_name: "Anthropic", - litellm_provider: "anthropic", - default_model_placeholder: "claude-3-opus", - credential_fields: [ - { key: "api_base", label: "Upstream API Base", field_type: "text" }, - { key: "api_key", label: "API Key", field_type: "password" }, - ], - credential_variants: { - selector_label: "Authentication method", - default_variant: "api_key", - field_definitions: [ - { key: "api_base", label: "Upstream API Base", field_type: "text" }, - { key: "api_key", label: "API Key", field_type: "password" }, - { - key: "anthropic_federation_rule_id", - label: "Federation Rule ID", - field_type: "text", - required: true, - tooltip: "Can be left blank and filled in once the JWKS is registered.", - }, - { key: "anthropic_organization_id", label: "Organization ID", field_type: "text", required: true }, - { key: "anthropic_service_account_id", label: "Service Account ID", field_type: "text", required: false }, - { key: "anthropic_workspace_id", label: "Workspace ID", field_type: "text", required: false }, - { key: "anthropic_issuer_url", label: "Issuer URL", field_type: "text", required: true }, - { key: "anthropic_issuer_subject", label: "Issuer Subject", field_type: "text", required: true }, - { - key: "anthropic_issuer_signing_key_ref", - label: "Signing Key Reference", - field_type: "text", - required: true, - }, - ], - variants: [ - { id: "api_key", label: "API Key", field_keys: ["api_base", "api_key"], fixed_values: {} }, - { - id: "wif_internal_issuer", - label: "Workload Identity Federation (LiteLLM-signed)", - field_keys: [ - "anthropic_issuer_url", - "anthropic_issuer_subject", - "anthropic_issuer_signing_key_ref", - "anthropic_organization_id", - "anthropic_federation_rule_id", - "anthropic_service_account_id", - "anthropic_workspace_id", - ], - optional_field_keys: ["anthropic_organization_id", "anthropic_federation_rule_id"], - fixed_values: { anthropic_identity_source: "internal_issuer" }, - }, - ], - }, - }, - { - provider: "OpenAI", - provider_display_name: "OpenAI", - litellm_provider: "openai", - default_model_placeholder: "gpt-4o", - credential_fields: [{ key: "api_key", label: "API Key", field_type: "password" }], - }, - ], - isLoading: false, - error: null, - }), -})); - -const PROXY_ADMIN = { accessToken: "test-access-token" }; - -const setup = async () => { - const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); - renderWithProviders(); - await screen.findByLabelText("Provider"); - return { user }; -}; - -const chooseProvider = async (user: ReturnType, name: string) => { - await user.click(screen.getByLabelText("Provider")); - await user.click(await screen.findByText(name)); -}; - -const rowFor = (upstreamId: string) => - within(screen.getByRole("row", { name: (accessibleName) => accessibleName.startsWith(`${upstreamId} `) })); - -const INTERNAL_ISSUER_CREATE_VALUES = { - anthropic_issuer_url: "https://proxy.example.com", - anthropic_issuer_subject: "litellm-proxy", - anthropic_issuer_signing_key_ref: "os.environ/SIGNING_KEY", - anthropic_identity_source: "internal_issuer", -}; - -const saveInternalIssuerCredential = async (user: ReturnType, name: string) => { - await chooseProvider(user, "Anthropic"); - await user.type(screen.getByLabelText("Credential name"), name); - await user.click(screen.getByRole("button", { name: /Next/ })); - await chooseSelectOption( - user, - await screen.findByRole("combobox", { name: "Authentication method" }), - "Workload Identity Federation (LiteLLM-signed)", - ); - fireEvent.change(await screen.findByLabelText("Issuer URL"), { target: { value: "https://proxy.example.com" } }); - fireEvent.change(screen.getByLabelText("Issuer Subject"), { target: { value: "litellm-proxy" } }); - fireEvent.change(screen.getByLabelText("Signing Key Reference"), { target: { value: "os.environ/SIGNING_KEY" } }); - await user.click(screen.getByRole("button", { name: "Save credential" })); - await screen.findByText("Register this JWKS with Anthropic"); -}; - -const fillFederationIds = (ids: Record) => { - for (const [label, value] of Object.entries(ids)) { - fireEvent.change(screen.getByLabelText(label), { target: { value } }); - } -}; - -describe("AddProviderPanel", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockAuthorized.mockReturnValue(PROXY_ADMIN); - credentialCreateCall.mockResolvedValue({}); - credentialUpdateCall.mockResolvedValue({}); - listAllModelsCall.mockResolvedValue({ data: [] }); - createProviderModelCall.mockResolvedValue({ model_id: "new-id" }); - getCallbacksCall.mockResolvedValue({ router_settings: {} }); - setCallbacksCall.mockResolvedValue({}); - getCredentialJwksCall.mockResolvedValue({ keys: [{ kid: "kid-1", kty: "RSA", n: "n", e: "AQAB" }] }); - }); - - it("walks provider -> credential -> discover -> review -> create, with blocked and aliases wired correctly", async () => { - discoverProviderModelsCall.mockResolvedValue({ models: ["claude-3-opus", "claude-3-haiku"] }); - const { user } = await setup(); - - await chooseProvider(user, "Anthropic"); - await user.type(screen.getByLabelText("Credential name"), "anthropic-prod"); - await user.click(screen.getByRole("button", { name: /Next/ })); - - await user.type(await screen.findByLabelText("API Key"), "sk-ant-test"); - await user.click(screen.getByRole("button", { name: "Save credential" })); - - expect(await screen.findByText("claude-3-opus")).toBeInTheDocument(); - expect(credentialCreateCall).toHaveBeenCalledWith("test-access-token", { - credential_name: "anthropic-prod", - credential_values: { api_key: "sk-ant-test" }, - credential_info: { custom_llm_provider: "anthropic" }, - }); - expect(discoverProviderModelsCall).toHaveBeenCalledWith("test-access-token", { - custom_llm_provider: "anthropic", - litellm_credential_name: "anthropic-prod", - }); - - // Disable the first discovered row. - await user.click(rowFor("claude-3-opus").getByRole("switch")); - - // Add an alternate name to the second discovered row. - const haikuAltNames = rowFor("claude-3-haiku").getByRole("combobox"); - await user.type(haikuAltNames, "gpt-4o-mini"); - await user.click(await screen.findByText('Create "gpt-4o-mini"')); - - // Add a manual (hidden) model. - await user.type(screen.getByPlaceholderText("upstream model id"), "claude-hidden"); - await user.click(screen.getByRole("button", { name: "Add" })); - - await user.click(screen.getByRole("button", { name: "Create 3 models" })); - - await waitFor(() => expect(createProviderModelCall).toHaveBeenCalledTimes(3)); - const disabledOpusCreation = { - model_name: "claude-3-opus", - litellm_params: { model: "anthropic/claude-3-opus", litellm_credential_name: "anthropic-prod" }, - model_info: {}, - blocked: true, - }; - const renamedHaikuCreation = { - model_name: "claude-3-haiku", - litellm_params: { model: "anthropic/claude-3-haiku", litellm_credential_name: "anthropic-prod" }, - model_info: {}, - blocked: false, - }; - const manualHiddenCreation = { - model_name: "claude-hidden", - litellm_params: { model: "anthropic/claude-hidden", litellm_credential_name: "anthropic-prod" }, - model_info: {}, - blocked: false, - }; - expect(createProviderModelCall).toHaveBeenCalledWith("test-access-token", disabledOpusCreation); - expect(createProviderModelCall).toHaveBeenCalledWith("test-access-token", renamedHaikuCreation); - expect(createProviderModelCall).toHaveBeenCalledWith("test-access-token", manualHiddenCreation); - - await waitFor(() => - expect(setCallbacksCall).toHaveBeenCalledWith("test-access-token", { - router_settings: { model_group_alias: { "gpt-4o-mini": "claude-3-haiku" } }, - }), - ); - - expect(await screen.findByText(/claude-3-opus: created/)).toBeInTheDocument(); - expect(screen.getByText(/claude-3-haiku: created/)).toBeInTheDocument(); - expect(screen.getByText(/claude-hidden: created/)).toBeInTheDocument(); - }); - - it("shows a sanitized discovery error with a working retry", async () => { - discoverProviderModelsCall.mockRejectedValueOnce(new Error("upstream auth failed")); - discoverProviderModelsCall.mockResolvedValueOnce({ models: ["claude-3-opus"] }); - const { user } = await setup(); - - await chooseProvider(user, "Anthropic"); - await user.type(screen.getByLabelText("Credential name"), "anthropic-prod"); - await user.click(screen.getByRole("button", { name: /Next/ })); - await user.type(await screen.findByLabelText("API Key"), "sk-ant-test"); - await user.click(screen.getByRole("button", { name: "Save credential" })); - - expect(await screen.findByText("Discovery failed")).toBeInTheDocument(); - expect(screen.getByText("upstream auth failed")).toBeInTheDocument(); - - await user.click(screen.getByRole("button", { name: "Retry" })); - - expect(await screen.findByText("claude-3-opus")).toBeInTheDocument(); - expect(discoverProviderModelsCall).toHaveBeenCalledTimes(2); - }); - - it("skips a row already created under this credential on a re-run", async () => { - discoverProviderModelsCall.mockResolvedValue({ models: ["claude-3-opus"] }); - listAllModelsCall.mockResolvedValue({ - data: [ - { - model_name: "claude-3-opus", - litellm_params: { model: "anthropic/claude-3-opus", litellm_credential_name: "anthropic-prod" }, - model_info: { id: "existing-id" }, - }, - ], - }); - const { user } = await setup(); - - await chooseProvider(user, "Anthropic"); - await user.type(screen.getByLabelText("Credential name"), "anthropic-prod"); - await user.click(screen.getByRole("button", { name: /Next/ })); - await user.type(await screen.findByLabelText("API Key"), "sk-ant-test"); - await user.click(screen.getByRole("button", { name: "Save credential" })); - - await screen.findByText("claude-3-opus"); - await user.click(screen.getByRole("button", { name: "Create 1 model" })); - - expect(await screen.findByText(/claude-3-opus: skipped/)).toBeInTheDocument(); - expect(createProviderModelCall).not.toHaveBeenCalled(); - }); - - it("does not persist an alias for a row whose model creation failed", async () => { - discoverProviderModelsCall.mockResolvedValue({ models: ["claude-3-opus", "claude-3-haiku"] }); - createProviderModelCall.mockImplementation(async (_token: string, payload: { model_name: string }) => { - if (payload.model_name === "claude-3-haiku") { - throw new Error("upstream rejected"); - } - return { model_id: "new-id" }; - }); - const { user } = await setup(); - - await chooseProvider(user, "Anthropic"); - await user.type(screen.getByLabelText("Credential name"), "anthropic-prod"); - await user.click(screen.getByRole("button", { name: /Next/ })); - await user.type(await screen.findByLabelText("API Key"), "sk-ant-test"); - await user.click(screen.getByRole("button", { name: "Save credential" })); - - expect(await screen.findByText("claude-3-opus")).toBeInTheDocument(); - - const opusAltNames = rowFor("claude-3-opus").getByRole("combobox"); - await user.type(opusAltNames, "gpt-4o"); - await user.click(await screen.findByText('Create "gpt-4o"')); - - const haikuAltNames = rowFor("claude-3-haiku").getByRole("combobox"); - await user.type(haikuAltNames, "gpt-4o-mini"); - await user.click(await screen.findByText('Create "gpt-4o-mini"')); - - await user.click(screen.getByRole("button", { name: "Create 2 models" })); - - expect(await screen.findByText(/claude-3-opus: created/)).toBeInTheDocument(); - expect(await screen.findByText(/claude-3-haiku: failed/)).toBeInTheDocument(); - - await waitFor(() => - expect(setCallbacksCall).toHaveBeenCalledWith("test-access-token", { - router_settings: { model_group_alias: { "gpt-4o": "claude-3-opus" } }, - }), - ); - }); - - it("saves a LiteLLM-signed credential before any Anthropic id exists, then collects them all on the JWKS step", async () => { - discoverProviderModelsCall.mockResolvedValue({ models: ["claude-3-opus"] }); - const { user } = await setup(); - - await saveInternalIssuerCredential(user, "anthropic-wif"); - - // Every id comes off the Anthropic Console only once the JWKS below is registered, and the - // JWKS only exists once the credential is saved, so saving must not demand any of them first. - expect(credentialCreateCall).toHaveBeenCalledWith("test-access-token", { - credential_name: "anthropic-wif", - credential_values: INTERNAL_ISSUER_CREATE_VALUES, - credential_info: { custom_llm_provider: "anthropic" }, - }); - expect(getCredentialJwksCall).toHaveBeenCalledWith("test-access-token", "anthropic-wif"); - - expect(screen.getByText("Still needed before discovery: Organization ID, Federation Rule ID.")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /Next/ })).toBeDisabled(); - - fillFederationIds({ "Organization ID": "org-1" }); - expect(screen.getByText("Still needed before discovery: Federation Rule ID.")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /Next/ })).toBeDisabled(); - - fillFederationIds({ "Federation Rule ID": " fdrl_abc ", "Service Account ID": "svac_1" }); - expect(screen.queryByText(/Still needed before discovery/)).not.toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: /Next/ })); - - await waitFor(() => - expect(credentialUpdateCall).toHaveBeenCalledWith("test-access-token", "anthropic-wif", { - credential_name: "anthropic-wif", - credential_values: { - anthropic_organization_id: "org-1", - anthropic_federation_rule_id: "fdrl_abc", - anthropic_service_account_id: "svac_1", - }, - credential_info: { custom_llm_provider: "anthropic" }, - }), - ); - expect(credentialUpdateCall).toHaveBeenCalledTimes(1); - expect(await screen.findByText("claude-3-opus")).toBeInTheDocument(); - expect(discoverProviderModelsCall).toHaveBeenCalledTimes(1); - }); - - it("keeps the ids from the JWKS step across Back, a credential re-save and the return trip", async () => { - discoverProviderModelsCall.mockRejectedValueOnce(new Error("Authentication failed")); - discoverProviderModelsCall.mockResolvedValueOnce({ models: ["claude-3-opus"] }); - const { user } = await setup(); - - await saveInternalIssuerCredential(user, "anthropic-wif"); - fillFederationIds({ "Organization ID": "org-1", "Federation Rule ID": "fdrl_abc", "Service Account ID": "svac_1" }); - await user.click(screen.getByRole("button", { name: /Next/ })); - expect(await screen.findByText("Discovery failed")).toBeInTheDocument(); - - await user.click(screen.getByRole("button", { name: /Back/ })); - expect(await screen.findByText("Register this JWKS with Anthropic")).toBeInTheDocument(); - expect(screen.getByLabelText("Federation Rule ID")).toHaveValue("fdrl_abc"); - expect(screen.getByLabelText("Service Account ID")).toHaveValue("svac_1"); - - await user.click(screen.getByRole("button", { name: /Back/ })); - expect(await screen.findByLabelText("Organization ID")).toHaveValue("org-1"); - expect(screen.getByLabelText("Federation Rule ID")).toHaveValue("fdrl_abc"); - expect(screen.getByLabelText("Service Account ID")).toHaveValue("svac_1"); - expect(screen.getByLabelText("Workspace ID")).toHaveValue(""); - - credentialUpdateCall.mockClear(); - await user.click(screen.getByRole("button", { name: "Save changes" })); - expect(await screen.findByText("Register this JWKS with Anthropic")).toBeInTheDocument(); - expect(credentialUpdateCall).toHaveBeenCalledWith("test-access-token", "anthropic-wif", { - credential_name: "anthropic-wif", - credential_values: { - ...INTERNAL_ISSUER_CREATE_VALUES, - anthropic_organization_id: "org-1", - anthropic_federation_rule_id: "fdrl_abc", - anthropic_service_account_id: "svac_1", - }, - credential_info: { custom_llm_provider: "anthropic" }, - }); - expect(screen.getByLabelText("Federation Rule ID")).toHaveValue("fdrl_abc"); - expect(screen.queryByText(/Still needed before discovery/)).not.toBeInTheDocument(); - - credentialUpdateCall.mockClear(); - await user.click(screen.getByRole("button", { name: /Next/ })); - expect(await screen.findByText("claude-3-opus")).toBeInTheDocument(); - expect(credentialUpdateCall).not.toHaveBeenCalled(); - }); - - it("lets a failed discovery be fixed by adding the Workspace ID on the JWKS step, PATCHing only that id", async () => { - discoverProviderModelsCall.mockRejectedValueOnce(new Error("Model discovery failed: HTTP 401")); - discoverProviderModelsCall.mockResolvedValueOnce({ models: ["claude-3-opus"] }); - const { user } = await setup(); - - await saveInternalIssuerCredential(user, "anthropic-wif"); - fillFederationIds({ "Organization ID": "org-1", "Federation Rule ID": "fdrl_abc" }); - await user.click(screen.getByRole("button", { name: /Next/ })); - expect(await screen.findByText("Model discovery failed: HTTP 401")).toBeInTheDocument(); - - await user.click(screen.getByRole("button", { name: /Back/ })); - fillFederationIds({ "Workspace ID": "wrkspc_1" }); - credentialUpdateCall.mockClear(); - await user.click(screen.getByRole("button", { name: /Next/ })); - - await waitFor(() => - expect(credentialUpdateCall).toHaveBeenCalledWith("test-access-token", "anthropic-wif", { - credential_name: "anthropic-wif", - credential_values: { anthropic_workspace_id: "wrkspc_1" }, - credential_info: { custom_llm_provider: "anthropic" }, - }), - ); - expect(await screen.findByText("claude-3-opus")).toBeInTheDocument(); - expect(discoverProviderModelsCall).toHaveBeenCalledTimes(2); - }); - - it("deletes an id cleared on the JWKS step instead of leaving the saved value in place", async () => { - discoverProviderModelsCall.mockResolvedValue({ models: ["claude-3-opus"] }); - const { user } = await setup(); - - await chooseProvider(user, "Anthropic"); - await user.type(screen.getByLabelText("Credential name"), "anthropic-wif"); - await user.click(screen.getByRole("button", { name: /Next/ })); - await chooseSelectOption( - user, - await screen.findByRole("combobox", { name: "Authentication method" }), - "Workload Identity Federation (LiteLLM-signed)", - ); - fireEvent.change(await screen.findByLabelText("Issuer URL"), { target: { value: "https://proxy.example.com" } }); - fireEvent.change(screen.getByLabelText("Issuer Subject"), { target: { value: "litellm-proxy" } }); - fireEvent.change(screen.getByLabelText("Signing Key Reference"), { target: { value: "os.environ/SIGNING_KEY" } }); - fireEvent.change(screen.getByLabelText("Organization ID"), { target: { value: "org-1" } }); - fireEvent.change(screen.getByLabelText("Federation Rule ID"), { target: { value: "fdrl_abc" } }); - fireEvent.change(screen.getByLabelText("Workspace ID"), { target: { value: "wrkspc_stale" } }); - await user.click(screen.getByRole("button", { name: "Save credential" })); - await screen.findByText("Register this JWKS with Anthropic"); - expect(screen.getByLabelText("Workspace ID")).toHaveValue("wrkspc_stale"); - - fillFederationIds({ "Workspace ID": "" }); - await user.click(screen.getByRole("button", { name: /Next/ })); - - const workspaceDeletion = { - credential_name: "anthropic-wif", - credential_values: {}, - credential_info: { custom_llm_provider: "anthropic" }, - credential_values_to_delete: ["anthropic_workspace_id"], - }; - await waitFor(() => - expect(credentialUpdateCall).toHaveBeenCalledWith("test-access-token", "anthropic-wif", workspaceDeletion), - ); - expect(await screen.findByText("claude-3-opus")).toBeInTheDocument(); - }); - it("refuses to create when the deployment lookup fails, rather than duplicating saved rows", async () => { - discoverProviderModelsCall.mockResolvedValue({ models: ["claude-3-opus"] }); - listAllModelsCall.mockRejectedValue(new Error("proxy unreachable")); - const { user } = await setup(); - - await chooseProvider(user, "Anthropic"); - await user.type(screen.getByLabelText("Credential name"), "anthropic-prod"); - await user.click(screen.getByRole("button", { name: /Next/ })); - await user.type(await screen.findByLabelText("API Key"), "sk-ant-test"); - await user.click(screen.getByRole("button", { name: "Save credential" })); - - await screen.findByText("claude-3-opus"); - await user.click(screen.getByRole("button", { name: "Create 1 model" })); - - expect(await screen.findByText(/could duplicate ones already saved/i)).toBeInTheDocument(); - expect(createProviderModelCall).not.toHaveBeenCalled(); - }); - - it("creates rather than PATCHes after the credential name changes following a save", async () => { - discoverProviderModelsCall.mockResolvedValue({ models: ["claude-3-opus"] }); - const { user } = await setup(); - - await chooseProvider(user, "Anthropic"); - await user.type(screen.getByLabelText("Credential name"), "anthropic-first"); - await user.click(screen.getByRole("button", { name: /Next/ })); - - await chooseSelectOption( - user, - await screen.findByRole("combobox", { name: "Authentication method" }), - "Workload Identity Federation (LiteLLM-signed)", - ); - fireEvent.change(await screen.findByLabelText("Issuer URL"), { target: { value: "https://proxy.example.com" } }); - fireEvent.change(screen.getByLabelText("Issuer Subject"), { target: { value: "litellm-proxy" } }); - fireEvent.change(screen.getByLabelText("Signing Key Reference"), { target: { value: "os.environ/SIGNING_KEY" } }); - await user.click(screen.getByRole("button", { name: "Save credential" })); - - // The JWKS step is the one place the wizard pauses after a save, so it is the route back to - // the name field. Renaming there must create the new credential, never PATCH the old name. - await screen.findByText("Register this JWKS with Anthropic"); - await user.click(screen.getByRole("button", { name: /Back/ })); - await user.click(await screen.findByRole("button", { name: /Back/ })); - - const nameInput = await screen.findByLabelText("Credential name"); - await user.clear(nameInput); - await user.type(nameInput, "anthropic-second"); - await user.click(screen.getByRole("button", { name: /Next/ })); - - credentialCreateCall.mockClear(); - credentialUpdateCall.mockClear(); - await user.click(await screen.findByRole("button", { name: /Save credential/ })); - - await waitFor(() => - expect(credentialCreateCall).toHaveBeenCalledWith( - "test-access-token", - expect.objectContaining({ credential_name: "anthropic-second" }), - ), - ); - expect(credentialUpdateCall).not.toHaveBeenCalled(); - }); - - it("creates rather than PATCHes when the provider changes under the same credential name", async () => { - discoverProviderModelsCall.mockResolvedValue({ models: ["claude-3-opus"] }); - const { user } = await setup(); - - await chooseProvider(user, "Anthropic"); - await user.type(screen.getByLabelText("Credential name"), "shared-name"); - await user.click(screen.getByRole("button", { name: /Next/ })); - - await chooseSelectOption( - user, - await screen.findByRole("combobox", { name: "Authentication method" }), - "Workload Identity Federation (LiteLLM-signed)", - ); - fireEvent.change(await screen.findByLabelText("Issuer URL"), { target: { value: "https://proxy.example.com" } }); - fireEvent.change(screen.getByLabelText("Issuer Subject"), { target: { value: "litellm-proxy" } }); - fireEvent.change(screen.getByLabelText("Signing Key Reference"), { target: { value: "os.environ/SIGNING_KEY" } }); - await user.click(screen.getByRole("button", { name: "Save credential" })); - - // A credential is (name, provider). Keeping the name but switching provider must not PATCH the - // Anthropic credential into an OpenAI one. - await screen.findByText("Register this JWKS with Anthropic"); - await user.click(screen.getByRole("button", { name: /Back/ })); - await user.click(await screen.findByRole("button", { name: /Back/ })); - await chooseProvider(user, "OpenAI"); - await user.click(screen.getByRole("button", { name: /Next/ })); - - credentialCreateCall.mockClear(); - credentialUpdateCall.mockClear(); - await user.type(await screen.findByLabelText("API Key"), "sk-openai-test"); - await user.click(screen.getByRole("button", { name: /Save credential/ })); - - await waitFor(() => - expect(credentialCreateCall).toHaveBeenCalledWith( - "test-access-token", - expect.objectContaining({ - credential_name: "shared-name", - credential_info: { custom_llm_provider: "openai" }, - }), - ), - ); - expect(credentialUpdateCall).not.toHaveBeenCalled(); - }); - - it("drops the previous provider's credential values when the provider changes", async () => { - discoverProviderModelsCall.mockResolvedValue({ models: ["gpt-4o"] }); - const { user } = await setup(); - - await chooseProvider(user, "Anthropic"); - await user.type(screen.getByLabelText("Credential name"), "switching"); - await user.click(screen.getByRole("button", { name: /Next/ })); - await user.type(await screen.findByLabelText("API Key"), "sk-ant-secret"); - await user.click(screen.getByRole("button", { name: /Back/ })); - - await chooseProvider(user, "OpenAI"); - await user.click(screen.getByRole("button", { name: /Next/ })); - - const apiKeyField = await screen.findByLabelText("API Key"); - expect(apiKeyField).toHaveValue(""); - await user.type(apiKeyField, "sk-openai-test"); - await user.click(screen.getByRole("button", { name: /Save credential/ })); - - await waitFor(() => - expect(credentialCreateCall).toHaveBeenCalledWith( - "test-access-token", - expect.objectContaining({ - credential_values: expect.objectContaining({ api_key: "sk-openai-test" }), - credential_info: { custom_llm_provider: "openai" }, - }), - ), - ); - }); - - it("blocks creation while any model name is blank", async () => { - discoverProviderModelsCall.mockResolvedValue({ models: ["claude-3-opus"] }); - const { user } = await setup(); - - await chooseProvider(user, "Anthropic"); - await user.type(screen.getByLabelText("Credential name"), "anthropic-prod"); - await user.click(screen.getByRole("button", { name: /Next/ })); - await user.type(await screen.findByLabelText("API Key"), "sk-ant-test"); - await user.click(screen.getByRole("button", { name: "Save credential" })); - - const nameCell = await screen.findByDisplayValue("claude-3-opus"); - await user.clear(nameCell); - - expect(screen.getByRole("button", { name: /Create 1 model/ })).toBeDisabled(); - }); -}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/AddProviderPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/AddProviderPanel.tsx deleted file mode 100644 index 602c3593d83..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/AddProviderPanel.tsx +++ /dev/null @@ -1,388 +0,0 @@ -"use client"; - -import React from "react"; -import { useForm, useWatch, FormProvider } from "react-hook-form"; -import { useQueryClient } from "@tanstack/react-query"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { toast } from "@/lib/toast"; -import { useProviderFields } from "@/app/(dashboard)/hooks/providers/useProviderFields"; -import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials"; -import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -import { - MountedFormProvider, - projectMountedValues, - useMountRegistry, - type MountedFormValues, -} from "@/components/common_components/MountedFormField"; -import { computeCredentialValuesToDelete } from "@/components/model_add/credential_form_helpers"; -import ProviderSpecificFields from "@/components/add_model/provider_specific_fields"; -import { ProviderLogo } from "@/components/molecules/models/ProviderLogo"; -import { Providers } from "@/components/provider_info_helpers"; -import { - credentialCreateCall, - credentialUpdateCall, - discoverProviderModelsCall, - getCredentialJwksCall, - listAllModelsCall, - getCallbacksCall, - setCallbacksCall, - createProviderModelCall, - type AnthropicJwks, - type DeploymentInfoRow, - type ProviderCreateInfo, -} from "@/components/networking"; -import type { SearchSelectOption } from "@/components/shared/SearchSelect"; -import { ArrowLeft } from "lucide-react"; -import { extractProxyErrorMessage } from "@/lib/http/client"; -import { - aliasAdditionsFromRows, - buildDiscoveredRows, - buildModelCreationPayload, - mergeModelGroupAliases, - rowsPendingCreation, - type CreationResult, - type DiscoveredModelRow, - type ModelGroupAliasMap, -} from "./wizardLogic"; -import { federationIdsUpdate, readFederationIds, withFederationIds } from "./anthropicFederation"; -import ReviewModelsStep from "./ReviewModelsStep"; -import { DiscoverStep, JwksStep, ProviderStep, ResultsStep } from "./WizardSteps"; - -type WizardStep = "provider" | "credential" | "jwks" | "discover" | "review" | "creating" | "done"; - -const STEP_ORDER: readonly WizardStep[] = ["provider", "credential", "jwks", "discover", "review", "creating", "done"]; - -const STEP_LABELS: Record = { - provider: "Provider", - credential: "Authentication", - jwks: "Register issuer", - discover: "Discover models", - review: "Review models", - creating: "Creating", - done: "Done", -}; - -const ANTHROPIC_INTERNAL_ISSUER_DISCRIMINATOR = "internal_issuer"; - -const StepIndicator: React.FC<{ step: WizardStep; skipJwks: boolean }> = ({ step, skipJwks }) => { - const visibleSteps = STEP_ORDER.filter((s) => s !== "creating" && (!skipJwks || s !== "jwks")); - const currentIndex = visibleSteps.indexOf(step === "creating" ? "done" : step); - return ( -
- {visibleSteps.map((s, index) => ( - - {index > 0 && {"->"}} - - {STEP_LABELS[s]} - - - ))} -
- ); -}; - -export default function AddProviderPanel() { - const { accessToken } = useAuthorized(); - const queryClient = useQueryClient(); - const { data: providerMetadata } = useProviderFields(); - const { data: credentialsResponse } = useCredentials(); - - const [step, setStep] = React.useState("provider"); - const [selectedProvider, setSelectedProvider] = React.useState(null); - const [credentialName, setCredentialName] = React.useState(""); - const [savedCredential, setSavedCredential] = React.useState<{ name: string; provider: string } | null>(null); - const [savedValues, setSavedValues] = React.useState>({}); - const [jwks, setJwks] = React.useState(null); - const [jwksError, setJwksError] = React.useState(null); - const [discoveryError, setDiscoveryError] = React.useState(null); - const [createError, setCreateError] = React.useState(null); - const [isDiscovering, setIsDiscovering] = React.useState(false); - const [rows, setRows] = React.useState([]); - const [isCreating, setIsCreating] = React.useState(false); - const [creationResults, setCreationResults] = React.useState([]); - const [aliasCollisions, setAliasCollisions] = React.useState([]); - - const form = useForm({ mode: "onChange" }); - const registry = useMountRegistry(); - const federationIds = readFederationIds(useWatch({ control: form.control })); - - const providerOptions: SearchSelectOption[] = React.useMemo( - () => - (providerMetadata ?? []) - .slice() - .sort((a, b) => a.provider_display_name.localeCompare(b.provider_display_name)) - .map((p) => ({ - label: p.provider_display_name, - value: p.provider_display_name, - icon: , - })), - [providerMetadata], - ); - - const selectedProviderInfo: ProviderCreateInfo | undefined = React.useMemo( - () => providerMetadata?.find((p) => p.provider_display_name === selectedProvider), - [providerMetadata, selectedProvider], - ); - const litellmProvider = selectedProviderInfo?.litellm_provider ?? ""; - - // A credential is identified by name AND provider: switching provider under the same name must - // create a new one, not PATCH the previous provider's credential into a different provider. - const credentialSaved = - savedCredential !== null && savedCredential.name === credentialName && savedCredential.provider === litellmProvider; - - const nameCollision = - credentialName.length > 0 && - !credentialSaved && - (credentialsResponse?.credentials ?? []).some((c) => c.credential_name === credentialName); - - const goTo = (next: WizardStep) => setStep(next); - - const saveCredential = async () => { - if (!accessToken || !selectedProvider) { - return; - } - const isValid = await form.trigger(registry.mountedNames() as string[]); - if (!isValid) { - return; - } - const values = projectMountedValues(registry, form.getValues) as Record; - const nonEmptyValues = Object.fromEntries( - Object.entries(values).filter(([, v]) => v !== "" && v !== undefined && v !== null), - ); - try { - if (!credentialSaved) { - await credentialCreateCall(accessToken, { - credential_name: credentialName, - credential_values: nonEmptyValues, - credential_info: { custom_llm_provider: litellmProvider }, - }); - } else { - const credentialValuesToDelete = computeCredentialValuesToDelete(savedValues, values); - const updatePayload = { - credential_name: credentialName, - credential_values: nonEmptyValues, - credential_info: { custom_llm_provider: litellmProvider }, - ...(credentialValuesToDelete.length > 0 ? { credential_values_to_delete: credentialValuesToDelete } : {}), - }; - await credentialUpdateCall(accessToken, credentialName, updatePayload); - } - setSavedValues(values); - setSavedCredential({ name: credentialName, provider: litellmProvider }); - queryClient.invalidateQueries({ queryKey: ["credentials"] }); - toast.success(`Credential "${credentialName}" saved`); - if (values.anthropic_identity_source === ANTHROPIC_INTERNAL_ISSUER_DISCRIMINATOR) { - goTo("jwks"); - void loadJwks(); - } else { - goTo("discover"); - void runDiscovery(); - } - } catch (error) { - toast.fromError(`Failed to save credential: ${extractProxyErrorMessage(error)}`); - } - }; - - const loadJwks = async () => { - if (!accessToken) return; - setJwksError(null); - try { - const result = await getCredentialJwksCall(accessToken, credentialName); - setJwks(result); - } catch (error) { - setJwksError(extractProxyErrorMessage(error)); - } - }; - - const confirmFederationIds = async () => { - if (!accessToken) return; - const ids = readFederationIds(form.getValues()); - const update = federationIdsUpdate(savedValues, ids); - if (update !== null) { - const updatePayload = { - credential_name: credentialName, - credential_values: update.credential_values, - credential_info: { custom_llm_provider: litellmProvider }, - ...(update.credential_values_to_delete.length > 0 - ? { credential_values_to_delete: [...update.credential_values_to_delete] } - : {}), - }; - try { - await credentialUpdateCall(accessToken, credentialName, updatePayload); - setSavedValues((prev) => withFederationIds(prev, ids)); - } catch (error) { - toast.fromError(`Failed to save the federation ids: ${extractProxyErrorMessage(error)}`); - return; - } - } - goTo("discover"); - void runDiscovery(); - }; - - const runDiscovery = async () => { - if (!accessToken) return; - setIsDiscovering(true); - setDiscoveryError(null); - try { - const result = await discoverProviderModelsCall(accessToken, { - custom_llm_provider: litellmProvider, - litellm_credential_name: credentialName, - }); - setRows(buildDiscoveredRows(result.models)); - goTo("review"); - } catch (error) { - setDiscoveryError(extractProxyErrorMessage(error)); - } finally { - setIsDiscovering(false); - } - }; - - const createModels = async () => { - if (!accessToken) return; - setIsCreating(true); - setCreationResults([]); - setAliasCollisions([]); - setCreateError(null); - goTo("creating"); - - let existing: DeploymentInfoRow[]; - try { - existing = (await listAllModelsCall(accessToken)).data; - } catch (error) { - setIsCreating(false); - setCreateError( - `Could not read the existing deployments, so creating now could duplicate ones already saved. ${extractProxyErrorMessage(error)}`, - ); - goTo("review"); - return; - } - const pending = rowsPendingCreation(rows, litellmProvider, credentialName, existing); - const pendingIds = new Set(pending.map((r) => r.id)); - - const results: CreationResult[] = []; - for (const row of rows) { - if (!pendingIds.has(row.id)) { - results.push({ row, status: "skipped", detail: "already created" }); - continue; - } - try { - await createProviderModelCall(accessToken, buildModelCreationPayload(litellmProvider, credentialName, row)); - results.push({ row, status: "created" }); - } catch (error) { - results.push({ row, status: "failed", detail: extractProxyErrorMessage(error) }); - } - } - setCreationResults(results); - - const failedRowIds = new Set(results.filter((r) => r.status === "failed").map((r) => r.row.id)); - const additions = aliasAdditionsFromRows(rows.filter((row) => !failedRowIds.has(row.id))); - if (additions.length > 0) { - try { - const config = await getCallbacksCall(accessToken, "", ""); - const existingAliasMap: ModelGroupAliasMap = config?.router_settings?.model_group_alias ?? {}; - const { merged, collisions } = mergeModelGroupAliases(existingAliasMap, additions); - if (collisions.length > 0) { - setAliasCollisions([...collisions]); - } - await setCallbacksCall(accessToken, { router_settings: { model_group_alias: merged } }); - } catch (error) { - toast.fromError(`Failed to save alternate names: ${extractProxyErrorMessage(error)}`); - } - } - - queryClient.invalidateQueries({ queryKey: ["models", "list"] }); - setIsCreating(false); - goTo("done"); - }; - - const isInternalIssuer = savedValues.anthropic_identity_source === ANTHROPIC_INTERNAL_ISSUER_DISCRIMINATOR; - - return ( -
-

Add Provider

- - - {step === "provider" && ( - { - if (provider !== selectedProvider) form.reset(); - setSelectedProvider(provider); - }} - credentialName={credentialName} - onCredentialNameChange={setCredentialName} - nameCollision={Boolean(nameCollision)} - onNext={() => goTo("credential")} - /> - )} - - {step === "credential" && selectedProvider && ( - - - - -
{ - e.preventDefault(); - void saveCredential(); - }} - > - -
- - -
- -
-
-
-
- )} - - {step === "jwks" && ( - form.setValue(key, value, { shouldDirty: true })} - onBack={() => goTo("credential")} - onNext={() => void confirmFederationIds()} - /> - )} - - {step === "discover" && ( - goTo(isInternalIssuer ? "jwks" : "credential")} - onRetry={() => void runDiscovery()} - /> - )} - - {step === "review" && ( - { - goTo("discover"); - void runDiscovery(); - }} - onCreateModels={() => void createModels()} - /> - )} - - {(step === "creating" || step === "done") && ( - - )} -
- ); -} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/ReviewModelsStep.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/ReviewModelsStep.tsx deleted file mode 100644 index 20326a48736..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/ReviewModelsStep.tsx +++ /dev/null @@ -1,134 +0,0 @@ -"use client"; - -import React from "react"; -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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { MultiSelect } from "@/components/shared/MultiSelect"; -import { ArrowLeft, Plus, X } from "lucide-react"; -import { buildManualRow, type DiscoveredModelRow } from "./wizardLogic"; - -interface ReviewModelsStepProps { - rows: DiscoveredModelRow[]; - setRows: React.Dispatch>; - createError: string | null; - onBack: () => void; - onCreateModels: () => void; -} - -const ReviewModelsStep: React.FC = ({ rows, setRows, createError, onBack, onCreateModels }) => { - const [manualId, setManualId] = React.useState(""); - - const hasBlankName = rows.some((row) => row.modelName.trim() === ""); - - const updateRow = (id: string, patch: Partial) => - setRows((current) => current.map((row) => (row.id === id ? { ...row, ...patch } : row))); - - const removeRow = (id: string) => setRows((current) => current.filter((row) => row.id !== id)); - - const addManualRow = () => { - const trimmed = manualId.trim(); - if (!trimmed) return; - setRows((current) => [...current, buildManualRow(trimmed)]); - setManualId(""); - }; - - return ( - - - {rows.length === 0 ? ( -

No models discovered. Add one manually below.

- ) : ( -
- - - - Upstream ID - Enabled - Model name - Alternate names - - - - - {rows.map((row) => ( - - {row.upstreamId} - - updateRow(row.id, { enabled: checked })} - aria-label={`Enable ${row.upstreamId}`} - /> - - - updateRow(row.id, { modelName: e.target.value })} - aria-label={`Model name for ${row.upstreamId}`} - /> - - - updateRow(row.id, { alternateNames: value })} - options={[]} - allowCustomValues - placeholder="Add alternate names" - emptyText="Type to add an alternate name" - /> - - - {row.manual && ( - - )} - - - ))} - -
-
- )} - -
-
- - setManualId(e.target.value)} - placeholder="upstream model id" - /> -
- -
- - {createError &&

{createError}

} - -
- - -
-
-
- ); -}; - -export default ReviewModelsStep; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/WizardSteps.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/WizardSteps.tsx deleted file mode 100644 index 21faaf29115..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/WizardSteps.tsx +++ /dev/null @@ -1,226 +0,0 @@ -"use client"; - -import React from "react"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"; -import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect"; -import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert"; -import CopyButton from "@/components/shared/CopyButton"; -import { AlertTriangle, ArrowLeft, ArrowRight, Loader2 } from "lucide-react"; -import { Providers } from "@/components/provider_info_helpers"; -import type { AnthropicJwks } from "@/components/networking"; -import type { CreationResult } from "./wizardLogic"; -import { - ANTHROPIC_FEDERATION_FIELDS, - missingFederationFields, - type AnthropicFederationIds, - type AnthropicFederationKey, -} from "./anthropicFederation"; - -const CREATION_RESULT_CLASS_NAME: Record = { - failed: "text-destructive", - skipped: "text-muted-foreground", - created: "text-success", -}; - -interface ProviderStepProps { - providerOptions: SearchSelectOption[]; - selectedProvider: Providers | null; - onSelectProvider: (provider: Providers) => void; - credentialName: string; - onCredentialNameChange: (name: string) => void; - nameCollision: boolean; - onNext: () => void; -} - -export const ProviderStep: React.FC = ({ - providerOptions, - selectedProvider, - onSelectProvider, - credentialName, - onCredentialNameChange, - nameCollision, - onNext, -}) => ( - - - - Provider - onSelectProvider(value as Providers)} - /> - - - Credential name - onCredentialNameChange(e.target.value)} - placeholder="e.g. anthropic-prod" - /> - {nameCollision &&

A credential with this name already exists.

} -
-
- -
-
-
-); - -interface JwksStepProps { - jwks: AnthropicJwks | null; - jwksError: string | null; - federationIds: AnthropicFederationIds; - onFederationIdChange: (key: AnthropicFederationKey, value: string) => void; - onBack: () => void; - onNext: () => void; -} - -export const JwksStep: React.FC = ({ - jwks, - jwksError, - federationIds, - onFederationIdChange, - onBack, - onNext, -}) => { - const missing = missingFederationFields(federationIds); - return ( - - - - Register this JWKS with Anthropic - - In the Claude Console, open Settings {">"} Workload identity, click Connect workload, choose Custom OIDC and - paste this JWKS as the inline key set, using the Issuer URL and Subject from the previous step. Once the - rule and its service account exist, copy their ids below. Everything entered here is saved to this - credential before discovery runs. - - - {jwksError && ( - - - Could not load JWKS - {jwksError} - - )} - {jwks && ( -
- -
{JSON.stringify(jwks, null, 2)}
-
- )} - {ANTHROPIC_FEDERATION_FIELDS.map((field) => ( - - {field.label} - onFederationIdChange(field.key, e.target.value)} - /> - {field.hint} - - ))} - {missing.length > 0 && ( -

Still needed before discovery: {missing.join(", ")}.

- )} -
- - -
-
-
- ); -}; - -interface DiscoverStepProps { - isDiscovering: boolean; - discoveryError: string | null; - onBack: () => void; - onRetry: () => void; -} - -export const DiscoverStep: React.FC = ({ isDiscovering, discoveryError, onBack, onRetry }) => ( - - - {isDiscovering && ( -

- Discovering models... -

- )} - {discoveryError && ( - - - Discovery failed - {discoveryError} - - )} -
- - {discoveryError && ( - - )} -
-
-
-); - -interface ResultsStepProps { - isCreating: boolean; - isDone: boolean; - creationResults: CreationResult[]; - aliasCollisions: string[]; -} - -export const ResultsStep: React.FC = ({ isCreating, isDone, creationResults, aliasCollisions }) => ( - - - {isCreating && ( -

- Creating models... -

- )} - {isDone && ( - <> -
    - {creationResults.map((result) => ( -
  • - - {result.row.modelName}: {result.status} - {result.detail ? ` (${result.detail})` : ""} - -
  • - ))} -
- {aliasCollisions.length > 0 && ( - - - Some alternate names were not saved - - These alias names already exist and were left unchanged: {aliasCollisions.join(", ")} - - - )} - - )} -
-
-); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/anthropicFederation.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/anthropicFederation.test.ts deleted file mode 100644 index 60633487eb5..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/anthropicFederation.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - federationIdsUpdate, - missingFederationFields, - readFederationIds, - withFederationIds, -} from "./anthropicFederation"; - -const ALL_IDS = { - anthropic_organization_id: "org-1", - anthropic_federation_rule_id: "fdrl_1", - anthropic_service_account_id: "svac_1", - anthropic_workspace_id: "wrkspc_1", -}; - -const ids = (overrides: Partial> = {}) => readFederationIds({ ...ALL_IDS, ...overrides }); - -describe("readFederationIds", () => { - it("reads the four ids, trimming pasted whitespace and treating anything else as blank", () => { - const formValues = { - anthropic_organization_id: " org-1 ", - anthropic_federation_rule_id: undefined, - anthropic_service_account_id: 42, - api_key: "unrelated", - }; - const onlyOrganization = { - anthropic_organization_id: "org-1", - anthropic_federation_rule_id: "", - anthropic_service_account_id: "", - anthropic_workspace_id: "", - }; - expect(readFederationIds(formValues)).toEqual(onlyOrganization); - }); -}); - -describe("missingFederationFields", () => { - it("names only the organization and federation rule when everything is blank", () => { - expect(missingFederationFields(readFederationIds({}))).toEqual(["Organization ID", "Federation Rule ID"]); - }); - - it("does not require the service account or workspace ids", () => { - expect(missingFederationFields(ids({ anthropic_service_account_id: "", anthropic_workspace_id: "" }))).toEqual([]); - }); - - it("treats whitespace as blank", () => { - expect(missingFederationFields(ids({ anthropic_federation_rule_id: " " }))).toEqual(["Federation Rule ID"]); - }); -}); - -describe("federationIdsUpdate", () => { - it("is null when the entered ids match the saved credential", () => { - expect( - federationIdsUpdate( - { anthropic_organization_id: "org-1", anthropic_issuer_url: "https://proxy.example.com" }, - ids({ anthropic_federation_rule_id: "", anthropic_service_account_id: "", anthropic_workspace_id: "" }), - ), - ).toBeNull(); - }); - - it("sends only the ids that changed and keeps the untouched saved ones out of the payload", () => { - expect( - federationIdsUpdate( - { anthropic_organization_id: "org-1" }, - ids({ anthropic_service_account_id: "", anthropic_workspace_id: "" }), - ), - ).toEqual({ credential_values: { anthropic_federation_rule_id: "fdrl_1" }, credential_values_to_delete: [] }); - }); - - it("deletes an id the operator cleared after it was saved instead of merging over it", () => { - expect( - federationIdsUpdate( - { anthropic_organization_id: "org-1", anthropic_federation_rule_id: "fdrl_1", anthropic_workspace_id: "w" }, - ids({ anthropic_service_account_id: "", anthropic_workspace_id: "" }), - ), - ).toEqual({ credential_values: {}, credential_values_to_delete: ["anthropic_workspace_id"] }); - }); - - it("ignores whitespace-only differences against the saved value", () => { - expect(federationIdsUpdate({ anthropic_organization_id: " org-1" }, ids())).toEqual({ - credential_values: { - anthropic_federation_rule_id: "fdrl_1", - anthropic_service_account_id: "svac_1", - anthropic_workspace_id: "wrkspc_1", - }, - credential_values_to_delete: [], - }); - }); -}); - -describe("withFederationIds", () => { - it("replaces the saved ids with the entered ones, dropping cleared ids and keeping other fields", () => { - const saved = { anthropic_organization_id: "old", anthropic_workspace_id: "w", anthropic_issuer_url: "u" }; - const merged = { - anthropic_issuer_url: "u", - anthropic_organization_id: "org-1", - anthropic_federation_rule_id: "fdrl_1", - anthropic_service_account_id: "svac_1", - }; - expect(withFederationIds(saved, ids({ anthropic_workspace_id: "" }))).toEqual(merged); - }); -}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/anthropicFederation.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/anthropicFederation.ts deleted file mode 100644 index a31d0ebbaba..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/anthropicFederation.ts +++ /dev/null @@ -1,82 +0,0 @@ -export const ANTHROPIC_FEDERATION_FIELDS = [ - { - key: "anthropic_organization_id", - label: "Organization ID", - required: true, - hint: "The UUID under Settings > Organization in the Claude Console.", - }, - { - key: "anthropic_federation_rule_id", - label: "Federation Rule ID", - required: true, - hint: "The fdrl_... id on the rule's detail page under Settings > Workload identity.", - }, - { - key: "anthropic_service_account_id", - label: "Service Account ID", - required: false, - hint: - "The svac_... id of the service account the rule targets, on the same page. Anthropic lists it as required, " + - "though the exchange works without it when the rule targets a single service account, so fill it in whenever " + - "you have it.", - }, - { - key: "anthropic_workspace_id", - label: "Workspace ID", - required: false, - hint: - "Required only when the rule is enabled in more than one workspace (for example All workspaces); Anthropic " + - "then rejects the exchange with a 401 logged as workspace_id_required. Use the wrkspc_... id under " + - "Settings > Workspaces, or the literal default. Leave blank when the rule is enabled in a single workspace.", - }, -] as const; - -export type AnthropicFederationKey = (typeof ANTHROPIC_FEDERATION_FIELDS)[number]["key"]; - -export type AnthropicFederationIds = Readonly>; - -export interface FederationIdsUpdate { - readonly credential_values: Readonly>>; - readonly credential_values_to_delete: readonly AnthropicFederationKey[]; -} - -const trimmed = (value: unknown): string => (typeof value === "string" ? value.trim() : ""); - -export const readFederationIds = (values: Readonly>): AnthropicFederationIds => - Object.fromEntries(ANTHROPIC_FEDERATION_FIELDS.map((field) => [field.key, trimmed(values[field.key])])) as Record< - AnthropicFederationKey, - string - >; - -export const missingFederationFields = (ids: AnthropicFederationIds): readonly string[] => - ANTHROPIC_FEDERATION_FIELDS.filter((field) => field.required && ids[field.key] === "").map((field) => field.label); - -/** - * The PATCH that brings a saved credential in line with the ids entered on the Register issuer - * step, or null when every id already matches what was saved. Ids the operator cleared since the - * save are deleted rather than merged over, the same rule the Authentication step applies. - */ -export const federationIdsUpdate = ( - saved: Readonly>, - ids: AnthropicFederationIds, -): FederationIdsUpdate | null => { - const changed = ANTHROPIC_FEDERATION_FIELDS.map((field) => field.key).filter( - (key) => ids[key] !== trimmed(saved[key]), - ); - if (changed.length === 0) { - return null; - } - return { - credential_values: Object.fromEntries(changed.filter((key) => ids[key] !== "").map((key) => [key, ids[key]])), - credential_values_to_delete: changed.filter((key) => ids[key] === ""), - }; -}; - -export const withFederationIds = ( - saved: Readonly>, - ids: AnthropicFederationIds, -): Readonly> => - Object.fromEntries([ - ...Object.entries(saved).filter(([key]) => !(key in ids)), - ...Object.entries(ids).filter(([, value]) => value !== ""), - ]); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/wizardLogic.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/wizardLogic.test.ts deleted file mode 100644 index 545e725b2c4..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/wizardLogic.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { DeploymentInfoRow } from "@/components/networking"; -import { - aliasAdditionsFromRows, - buildDiscoveredRows, - buildManualRow, - buildModelCreationPayload, - litellmModelForUpstreamId, - mergeModelGroupAliases, - rowsPendingCreation, - type DiscoveredModelRow, -} from "./wizardLogic"; - -describe("litellmModelForUpstreamId", () => { - it("prefixes a bare upstream id with the provider", () => { - expect(litellmModelForUpstreamId("anthropic", "claude-3-opus")).toBe("anthropic/claude-3-opus"); - }); - - it("does not double-prefix an id that already carries the provider prefix", () => { - expect(litellmModelForUpstreamId("anthropic", "anthropic/claude-3-opus")).toBe("anthropic/claude-3-opus"); - }); -}); - -describe("buildDiscoveredRows", () => { - it("defaults every row to enabled, with model_name equal to the discovered id", () => { - const rows = buildDiscoveredRows(["claude-3-opus", "claude-3-haiku"]); - expect(rows).toHaveLength(2); - const defaultedOpusRow = { - upstreamId: "claude-3-opus", - modelName: "claude-3-opus", - enabled: true, - alternateNames: [], - manual: false, - }; - expect(rows[0]).toMatchObject(defaultedOpusRow); - }); - - it("gives every row a unique id even for duplicate upstream ids", () => { - const rows = buildDiscoveredRows(["same-id", "same-id"]); - expect(rows[0].id).not.toBe(rows[1].id); - }); -}); - -describe("buildManualRow", () => { - it("marks the row manual and enabled by default", () => { - const row = buildManualRow("hidden-model"); - const manualEnabledRow = { upstreamId: "hidden-model", modelName: "hidden-model", enabled: true, manual: true }; - expect(row).toMatchObject(manualEnabledRow); - }); -}); - -describe("buildModelCreationPayload", () => { - const baseRow: DiscoveredModelRow = { - id: "row-1", - upstreamId: "claude-3-opus", - modelName: "my-claude", - enabled: true, - alternateNames: [], - manual: false, - }; - - it("maps enabled=true to blocked=false", () => { - const payload = buildModelCreationPayload("anthropic", "my-cred", baseRow); - const unblockedCreation = { - model_name: "my-claude", - litellm_params: { model: "anthropic/claude-3-opus", litellm_credential_name: "my-cred" }, - model_info: {}, - blocked: false, - }; - expect(payload).toEqual(unblockedCreation); - }); - - it("maps enabled=false to blocked=true", () => { - const payload = buildModelCreationPayload("anthropic", "my-cred", { ...baseRow, enabled: false }); - expect(payload.blocked).toBe(true); - }); -}); - -describe("rowsPendingCreation", () => { - const rows: DiscoveredModelRow[] = [ - { id: "1", upstreamId: "claude-3-opus", modelName: "opus", enabled: true, alternateNames: [], manual: false }, - { id: "2", upstreamId: "claude-3-haiku", modelName: "haiku", enabled: true, alternateNames: [], manual: false }, - ]; - - it("returns every row when nothing exists yet", () => { - expect(rowsPendingCreation(rows, "anthropic", "my-cred", [])).toHaveLength(2); - }); - - it("skips a row already created under the same credential", () => { - const existing: DeploymentInfoRow[] = [ - { - model_name: "opus", - litellm_params: { model: "anthropic/claude-3-opus", litellm_credential_name: "my-cred" }, - model_info: { id: "abc" }, - }, - ]; - const pending = rowsPendingCreation(rows, "anthropic", "my-cred", existing); - expect(pending.map((r) => r.upstreamId)).toEqual(["claude-3-haiku"]); - }); - - it("does not skip a same-model row that belongs to a different credential", () => { - const existing: DeploymentInfoRow[] = [ - { - model_name: "opus", - litellm_params: { model: "anthropic/claude-3-opus", litellm_credential_name: "someone-elses-cred" }, - model_info: { id: "abc" }, - }, - ]; - expect(rowsPendingCreation(rows, "anthropic", "my-cred", existing)).toHaveLength(2); - }); -}); - -describe("mergeModelGroupAliases", () => { - it("adds new aliases to an empty map", () => { - const { merged, collisions } = mergeModelGroupAliases({}, [{ alias: "gpt-4o", targetModelGroup: "opus" }]); - expect(merged).toEqual({ "gpt-4o": "opus" }); - expect(collisions).toEqual([]); - }); - - it("preserves an existing object-valued entry untouched", () => { - const existing = { "hidden-alias": { model: "some-model", hidden: true } }; - const { merged } = mergeModelGroupAliases(existing, [{ alias: "new-alias", targetModelGroup: "opus" }]); - expect(merged["hidden-alias"]).toEqual({ model: "some-model", hidden: true }); - expect(merged["new-alias"]).toBe("opus"); - }); - - it("rejects a collision with an existing alias rather than overwriting it", () => { - const existing = { "gpt-4o": "some-other-model" }; - const { merged, collisions } = mergeModelGroupAliases(existing, [{ alias: "gpt-4o", targetModelGroup: "opus" }]); - expect(merged["gpt-4o"]).toBe("some-other-model"); - expect(collisions).toEqual(["gpt-4o"]); - }); - - it("rejects a collision between two additions in the same batch", () => { - const { merged, collisions } = mergeModelGroupAliases({}, [ - { alias: "dup", targetModelGroup: "opus" }, - { alias: "dup", targetModelGroup: "haiku" }, - ]); - expect(merged.dup).toBe("opus"); - expect(collisions).toEqual(["dup"]); - }); -}); - -describe("aliasAdditionsFromRows", () => { - it("flattens each row's alternate names against its model_name", () => { - const rows: DiscoveredModelRow[] = [ - { id: "1", upstreamId: "opus", modelName: "my-opus", enabled: true, alternateNames: ["gpt-4o"], manual: false }, - { id: "2", upstreamId: "haiku", modelName: "my-haiku", enabled: true, alternateNames: [], manual: false }, - ]; - expect(aliasAdditionsFromRows(rows)).toEqual([{ alias: "gpt-4o", targetModelGroup: "my-opus" }]); - }); -}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/wizardLogic.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/wizardLogic.ts deleted file mode 100644 index 5622934b941..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/wizardLogic.ts +++ /dev/null @@ -1,129 +0,0 @@ -import type { DeploymentInfoRow } from "@/components/networking"; - -export interface DiscoveredModelRow { - id: string; - upstreamId: string; - modelName: string; - enabled: boolean; - alternateNames: string[]; - manual: boolean; -} - -/** - * The litellm_params.model this upstream id resolves to: provider-prefixed, but never - * double-prefixed if the discovered/typed id already carries the provider's own prefix. - */ -export const litellmModelForUpstreamId = (litellmProvider: string, upstreamId: string): string => - upstreamId.startsWith(`${litellmProvider}/`) ? upstreamId : `${litellmProvider}/${upstreamId}`; - -export const buildDiscoveredRows = (upstreamIds: readonly string[]): DiscoveredModelRow[] => - upstreamIds.map((upstreamId, index) => ({ - id: `discovered-${index}-${upstreamId}`, - upstreamId, - modelName: upstreamId, - enabled: true, - alternateNames: [], - manual: false, - })); - -export const buildManualRow = (upstreamId: string): DiscoveredModelRow => ({ - id: `manual-${upstreamId}-${Math.random().toString(36).slice(2)}`, - upstreamId, - modelName: upstreamId, - enabled: true, - alternateNames: [], - manual: true, -}); - -export interface CreationResult { - row: DiscoveredModelRow; - status: "created" | "skipped" | "failed"; - detail?: string; -} - -export interface ModelCreationPayload { - model_name: string; - litellm_params: { model: string; litellm_credential_name: string }; - model_info: Record; - blocked: boolean; -} - -/** blocked is the inverse of the review table's enabled toggle -- a disabled row is still - * created, just paused, so a later enable is a one-click unblock instead of re-discovery. */ -export const buildModelCreationPayload = ( - litellmProvider: string, - credentialName: string, - row: DiscoveredModelRow, -): ModelCreationPayload => ({ - model_name: row.modelName, - litellm_params: { - model: litellmModelForUpstreamId(litellmProvider, row.upstreamId), - litellm_credential_name: credentialName, - }, - model_info: {}, - blocked: !row.enabled, -}); - -const deploymentKey = (credentialName: string, litellmModel: string): string => `${credentialName}::${litellmModel}`; - -/** - * Rows not yet created for this credential, matched against every existing deployment by - * (litellm_credential_name, litellm_params.model) -- the partial-failure-recovery key: re-running - * the wizard after some rows already landed must not attempt to recreate them. - */ -export const rowsPendingCreation = ( - rows: readonly DiscoveredModelRow[], - litellmProvider: string, - credentialName: string, - existingDeployments: readonly DeploymentInfoRow[], -): DiscoveredModelRow[] => { - const existingKeys = new Set( - existingDeployments - .filter((deployment) => deployment.litellm_params.litellm_credential_name === credentialName) - .map((deployment) => deploymentKey(credentialName, deployment.litellm_params.model ?? "")), - ); - return rows.filter( - (row) => - !existingKeys.has(deploymentKey(credentialName, litellmModelForUpstreamId(litellmProvider, row.upstreamId))), - ); -}; - -export type ModelGroupAliasValue = string | { model: string; hidden?: boolean }; -export type ModelGroupAliasMap = Record; - -export interface AliasAddition { - alias: string; - targetModelGroup: string; -} - -export interface AliasMergeResult { - merged: ModelGroupAliasMap; - collisions: readonly string[]; -} - -/** - * Merges new alias entries into the existing model_group_alias map for a single - * /config/update write. Never touches an existing key's value (object-valued {model, hidden} - * entries survive untouched) and never overwrites a name collision -- a colliding alias is - * reported back rather than silently dropped or silently replacing what was there. - */ -export const mergeModelGroupAliases = ( - existing: ModelGroupAliasMap, - additions: readonly AliasAddition[], -): AliasMergeResult => { - const collisions: string[] = []; - const added: ModelGroupAliasMap = {}; - for (const { alias, targetModelGroup } of additions) { - if (alias in existing || alias in added) { - collisions.push(alias); - continue; - } - added[alias] = targetModelGroup; - } - return { merged: { ...existing, ...added }, collisions }; -}; - -/** Flattens the review table's per-row alternate names into the alias additions - * mergeModelGroupAliases expects, skipping rows the operator removed/left disabled-but-empty. */ -export const aliasAdditionsFromRows = (rows: readonly DiscoveredModelRow[]): AliasAddition[] => - rows.flatMap((row) => row.alternateNames.map((alias) => ({ alias, targetModelGroup: row.modelName }))); diff --git a/ui/litellm-dashboard/src/components/model_add/CredentialModal.test.tsx b/ui/litellm-dashboard/src/components/model_add/CredentialModal.test.tsx index 080c5e86d61..e29a9721abd 100644 --- a/ui/litellm-dashboard/src/components/model_add/CredentialModal.test.tsx +++ b/ui/litellm-dashboard/src/components/model_add/CredentialModal.test.tsx @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest"; import { Providers } from "../provider_info_helpers"; import { CredentialItem } from "../networking"; import CredentialModal from "./CredentialModal"; +import { chooseSelectOption } from "../../../tests/test-utils"; vi.mock("../networking", async () => { const actual = await vi.importActual("../networking"); @@ -51,6 +52,9 @@ vi.mock("../networking", async () => { { key: "api_key", label: "Anthropic API Key", field_type: "password" }, { key: "anthropic_federation_rule_id", label: "Federation Rule ID", field_type: "text", required: true }, { key: "anthropic_organization_id", label: "Organization ID", field_type: "text", required: true }, + { key: "anthropic_issuer_url", label: "Issuer URL", field_type: "text", required: true }, + { key: "anthropic_issuer_subject", label: "Subject", field_type: "text", required: true }, + { key: "anthropic_issuer_signing_key_ref", label: "Signing Key Reference", field_type: "text" }, { key: "anthropic_identity_token", label: "Identity Token Reference", @@ -66,6 +70,18 @@ vi.mock("../networking", async () => { field_keys: ["anthropic_federation_rule_id", "anthropic_organization_id", "anthropic_identity_token"], fixed_values: {}, }, + { + id: "wif_internal_issuer", + label: "Workload Identity Federation (LiteLLM-signed)", + field_keys: [ + "anthropic_issuer_url", + "anthropic_issuer_subject", + "anthropic_issuer_signing_key_ref", + "anthropic_organization_id", + "anthropic_federation_rule_id", + ], + fixed_values: { anthropic_identity_source: "internal_issuer" }, + }, ], }, }, @@ -97,7 +113,15 @@ const mockCredential: CredentialItem = { const renderModal = (props: Partial> = {}) => render( - + , ); @@ -151,6 +175,130 @@ describe("CredentialModal", () => { }); }); + describe("footer", () => { + it("puts Cancel on the left and Test Connection beside the submit button on the right", () => { + renderModal({ mode: "add" }); + + const cancel = screen.getByRole("button", { name: "Cancel" }); + const testConnection = screen.getByRole("button", { name: "Test Connection" }); + const submit = screen.getByRole("button", { name: "Add Credential" }); + expect(cancel.compareDocumentPosition(testConnection) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(testConnection.compareDocumentPosition(submit) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it("closes on Cancel without submitting the form", () => { + const onCancel = vi.fn(); + const onSubmit = vi.fn(); + renderModal({ mode: "add", onCancel, onSubmit }); + + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onSubmit).not.toHaveBeenCalled(); + }); + }); + + describe("test connection", () => { + it("tests an entered API key inline before the credential is saved", async () => { + const testConnection = vi.fn().mockResolvedValue({ models: ["gpt-5.5", "gpt-5.5-mini"] }); + renderModal({ mode: "add", testConnection }); + + fireEvent.change(await screen.findByLabelText("OpenAI API Key"), { target: { value: "sk-test" } }); + const button = screen.getByRole("button", { name: "Test Connection" }); + await waitFor(() => expect(button).toBeEnabled()); + fireEvent.click(button); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Connection succeeded. 2 models available: gpt-5.5, gpt-5.5-mini.", + ); + expect(testConnection).toHaveBeenCalledWith({ custom_llm_provider: "openai", api_key: "sk-test" }); + }); + + it("keeps workload identity values out of an inline test and points at saving first", async () => { + const testConnection = vi.fn(); + const user = userEvent.setup(); + renderModal({ mode: "add", testConnection }); + + const providerInput = screen.getByRole("combobox", { name: "Provider:" }); + await user.click(providerInput); + fireEvent.change(providerInput, { target: { value: "Anthropic" } }); + await user.click(await screen.findByRole("option", { name: (_, option) => option.textContent === "Anthropic" })); + await chooseSelectOption( + user, + await screen.findByRole("combobox", { name: "Authentication method" }), + "Workload Identity Federation (LiteLLM-signed)", + ); + fireEvent.change(await screen.findByLabelText("Issuer URL"), { + target: { value: "https://litellm.example.com" }, + }); + + expect(await screen.findByText(/^Add the credential first, then test it from Edit/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Test Connection" })).toBeDisabled(); + expect(testConnection).not.toHaveBeenCalled(); + }); + + it("tests a saved credential by name and surfaces the proxy error", async () => { + const testConnection = vi.fn().mockRejectedValue(new Error("Model discovery failed: invalid x-api-key")); + renderModal({ mode: "edit", existingCredential: mockCredential, testConnection }); + + await screen.findByLabelText("OpenAI API Key"); + fireEvent.click(screen.getByRole("button", { name: "Test Connection" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("Model discovery failed: invalid x-api-key"); + expect(testConnection).toHaveBeenCalledWith({ + custom_llm_provider: "openai", + litellm_credential_name: "test-credential", + }); + }); + + it("waits for unsaved edits to be saved before testing a saved credential", async () => { + const testConnection = vi.fn(); + renderModal({ mode: "edit", existingCredential: mockCredential, testConnection }); + + fireEvent.change(await screen.findByLabelText("OpenAI API Key"), { target: { value: "sk-changed" } }); + + expect( + await screen.findByText("Update the credential first. Test Connection checks the saved values."), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Test Connection" })).toBeDisabled(); + expect(testConnection).not.toHaveBeenCalled(); + }); + }); + + describe("LiteLLM-signed JWKS", () => { + const signedCredential: CredentialItem = { + credential_name: "anthropic-signed", + credential_values: { + anthropic_identity_source: "internal_issuer", + anthropic_issuer_url: "https://litellm.example.com", + anthropic_issuer_subject: "litellm-proxy", + anthropic_issuer_signing_key_ref: "os.environ/ISSUER_SIGNING_KEY_PEM", + anthropic_organization_id: "", + anthropic_federation_rule_id: "", + }, + credential_info: { custom_llm_provider: Providers.Anthropic }, + }; + + it("shows the saved credential's public JWKS with a copy button", async () => { + const loadJwks = vi.fn().mockResolvedValue({ keys: [{ kty: "RSA", kid: "k-2026", n: "abc", e: "AQAB" }] }); + renderModal({ mode: "edit", existingCredential: signedCredential, loadJwks }); + + expect(await screen.findByText(/"kid": "k-2026"/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Copy JWKS" })).toBeInTheDocument(); + expect(loadJwks).toHaveBeenCalledWith("anthropic-signed"); + }); + + it("does not request a JWKS for a credential that is not LiteLLM-signed", async () => { + const loadJwks = vi.fn(); + renderModal({ mode: "edit", existingCredential: mockCredential, loadJwks }); + + await screen.findByLabelText("OpenAI API Key"); + + expect(screen.queryByText("Public JWKS")).not.toBeInTheDocument(); + expect(loadJwks).not.toHaveBeenCalled(); + }); + }); + describe("credential_values_to_delete", () => { const anthropicWifCredential: CredentialItem = { credential_name: "anthropic-wif-cred", diff --git a/ui/litellm-dashboard/src/components/model_add/CredentialModal.tsx b/ui/litellm-dashboard/src/components/model_add/CredentialModal.tsx index b9a976c222b..de36d77a817 100644 --- a/ui/litellm-dashboard/src/components/model_add/CredentialModal.tsx +++ b/ui/litellm-dashboard/src/components/model_add/CredentialModal.tsx @@ -1,12 +1,20 @@ -import { Input } from "@/components/ui/input"; -import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect"; -import { SimpleTooltip } from "@/components/ui/tooltip"; +import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { FieldDescription, FieldLegend, FieldSet } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { SimpleTooltip } from "@/components/ui/tooltip"; +import { Logo } from "@/components/molecules/logo/Logo"; +import CopyButton from "@/components/shared/CopyButton"; +import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect"; +import { labelWithHint } from "@/components/shared/form/LabelWithHint"; +import { extractProxyErrorMessage } from "@/lib/http/client"; +import { useQuery } from "@tanstack/react-query"; +import { CircleCheck, CircleX } from "lucide-react"; import { useState } from "react"; -import { FormProvider, useForm } from "react-hook-form"; +import { FormProvider, useForm, useWatch } from "react-hook-form"; import ProviderSpecificFields from "../add_model/provider_specific_fields"; import { requiredRule } from "../common_components/formRules"; -import { labelWithHint } from "@/components/shared/form/LabelWithHint"; import { MountedFormField, MountedFormProvider, @@ -14,11 +22,19 @@ import { useMountRegistry, type MountedFormValues, } from "../common_components/MountedFormField"; -import { CredentialItem } from "../networking"; +import type { + AnthropicJwks, + CredentialItem, + ProviderModelDiscoveryRequest, + ProviderModelDiscoveryResponse, +} from "../networking"; import { Providers } from "../provider_info_helpers"; -import { Logo } from "@/components/molecules/logo/Logo"; -import { computeCredentialValuesToDelete, resetCredentialFormOnProviderChange } from "./credential_form_helpers"; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { + computeCredentialValuesToDelete, + planCredentialTest, + resetCredentialFormOnProviderChange, + summarizeDiscoveredModels, +} from "./credential_form_helpers"; const providerOptions: SearchSelectOption[] = Object.entries(Providers).map(([providerEnum, providerDisplayName]) => ({ label: providerDisplayName, @@ -26,12 +42,49 @@ const providerOptions: SearchSelectOption[] = Object.entries(Providers).map(([pr icon: , })); +type ConnectionTest = + | { readonly kind: "idle" } + | { readonly kind: "testing" } + | { readonly kind: "success"; readonly message: string } + | { readonly kind: "failure"; readonly message: string }; + interface CredentialModalProps { open: boolean; onCancel: () => void; onSubmit: (values: any, credentialValuesToDelete: string[]) => void; mode: "add" | "edit"; existingCredential?: CredentialItem | null; + testConnection: (request: ProviderModelDiscoveryRequest) => Promise; + loadJwks: (credentialName: string) => Promise; +} + +function JwksExport({ + credentialName, + loadJwks, +}: { + credentialName: string; + loadJwks: CredentialModalProps["loadJwks"]; +}) { + const jwks = useQuery({ queryKey: ["credential-jwks", credentialName], queryFn: () => loadJwks(credentialName) }); + const jwksText = jwks.data ? JSON.stringify(jwks.data, null, 2) : null; + return ( +
+ Public JWKS + + In the Claude Console, open Settings, then Workload identity, click Connect workload, choose Custom OIDC and + paste this key set as the inline JWKS together with the Issuer URL and Subject above. Copy the Organization ID + and Federation Rule ID the Console shows into the fields above, update the credential, then test the connection. + + {jwks.isPending &&

Loading JWKS...

} + {jwks.isError &&

{extractProxyErrorMessage(jwks.error)}

} + {jwksText && ( +
+ +
{jwksText}
+
+ )} +
+ ); } export default function CredentialModal({ @@ -40,11 +93,14 @@ export default function CredentialModal({ onSubmit, mode, existingCredential = null, + testConnection, + loadJwks, }: CredentialModalProps) { const isEdit = mode === "edit"; const [selectedProvider, setSelectedProvider] = useState( (existingCredential?.credential_info.custom_llm_provider as Providers) ?? Providers.OpenAI, ); + const [connectionTest, setConnectionTest] = useState({ kind: "idle" }); const initialValues = existingCredential ? { @@ -58,6 +114,19 @@ export default function CredentialModal({ const form = useForm({ mode: "onChange", defaultValues: initialValues }); const registry = useMountRegistry(); + useWatch({ control: form.control }); + const testInput = { + mode, + provider: selectedProvider, + credentialName: existingCredential?.credential_name ?? "", + mountedValues: projectMountedValues(registry, form.getValues), + hasUnsavedChanges: form.formState.isDirty, + }; + const testPlan = planCredentialTest(testInput); + const jwksCredentialName = + isEdit && existingCredential?.credential_values?.anthropic_identity_source === "internal_issuer" + ? existingCredential.credential_name + : null; const formAdapter = { getFieldValue: (field: string) => form.getValues(field), @@ -84,6 +153,19 @@ export default function CredentialModal({ form.reset(); }; + const handleTestConnection = async () => { + if (testPlan.kind !== "ready") { + return; + } + setConnectionTest({ kind: "testing" }); + try { + const { models } = await testConnection(testPlan.request); + setConnectionTest({ kind: "success", message: summarizeDiscoveredModels(models) }); + } catch (error) { + setConnectionTest({ kind: "failure", message: extractProxyErrorMessage(error) }); + } + }; + const closeAndReset = () => { onCancel(); form.reset(); @@ -145,17 +227,48 @@ export default function CredentialModal({ -
- - - Need Help? - - + {jwksCredentialName && } -
- + + + Need Help? + + +
+ +
+
diff --git a/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.tsx b/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.tsx index cc9e94772ab..18a5b45f042 100644 --- a/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.tsx +++ b/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.tsx @@ -10,6 +10,9 @@ import { credentialDeleteCall, CredentialItem, credentialUpdateCall, + discoverProviderModelsCall, + getCredentialJwksCall, + type ProviderModelDiscoveryRequest, } from "@/components/networking"; import { Button } from "@/components/ui/button"; import { stripMaskedSecrets } from "@/utils/maskedSecretUtils"; @@ -35,6 +38,10 @@ const withoutRestrictedFields = (values: Record): Record + accessToken ? discoverProviderModelsCall(accessToken, request) : Promise.reject(new Error("Not signed in")); + const loadJwks = (credentialName: string) => + accessToken ? getCredentialJwksCall(accessToken, credentialName) : Promise.reject(new Error("Not signed in")); // Admin Viewer follows the read-parity rule: see credentials, do not modify. const canModifyCredentials = isProxyAdminRole(userRole ?? ""); const { data: credentialsResponse, isLoading, refetch: refetchCredentials } = useCredentials(); @@ -141,6 +148,8 @@ export default function CredentialsPanel() { onSubmit={handleAddCredential} open={isAddModalOpen} onCancel={() => setIsAddModalOpen(false)} + testConnection={testConnection} + loadJwks={loadJwks} /> )} {isUpdateModalOpen && ( @@ -150,6 +159,8 @@ export default function CredentialsPanel() { existingCredential={selectedCredential} onSubmit={handleUpdateCredential} onCancel={() => setIsUpdateModalOpen(false)} + testConnection={testConnection} + loadJwks={loadJwks} /> )} diff --git a/ui/litellm-dashboard/src/components/model_add/credential_form_helpers.test.ts b/ui/litellm-dashboard/src/components/model_add/credential_form_helpers.test.ts index ccdbaeda899..89e8c299f1b 100644 --- a/ui/litellm-dashboard/src/components/model_add/credential_form_helpers.test.ts +++ b/ui/litellm-dashboard/src/components/model_add/credential_form_helpers.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it, vi } from "vitest"; import { Providers } from "../provider_info_helpers"; -import { computeCredentialValuesToDelete, resetCredentialFormOnProviderChange } from "./credential_form_helpers"; +import { + computeCredentialValuesToDelete, + type CredentialTestInput, + litellmProviderId, + planCredentialTest, + resetCredentialFormOnProviderChange, + summarizeDiscoveredModels, +} from "./credential_form_helpers"; /** * Build a minimal FormInstance stub that records calls. We don't depend @@ -117,3 +124,97 @@ describe("computeCredentialValuesToDelete", () => { expect(computeCredentialValuesToDelete({}, { api_key: "sk-new" })).toEqual([]); }); }); + +describe("litellmProviderId", () => { + it("maps a dashboard provider key to the litellm provider id", () => { + expect(litellmProviderId("Anthropic")).toBe("anthropic"); + expect(litellmProviderId("Google_AI_Studio")).toBe("gemini"); + }); + + it("passes a litellm provider id stored by curl or the API through unchanged", () => { + expect(litellmProviderId("anthropic")).toBe("anthropic"); + }); +}); + +describe("planCredentialTest", () => { + const planWith = (overrides: Partial) => { + const input: CredentialTestInput = { + mode: "add", + provider: "OpenAI", + credentialName: "", + mountedValues: {}, + hasUnsavedChanges: true, + ...overrides, + }; + return planCredentialTest(input); + }; + const addWith = (mountedValues: Record) => planWith({ mountedValues }); + + it("sends only the entered api_key and api_base inline before saving", () => { + const keyOnlyForm = { credential_name: "prod", custom_llm_provider: "OpenAI", api_key: "sk-x", api_base: "" }; + expect(addWith(keyOnlyForm)).toEqual({ + kind: "ready", + request: { custom_llm_provider: "openai", api_key: "sk-x" }, + }); + expect(addWith({ api_key: "sk-x", api_base: "https://proxy.example.com/v1" })).toEqual({ + kind: "ready", + request: { custom_llm_provider: "openai", api_key: "sk-x", api_base: "https://proxy.example.com/v1" }, + }); + }); + + it("asks for values before anything has been entered", () => { + expect(addWith({ credential_name: "prod", custom_llm_provider: "OpenAI", api_key: null })).toEqual({ + kind: "unavailable", + reason: "Fill in the credential values first.", + }); + }); + + it("refuses to send server-owned values inline and points at saving first", () => { + const plan = planWith({ + provider: "Anthropic", + mountedValues: { anthropic_identity_source: "internal_issuer", anthropic_issuer_url: "", api_base: "" }, + }); + expect(plan.kind).toBe("unavailable"); + expect(plan.kind === "unavailable" && plan.reason).toMatch(/^Add the credential first/); + }); + + it("tests a saved credential by name so its values never leave the proxy", () => { + const savedWif: Partial = { + mode: "edit", + provider: "Anthropic", + credentialName: "anthropic-wif", + mountedValues: { anthropic_federation_rule_id: "fdrl_1", anthropic_organization_id: "org-1" }, + hasUnsavedChanges: false, + }; + expect(planWith(savedWif)).toEqual({ + kind: "ready", + request: { custom_llm_provider: "anthropic", litellm_credential_name: "anthropic-wif" }, + }); + }); + + it("requires unsaved edits to be saved before a by-name test", () => { + const editedWif: Partial = { + mode: "edit", + provider: "Anthropic", + credentialName: "anthropic-wif", + mountedValues: { api_key: "sk-new" }, + }; + expect(planWith(editedWif)).toEqual({ + kind: "unavailable", + reason: "Update the credential first. Test Connection checks the saved values.", + }); + }); +}); + +describe("summarizeDiscoveredModels", () => { + it("reports an empty discovery as a success without models", () => { + expect(summarizeDiscoveredModels([])).toBe("Connection succeeded, but the provider returned no models."); + }); + + it("lists up to three models and counts the rest", () => { + expect(summarizeDiscoveredModels(["a"])).toBe("Connection succeeded. 1 model available: a."); + expect(summarizeDiscoveredModels(["a", "b", "c", "d", "e"])).toBe( + "Connection succeeded. 5 models available: a, b, c and 2 more.", + ); + }); +}); diff --git a/ui/litellm-dashboard/src/components/model_add/credential_form_helpers.ts b/ui/litellm-dashboard/src/components/model_add/credential_form_helpers.ts index dc7245df1f1..ffd5aaf22d5 100644 --- a/ui/litellm-dashboard/src/components/model_add/credential_form_helpers.ts +++ b/ui/litellm-dashboard/src/components/model_add/credential_form_helpers.ts @@ -1,4 +1,5 @@ -import { Providers } from "../provider_info_helpers"; +import type { ProviderModelDiscoveryRequest } from "../networking"; +import { provider_map, Providers } from "../provider_info_helpers"; interface CredentialFormAdapter { getFieldValue: (field: string) => unknown; @@ -65,3 +66,63 @@ export function computeCredentialValuesToDelete( return value === "" || value === null || value === undefined; }); } + +export const litellmProviderId = (provider: string): string => provider_map[provider] ?? provider; + +const INLINE_TESTABLE_KEYS: ReadonlySet = new Set(["api_key", "api_base"]); +const FORM_META_KEYS: ReadonlySet = new Set(["credential_name", "custom_llm_provider"]); + +export type CredentialTestPlan = + | { readonly kind: "ready"; readonly request: ProviderModelDiscoveryRequest } + | { readonly kind: "unavailable"; readonly reason: string }; + +export interface CredentialTestInput { + readonly mode: "add" | "edit"; + readonly provider: string; + readonly credentialName: string; + readonly mountedValues: Record; + readonly hasUnsavedChanges: boolean; +} + +const isBlank = (value: unknown): boolean => value === "" || value === null || value === undefined; + +/** + * Decide what Test Connection sends to POST /provider/models/discover. A saved credential is tested + * by name so its values never leave the proxy. Before saving, only api_key and api_base may travel + * inline: every other credential value (federation ids, signing key refs, cloud keys) is server-owned + * and the route refuses it in a request body, so those are tested from Edit after saving. + */ +export function planCredentialTest(input: CredentialTestInput): CredentialTestPlan { + const custom_llm_provider = litellmProviderId(input.provider); + if (input.mode === "edit") { + return input.hasUnsavedChanges + ? { kind: "unavailable", reason: "Update the credential first. Test Connection checks the saved values." } + : { kind: "ready", request: { custom_llm_provider, litellm_credential_name: input.credentialName } }; + } + const entered = Object.entries(input.mountedValues).filter( + ([key, value]) => !FORM_META_KEYS.has(key) && !isBlank(value), + ); + if (entered.length === 0) { + return { kind: "unavailable", reason: "Fill in the credential values first." }; + } + if (!entered.every(([key]) => INLINE_TESTABLE_KEYS.has(key))) { + return { + kind: "unavailable", + reason: + "Add the credential first, then test it from Edit. Only an API key and API base can be tested before saving.", + }; + } + return { + kind: "ready", + request: { custom_llm_provider, ...Object.fromEntries(entered.map(([key, value]) => [key, String(value)])) }, + }; +} + +export function summarizeDiscoveredModels(models: readonly string[]): string { + if (models.length === 0) { + return "Connection succeeded, but the provider returned no models."; + } + const shown = models.slice(0, 3).join(", "); + const rest = models.length > 3 ? ` and ${models.length - 3} more` : ""; + return `Connection succeeded. ${models.length} model${models.length === 1 ? "" : "s"} available: ${shown}${rest}.`; +} diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 1be26491361..8d0afffbd6f 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1858,20 +1858,6 @@ export const modelInfoV1Call = async (accessToken: string, modelId: string) => { } }; -export interface DeploymentInfoRow { - model_name: string; - litellm_params: { model?: string; litellm_credential_name?: string }; - model_info: { id: string }; -} - -export const listAllModelsCall = async (accessToken: string): Promise<{ data: DeploymentInfoRow[] }> => { - /** - * Every deployment the caller can see, unpaginated. Used to dedupe model creation against - * what already exists (e.g. when resuming a partially-failed Add Provider wizard run). - */ - return await apiClient.get(`/model/info`, { accessToken }); -}; - export const modelHubPublicModelsCall = async () => { const url = proxyBaseUrl ? `${proxyBaseUrl}/public/model_hub` : `/public/model_hub`; const response = await fetch(url, {