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 index bd0bdc45cd3..c1ddc666cbd 100644 --- 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 @@ -77,9 +77,22 @@ vi.mock("@/app/(dashboard)/hooks/providers/useProviderFields", () => ({ field_type: "text", required: true, }, + { key: "anthropic_identity_token", label: "Identity Token Reference", field_type: "text", required: true }, ], variants: [ { id: "api_key", label: "API Key", field_keys: ["api_base", "api_key"], fixed_values: {} }, + { + id: "wif_token", + label: "Workload Identity Federation (external token)", + field_keys: [ + "anthropic_federation_rule_id", + "anthropic_organization_id", + "anthropic_service_account_id", + "anthropic_workspace_id", + "anthropic_identity_token", + ], + fixed_values: {}, + }, { id: "wif_internal_issuer", label: "Workload Identity Federation (LiteLLM-signed)", @@ -157,6 +170,14 @@ const fillFederationIds = (ids: Record) => { } }; +const FEDERATION_ID_LABELS = ["Organization ID", "Federation Rule ID", "Service Account ID", "Workspace ID"] as const; + +const expectNoFederationIdFields = () => { + for (const label of FEDERATION_ID_LABELS) { + expect(screen.queryByLabelText(label)).not.toBeInTheDocument(); + } +}; + describe("AddProviderPanel", () => { beforeEach(() => { vi.clearAllMocks(); @@ -324,6 +345,44 @@ describe("AddProviderPanel", () => { ); }); + it("asks for the federation ids on the Register issuer step only, never on Authentication, for the LiteLLM-signed method", async () => { + 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/ })); + + // An external token means the rule already exists, so its ids are ordinary credential fields. + await chooseSelectOption( + user, + await screen.findByRole("combobox", { name: "Authentication method" }), + "Workload Identity Federation (external token)", + ); + expect(await screen.findByLabelText("Identity Token Reference")).toBeInTheDocument(); + for (const label of FEDERATION_ID_LABELS) { + expect(screen.getByLabelText(label)).toBeInTheDocument(); + } + + // Anthropic only issues the ids once the JWKS from the next step is registered, so asking for + // them here would be asking for values the operator cannot have yet. + await chooseSelectOption( + user, + screen.getByRole("combobox", { name: "Authentication method" }), + "Workload Identity Federation (LiteLLM-signed)", + ); + expect(await screen.findByLabelText("Issuer URL")).toBeInTheDocument(); + expectNoFederationIdFields(); + + fireEvent.change(screen.getByLabelText("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" })); + expect(await screen.findByText("Register this JWKS with Anthropic")).toBeInTheDocument(); + for (const label of FEDERATION_ID_LABELS) { + expect(screen.getByLabelText(label)).toHaveValue(""); + } + }); + 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(); @@ -382,25 +441,22 @@ describe("AddProviderPanel", () => { 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(""); + expect(await screen.findByLabelText("Issuer URL")).toHaveValue("https://proxy.example.com"); + expectNoFederationIdFields(); + // Re-saving Authentication must neither resend nor delete the ids it no longer mounts. credentialUpdateCall.mockClear(); + fireEvent.change(screen.getByLabelText("Issuer Subject"), { target: { value: "litellm-proxy-2" } }); 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_values: { ...INTERNAL_ISSUER_CREATE_VALUES, anthropic_issuer_subject: "litellm-proxy-2" }, credential_info: { custom_llm_provider: "anthropic" }, }); + expect(screen.getByLabelText("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.queryByText(/Still needed before discovery/)).not.toBeInTheDocument(); credentialUpdateCall.mockClear(); @@ -436,26 +492,17 @@ describe("AddProviderPanel", () => { }); it("deletes an id cleared on the JWKS step instead of leaving the saved value in place", async () => { - discoverProviderModelsCall.mockResolvedValue({ models: ["claude-3-opus"] }); + discoverProviderModelsCall.mockRejectedValueOnce(new Error("Model discovery failed: HTTP 401")); + discoverProviderModelsCall.mockResolvedValueOnce({ models: ["claude-3-opus"] }); const { user } = await setup(); - await chooseProvider(user, "Anthropic"); - await user.type(screen.getByLabelText("Credential name"), "anthropic-wif"); + await saveInternalIssuerCredential(user, "anthropic-wif"); + fillFederationIds({ "Organization ID": "org-1", "Federation Rule ID": "fdrl_abc", "Workspace ID": "wrkspc_stale" }); 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"); + expect(await screen.findByText("Model discovery failed: HTTP 401")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /Back/ })); + expect(await screen.findByLabelText("Workspace ID")).toHaveValue("wrkspc_stale"); fillFederationIds({ "Workspace ID": "" }); await user.click(screen.getByRole("button", { name: /Next/ })); 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 index 602c3593d83..eb87398a0ff 100644 --- 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 @@ -45,7 +45,13 @@ import { type DiscoveredModelRow, type ModelGroupAliasMap, } from "./wizardLogic"; -import { federationIdsUpdate, readFederationIds, withFederationIds } from "./anthropicFederation"; +import { + ANTHROPIC_FEDERATION_KEYS, + federationIdsUpdate, + readFederationIds, + savedFederationIds, + withFederationIds, +} from "./anthropicFederation"; import ReviewModelsStep from "./ReviewModelsStep"; import { DiscoverStep, JwksStep, ProviderStep, ResultsStep } from "./WizardSteps"; @@ -65,6 +71,12 @@ const STEP_LABELS: Record = { const ANTHROPIC_INTERNAL_ISSUER_DISCRIMINATOR = "internal_issuer"; +// The Register issuer step collects the federation ids, since Anthropic only issues them once the +// JWKS that step shows has been registered. +const AUTHENTICATION_STEP_HIDDEN_FIELDS: Readonly> = { + wif_internal_issuer: ANTHROPIC_FEDERATION_KEYS, +}; + 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); @@ -150,6 +162,8 @@ export default function AddProviderPanel() { const nonEmptyValues = Object.fromEntries( Object.entries(values).filter(([, v]) => v !== "" && v !== undefined && v !== null), ); + const isInternalIssuer = values.anthropic_identity_source === ANTHROPIC_INTERNAL_ISSUER_DISCRIMINATOR; + const retainedIds = isInternalIssuer ? savedFederationIds(savedValues) : {}; try { if (!credentialSaved) { await credentialCreateCall(accessToken, { @@ -158,7 +172,9 @@ export default function AddProviderPanel() { credential_info: { custom_llm_provider: litellmProvider }, }); } else { - const credentialValuesToDelete = computeCredentialValuesToDelete(savedValues, values); + const credentialValuesToDelete = computeCredentialValuesToDelete(savedValues, values).filter( + (key) => !(key in retainedIds), + ); const updatePayload = { credential_name: credentialName, credential_values: nonEmptyValues, @@ -167,11 +183,11 @@ export default function AddProviderPanel() { }; await credentialUpdateCall(accessToken, credentialName, updatePayload); } - setSavedValues(values); + setSavedValues({ ...values, ...retainedIds }); setSavedCredential({ name: credentialName, provider: litellmProvider }); queryClient.invalidateQueries({ queryKey: ["credentials"] }); toast.success(`Credential "${credentialName}" saved`); - if (values.anthropic_identity_source === ANTHROPIC_INTERNAL_ISSUER_DISCRIMINATOR) { + if (isInternalIssuer) { goTo("jwks"); void loadJwks(); } else { @@ -328,7 +344,10 @@ export default function AddProviderPanel() { void saveCredential(); }} > - +