From 4dfec1dcb08e383794a583b2aa457c91a64f6143 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:26:48 -0700 Subject: [PATCH] fix(ui): stop asking for the Anthropic federation ids on the Authentication step With the LiteLLM-signed identity source, Anthropic only issues the organization, federation rule, service account and workspace ids after the JWKS from the Register issuer step is registered, yet the Authentication step still asked for all four because the variant lists them as credential fields. The wizard now hides those four on Authentication for that method only and collects them on Register issuer, where they were already collected. The external-token and Keycloak methods keep them on Authentication since their rule exists before the credential does. A re-save of the Authentication step no longer mounts the ids, so it now leaves them untouched instead of treating them as deletions, and the wizard's saved state keeps them so the Register issuer step does not resend them. The provider JSON is unchanged so the LLM Credentials edit form can still change the ids on a saved credential. --- .../AddProviderPanel.integration.test.tsx | 101 +++++++++++++----- .../panels/add-provider/AddProviderPanel.tsx | 29 ++++- .../add-provider/anthropicFederation.ts | 14 +++ .../provider_specific_fields.test.tsx | 28 +++++ .../add_model/provider_specific_fields.tsx | 19 +++- 5 files changed, 154 insertions(+), 37 deletions(-) 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(); }} > - +