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.
This commit is contained in:
mateo-berri 2026-09-05 16:26:48 -07:00
parent 669f0283e3
commit 4dfec1dcb0
5 changed files with 154 additions and 37 deletions

View file

@ -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<string, string>) => {
}
};
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/ }));

View file

@ -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<WizardStep, string> = {
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<Record<string, readonly string[]>> = {
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();
}}
>
<ProviderSpecificFields selectedProvider={selectedProvider} />
<ProviderSpecificFields
selectedProvider={selectedProvider}
hiddenFieldKeysByVariant={AUTHENTICATION_STEP_HIDDEN_FIELDS}
/>
<div className="flex justify-between">
<Button type="button" variant="outline" onClick={() => goTo("provider")}>
<ArrowLeft className="mr-1 size-4" /> Back

View file

@ -33,6 +33,13 @@ export const ANTHROPIC_FEDERATION_FIELDS = [
export type AnthropicFederationKey = (typeof ANTHROPIC_FEDERATION_FIELDS)[number]["key"];
export const ANTHROPIC_FEDERATION_KEYS: readonly AnthropicFederationKey[] = ANTHROPIC_FEDERATION_FIELDS.map(
(field) => field.key,
);
const isFederationKey = (key: string): key is AnthropicFederationKey =>
(ANTHROPIC_FEDERATION_KEYS as readonly string[]).includes(key);
export type AnthropicFederationIds = Readonly<Record<AnthropicFederationKey, string>>;
export interface FederationIdsUpdate {
@ -72,6 +79,13 @@ export const federationIdsUpdate = (
};
};
/**
* The ids a re-save of the Authentication step must leave untouched on a LiteLLM-signed
* credential: that step no longer mounts them, so they would otherwise read as deletions.
*/
export const savedFederationIds = (saved: Readonly<Record<string, unknown>>): Readonly<Record<string, unknown>> =>
Object.fromEntries(Object.entries(saved).filter(([key]) => isFederationKey(key)));
export const withFederationIds = (
saved: Readonly<Record<string, unknown>>,
ids: AnthropicFederationIds,

View file

@ -524,6 +524,34 @@ describe("ProviderSpecificFields", () => {
expect(screen.queryByLabelText("Upstream API Base")).not.toBeInTheDocument();
});
it("leaves a variant's hidden field keys off the form while other variants keep them", async () => {
const queryClient = createQueryClient();
render(
<QueryClientProvider client={queryClient}>
<MountedFormHost>
<ProviderSpecificFields
selectedProvider={Providers.Anthropic}
hiddenFieldKeysByVariant={{ wif_internal_issuer: ["anthropic_federation_rule_id"] }}
/>
</MountedFormHost>
</QueryClientProvider>,
);
const user = userEvent.setup();
await screen.findByLabelText("API Key");
await user.click(await screen.findByRole("combobox", { name: "Authentication method" }));
await user.click(await screen.findByRole("option", { name: "Workload Identity Federation (LiteLLM-signed)" }));
expect(await screen.findByLabelText("Issuer URL")).toBeInTheDocument();
expect(screen.getByLabelText("Organization ID")).toBeInTheDocument();
expect(screen.queryByLabelText("Federation Rule ID")).not.toBeInTheDocument();
await user.click(screen.getByRole("combobox", { name: "Authentication method" }));
await user.click(await screen.findByRole("option", { name: "Workload Identity Federation (external token)" }));
expect(await screen.findByLabelText("Federation Rule ID")).toBeInTheDocument();
});
it("injects the fixed discriminator for a variant without rendering a field for it", async () => {
const queryClient = createQueryClient();
render(

View file

@ -22,6 +22,7 @@ import { getVariant, inferActiveVariant, resolveVariantFieldDefs } from "./provi
interface ProviderSpecificFieldsProps {
selectedProvider: Providers;
hiddenFieldKeysByVariant?: Readonly<Record<string, readonly string[]>>;
}
const readTextFile = (file: File, onLoaded: (contents: string) => void) => {
@ -141,7 +142,10 @@ const FixedValueField: React.FC<{ name: string; value: string }> = ({ name, valu
return null;
};
const ProviderSpecificFields: React.FC<ProviderSpecificFieldsProps> = ({ selectedProvider }) => {
const ProviderSpecificFields: React.FC<ProviderSpecificFieldsProps> = ({
selectedProvider,
hiddenFieldKeysByVariant,
}) => {
const selectedProviderEnum = Providers[selectedProvider as keyof typeof Providers] as Providers;
const form = useFormContext<MountedFormValues>();
const credentialsFileRef = React.useRef<HTMLInputElement>(null);
@ -241,10 +245,15 @@ const ProviderSpecificFields: React.FC<ProviderSpecificFieldsProps> = ({ selecte
: undefined;
const activeVariantId = validUserChoice ?? (variants ? inferActiveVariant(variants, form.getValues()) : "");
const activeVariantFields = React.useMemo(
() => (variants ? resolveVariantFieldDefs(variants, activeVariantId).map(mapFieldMetadataToUiField) : []),
[variants, activeVariantId],
);
const activeVariantFields = React.useMemo(() => {
if (!variants) {
return [];
}
const hidden = new Set(hiddenFieldKeysByVariant?.[activeVariantId] ?? []);
return resolveVariantFieldDefs(variants, activeVariantId)
.filter((field) => !hidden.has(field.key))
.map(mapFieldMetadataToUiField);
}, [variants, activeVariantId, hiddenFieldKeysByVariant]);
const currentFields = variants ? activeVariantFields : allFields;