From eb9355d11a5fed631fb5626476d5172d66c58dc9 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:38:58 -0700 Subject: [PATCH] feat(ui): configure Anthropic workload identity federation from the dashboard (#44889) * feat(ui): configure Anthropic workload identity federation from the dashboard Add Credential and Edit Credential offer workload identity federation for Anthropic, Add Model creates a federated credential and attaches it, and the credentials table marks federated credentials. Editing a credential now sends only the values the admin changed, and switching provider no longer leaves the previous provider's default base URL on screen * fix(ui): keep a credential edit to what the admin set in the federation form * fix(ui): lock the provider in the federation dialog opened from Add Model * fix(ui): require one federation id when the identity source is the proxy environment * test(credentials): cover the Anthropic federation dashboard and credential routes Integration cells for the credential routes every dashboard shape writes (round trips, PATCH set and delete, malformed bodies, non-admin refusals, the token-file allowlist and exchange-host checks, every identity source through chat and messages against a scripted exchange, concurrent writes across two workers and a worker kill mid burst), plus Playwright specs for the Add Credential, Edit Credential and Add Model federation flows and the team-admin view. The owned proxies boot with a 2 s config reload so both workers serve a stored credential inside the fixture budget. * test(e2e): type the federation spec's captured bodies and clean up the Add Model deployment by its created id captureRequestBody and postAsMaster take a type parameter instead of returning Record, the spec names the credential and model write shapes it captures, and the Add Model cell reads the deployment id from the /model/new response right after the click so a later failing check no longer leaves the deployment behind. --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com> --- tests/e2e/ui/helpers/roundTrip.ts | 6 +- .../anthropicFederationCredential.spec.ts | 799 +++++++++++++++++ .../tests/integrationCritical/expected.json | 6 +- .../test_credential_federation_values.py | 819 ++++++++++++++++++ .../hooks/credentials/useCredentials.ts | 2 +- .../AddModelForm.integration.test.tsx | 73 +- .../src/components/add_model/AddModelForm.tsx | 52 +- .../add_model/provider_specific_fields.tsx | 12 +- .../model_add/AnthropicFederationFields.tsx | 141 +++ .../CredentialModal.integration.test.tsx | 444 ++++++++++ .../components/model_add/CredentialModal.tsx | 146 +++- .../model_add/CredentialsPanel.test.tsx | 49 +- .../components/model_add/CredentialsPanel.tsx | 21 +- .../model_add/CredentialsTable.test.tsx | 20 + .../model_add/CredentialsTableColumns.tsx | 15 +- .../model_add/anthropic_federation.test.ts | 335 +++++++ .../model_add/anthropic_federation.ts | 379 ++++++++ .../model_add/credential_form_helpers.ts | 13 + 18 files changed, 3274 insertions(+), 58 deletions(-) create mode 100644 tests/e2e/ui/tests/integrationCritical/anthropicFederationCredential.spec.ts create mode 100644 tests/integration/management/test_credential_federation_values.py create mode 100644 ui/litellm-dashboard/src/components/model_add/AnthropicFederationFields.tsx create mode 100644 ui/litellm-dashboard/src/components/model_add/CredentialModal.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/components/model_add/anthropic_federation.test.ts create mode 100644 ui/litellm-dashboard/src/components/model_add/anthropic_federation.ts diff --git a/tests/e2e/ui/helpers/roundTrip.ts b/tests/e2e/ui/helpers/roundTrip.ts index 91e48b7087c..4dc6d17a369 100644 --- a/tests/e2e/ui/helpers/roundTrip.ts +++ b/tests/e2e/ui/helpers/roundTrip.ts @@ -7,18 +7,18 @@ import { masterKey } from "./traffic"; * `action` is a callback so the listener is armed before the click; awaiting the * click first lets the request go by, and the test then hangs until timeout. */ -export async function captureRequestBody( +export async function captureRequestBody>( page: Page, match: { method: string; urlIncludes: string }, action: () => Promise, -): Promise> { +): Promise { const pending = page.waitForRequest( (req) => req.method() === match.method && req.url().includes(match.urlIncludes), ); await action(); const request = await pending; - return JSON.parse(request.postData() ?? "{}") as Record; + return JSON.parse(request.postData() ?? "{}") as T; } /** Reads an endpoint as the master key, so a failure is bad data and not an expired UI token. */ diff --git a/tests/e2e/ui/tests/integrationCritical/anthropicFederationCredential.spec.ts b/tests/e2e/ui/tests/integrationCritical/anthropicFederationCredential.spec.ts new file mode 100644 index 00000000000..c00c61b9e6c --- /dev/null +++ b/tests/e2e/ui/tests/integrationCritical/anthropicFederationCredential.spec.ts @@ -0,0 +1,799 @@ +import { + test, + expect, + type APIRequestContext, + type Locator, + type Page as PlaywrightPage, +} from "@playwright/test"; +import { Page } from "../../fixtures/pages"; +import { navigateToPage } from "../../helpers/navigation"; +import { captureRequestBody, readBack } from "../../helpers/roundTrip"; +import { uniqueSuffix } from "../../helpers/traffic"; +import { + logInThroughLoginPage, + setInvitedUserPassword, +} from "../../helpers/userOnboarding"; + +const master = process.env.LITELLM_MASTER_KEY ?? "sk-integration-master"; +const headers = { Authorization: `Bearer ${master}` }; + +const ANTHROPIC_LABEL = "Anthropic"; +const OPENAI_LABEL = "OpenAI"; +const FEDERATION_BADGE = "Workload identity federation"; +const FEDERATION_BUTTON = "Use workload identity federation"; +const FEDERATION_HELP = + "Workload identity federation is saved as a credential, then attached to this model."; +const ADDED_TOAST = "Credential added successfully"; +const UPDATED_TOAST = "Credential updated successfully"; +const ALLOWLIST_VARIABLE = "LITELLM_OIDC_ALLOWED_CREDENTIAL_DIRS"; +const NO_IDS_MESSAGE = + "Enter at least one of these ids, or pick an identity source that stores a token. The proxy rejects a credential with no values"; +const RAW_TOKEN_MESSAGE = + "Enter an oidc/ secret reference such as oidc/env/VAR_NAME. Raw tokens and oidc/env_path/ references are not accepted"; +const TTL_MESSAGE = "Enter a whole number of seconds from 1 to 3600"; + +type CredentialValues = Record; + +interface StoredCredential { + credential_name: string; + credential_values: CredentialValues; + credential_info: { custom_llm_provider: string }; +} + +interface Deployment { + model_name: string; + litellm_params: Record; + model_info: { id: string }; +} + +interface CredentialWrite { + credential_name: string; + credential_values: CredentialValues; + credential_info: { custom_llm_provider: string }; + credential_values_to_delete?: string[]; +} + +interface ModelWrite { + model_name: string; + litellm_params: Record; +} + +interface ModelCreated { + model_id: string; +} + +const masked = (value: string): string => + value.length <= 4 ? "*****" : `${value.slice(0, 2)}****${value.slice(-2)}`; + +const exactText = (text: string): RegExp => + new RegExp(`^${text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`); + +async function logInAsAdmin(page: PlaywrightPage): Promise { + await page.goto("/ui/login"); + await page.getByPlaceholder("Enter your username").fill("admin"); + await page.getByPlaceholder("Enter your password").fill(master); + await page.getByRole("button", { name: "Login", exact: true }).click(); + await expect(page).toHaveURL( + (url) => url.pathname.startsWith("/ui") && !url.pathname.includes("login"), + ); +} + +async function openModelsTab(page: PlaywrightPage, tab: string): Promise { + await navigateToPage(page, Page.Models); + await page.getByRole("tab", { name: tab, exact: true }).click(); +} + +async function pickOption( + page: PlaywrightPage, + trigger: Locator, + label: string, +): Promise { + await trigger.click(); + await page + .getByRole("option") + .filter({ hasText: exactText(label) }) + .first() + .click(); +} + +async function pickProvider( + page: PlaywrightPage, + scope: Locator, + provider: string, +): Promise { + await pickOption(page, scope.getByPlaceholder("Select a provider"), provider); +} + +async function pickAuthMethod( + page: PlaywrightPage, + dialog: Locator, + label: string, +): Promise { + await pickOption(page, dialog.locator("#anthropic_auth_method"), label); +} + +async function pickIdentitySource( + page: PlaywrightPage, + dialog: Locator, + label: string, +): Promise { + await pickOption( + page, + dialog.locator("#anthropic_federation_identity_source"), + label, + ); +} + +async function fillFields( + dialog: Locator, + values: Record, +): Promise { + for (const [key, value] of Object.entries(values)) { + await dialog.locator(`#${key}`).fill(value); + } +} + +async function openAddCredentialDialog( + page: PlaywrightPage, + name: string, +): Promise { + await page + .getByRole("button", { name: "Add Credential", exact: true }) + .click(); + const dialog = page.getByRole("dialog", { name: "Add New Credential" }); + await expect(dialog).toBeVisible(); + await dialog + .getByPlaceholder("Enter a friendly name for these credentials") + .fill(name); + await pickProvider(page, dialog, ANTHROPIC_LABEL); + await pickAuthMethod(page, dialog, FEDERATION_BADGE); + return dialog; +} + +async function openEditDialog( + page: PlaywrightPage, + name: string, +): Promise { + await expect(page.getByText(UPDATED_TOAST)).toHaveCount(0); + const row = page.locator("tr", { hasText: name }); + await expect(row).toBeVisible({ timeout: 15_000 }); + await row.getByTestId(`credential-actions-${name}`).click(); + await page.getByTestId("credential-action-edit").click(); + const dialog = page.getByRole("dialog", { name: "Edit Credential" }); + await expect(dialog).toBeVisible(); + return dialog; +} + +const submitCredential = ( + page: PlaywrightPage, + dialog: Locator, + method: "POST" | "PATCH", + urlIncludes: string, + button: string, +): Promise => + captureRequestBody(page, { method, urlIncludes }, () => + dialog.getByRole("button", { name: button, exact: true }).click(), + ); + +function countRequests( + page: PlaywrightPage, + method: string, + pathSuffix: string, +): () => number { + let seen = 0; + page.on("request", (request) => { + if ( + request.method() === method && + new URL(request.url()).pathname.endsWith(pathSuffix) + ) { + seen += 1; + } + }); + return () => seen; +} + +async function expectBlocked( + page: PlaywrightPage, + dialog: Locator, + button: string, + message: string, + postsBefore: number, + posts: () => number, +): Promise { + await dialog.getByRole("button", { name: button, exact: true }).click(); + await expect(dialog.getByText(message)).toBeVisible(); + await expect(dialog).toBeVisible(); + expect(posts(), "a refused form must send nothing").toBe(postsBefore); +} + +async function createCredential( + request: APIRequestContext, + name: string, + values: CredentialValues, + provider = "anthropic", +): Promise { + const response = await request.post("/credentials", { + headers, + data: { + credential_name: name, + credential_values: values, + credential_info: { custom_llm_provider: provider }, + }, + }); + expect(response.status(), await response.text()).toBe(200); +} + +async function deleteCredentials( + request: APIRequestContext, + names: readonly string[], +): Promise { + for (const name of names) { + await request.delete(`/credentials/${name}`, { headers }); + } +} + +const sameValues = (left: CredentialValues, right: CredentialValues): boolean => + JSON.stringify(Object.entries(left).sort()) === + JSON.stringify(Object.entries(right).sort()); + +async function readStoredCredential( + request: APIRequestContext, + name: string, +): Promise { + const response = await request.get(`/credentials/by_name/${name}`, { + headers, + }); + return response.status() === 200 + ? ((await response.json()) as StoredCredential) + : null; +} + +async function expectStored( + request: APIRequestContext, + name: string, + expected: CredentialValues, +): Promise { + let last: StoredCredential | null = null; + await expect + .poll( + async () => { + last = await readStoredCredential(request, name); + return last !== null && sameValues(last.credential_values, expected); + }, + { + timeout: 70_000, + message: `the proxy must serve ${name} as ${JSON.stringify(expected)}; last read ${JSON.stringify(last)}`, + }, + ) + .toBe(true); + if (last === null) { + throw new Error(`${name} was never read back`); + } + return last; +} + +async function postAsMaster>( + request: APIRequestContext, + route: string, + data: Record, +): Promise { + const response = await request.post(route, { headers, data }); + expect(response.status(), `POST ${route}: ${await response.text()}`).toBe( + 200, + ); + return (await response.json()) as T; +} + +test("the Add Credential dialog stores each federation identity source with only the values it needs and refuses the invalid ones", async ({ + page, + request, +}) => { + const suffix = uniqueSuffix(); + const names = { + tokenFile: `int-wif-file-${suffix}`, + secretReference: `int-wif-ref-${suffix}`, + internalIssuer: `int-wif-issuer-${suffix}`, + environment: `int-wif-env-${suffix}`, + }; + const ids = { + anthropic_federation_rule_id: `fdrl_${suffix}`, + anthropic_organization_id: `org-${suffix}`, + anthropic_service_account_id: `svac_${suffix}`, + anthropic_federation_workspace_id: `wrkspc_${suffix}`, + }; + const tokenFile = `/var/run/secrets/anthropic/${suffix}/token`; + const posts = countRequests(page, "POST", "/credentials"); + try { + await logInAsAdmin(page); + await openModelsTab(page, "LLM Credentials"); + + const fileDialog = await openAddCredentialDialog(page, names.tokenFile); + await pickIdentitySource(page, fileDialog, "Identity token file"); + await fillFields(fileDialog, { + ...ids, + anthropic_identity_token_file: tokenFile, + }); + const created = await submitCredential( + page, + fileDialog, + "POST", + "/credentials", + "Add Credential", + ); + expect(created).toEqual({ + credential_name: names.tokenFile, + credential_values: { ...ids, anthropic_identity_token_file: tokenFile }, + credential_info: { custom_llm_provider: ANTHROPIC_LABEL }, + }); + await expect(page.getByText(ADDED_TOAST)).toBeVisible(); + const row = page.locator("tr", { hasText: names.tokenFile }); + await expect(row).toBeVisible({ timeout: 15_000 }); + await expect(row).toContainText(FEDERATION_BADGE); + await expectStored(request, names.tokenFile, { + ...ids, + anthropic_identity_token_file: masked(tokenFile), + }); + + const referenceDialog = await openAddCredentialDialog( + page, + names.secretReference, + ); + await pickIdentitySource( + page, + referenceDialog, + "Identity token secret reference", + ); + await fillFields(referenceDialog, { + anthropic_federation_rule_id: ids.anthropic_federation_rule_id, + anthropic_identity_token: `eyJhbGciOiJSUzI1NiJ9.${suffix}.signature`, + }); + await expectBlocked( + page, + referenceDialog, + "Add Credential", + RAW_TOKEN_MESSAGE, + 1, + posts, + ); + const reference = `oidc/env/ANTHROPIC_IDENTITY_${suffix.replace(/\W/g, "_")}`; + await fillFields(referenceDialog, { anthropic_identity_token: reference }); + const referenced = await submitCredential( + page, + referenceDialog, + "POST", + "/credentials", + "Add Credential", + ); + expect(referenced.credential_values).toEqual({ + anthropic_federation_rule_id: ids.anthropic_federation_rule_id, + anthropic_identity_token: reference, + }); + await expect( + page.locator("tr", { hasText: names.secretReference }), + ).toBeVisible({ timeout: 15_000 }); + + const issuerDialog = await openAddCredentialDialog( + page, + names.internalIssuer, + ); + await pickIdentitySource( + page, + issuerDialog, + "Token signed by LiteLLM (internal issuer)", + ); + const issuer = { + anthropic_issuer_url: `https://issuer-${suffix}.example`, + anthropic_issuer_subject: `proxy-${suffix}`, + anthropic_issuer_audience: `anthropic-${suffix}`, + anthropic_issuer_signing_key_ref: + "os.environ/ANTHROPIC_ISSUER_SIGNING_KEY", + }; + await fillFields(issuerDialog, { + anthropic_federation_rule_id: ids.anthropic_federation_rule_id, + ...issuer, + anthropic_issuer_ttl_seconds: "7200", + }); + await expectBlocked( + page, + issuerDialog, + "Add Credential", + TTL_MESSAGE, + 2, + posts, + ); + await fillFields(issuerDialog, { anthropic_issuer_ttl_seconds: "900" }); + const issued = await submitCredential( + page, + issuerDialog, + "POST", + "/credentials", + "Add Credential", + ); + expect(issued.credential_values).toEqual({ + anthropic_federation_rule_id: ids.anthropic_federation_rule_id, + ...issuer, + anthropic_issuer_ttl_seconds: 900, + anthropic_identity_source: "internal_issuer", + }); + await expect( + page.locator("tr", { hasText: names.internalIssuer }), + ).toBeVisible({ timeout: 15_000 }); + + const environmentDialog = await openAddCredentialDialog( + page, + names.environment, + ); + await pickIdentitySource( + page, + environmentDialog, + "Proxy environment variables", + ); + await expectBlocked( + page, + environmentDialog, + "Add Credential", + NO_IDS_MESSAGE, + 3, + posts, + ); + await fillFields(environmentDialog, { + anthropic_organization_id: ids.anthropic_organization_id, + }); + const fromEnvironment = await submitCredential( + page, + environmentDialog, + "POST", + "/credentials", + "Add Credential", + ); + expect(fromEnvironment.credential_values).toEqual({ + anthropic_organization_id: ids.anthropic_organization_id, + }); + await expect( + page.locator("tr", { hasText: names.environment }), + ).toBeVisible({ timeout: 15_000 }); + expect(posts()).toBe(4); + } finally { + await deleteCredentials(request, Object.values(names)); + } +}); + +test("the Edit Credential dialog sends only the changed values and deletes the keys the new choice leaves behind", async ({ + page, + request, +}) => { + const suffix = uniqueSuffix(); + const names = { + fromApiKey: `int-wif-edit-key-${suffix}`, + clearWorkspace: `int-wif-edit-clear-${suffix}`, + switchSource: `int-wif-edit-source-${suffix}`, + switchProvider: `int-wif-edit-provider-${suffix}`, + }; + const ruleId = `fdrl_${suffix}`; + const tokenFile = `/var/run/secrets/anthropic/${suffix}/token`; + try { + await createCredential(request, names.fromApiKey, { + api_key: `sk-ant-${suffix}`, + }); + await createCredential(request, names.clearWorkspace, { + anthropic_federation_rule_id: ruleId, + anthropic_federation_workspace_id: `wrkspc_${suffix}`, + anthropic_identity_token_file: tokenFile, + }); + await createCredential(request, names.switchSource, { + anthropic_federation_rule_id: ruleId, + anthropic_identity_token_file: tokenFile, + }); + await createCredential(request, names.switchProvider, { + anthropic_federation_rule_id: ruleId, + anthropic_organization_id: `org-${suffix}`, + anthropic_identity_token_file: tokenFile, + }); + + await logInAsAdmin(page); + await openModelsTab(page, "LLM Credentials"); + + const keyDialog = await openEditDialog(page, names.fromApiKey); + await pickAuthMethod(page, keyDialog, FEDERATION_BADGE); + await fillFields(keyDialog, { + anthropic_federation_rule_id: ruleId, + anthropic_identity_token_file: tokenFile, + }); + const federated = await submitCredential( + page, + keyDialog, + "PATCH", + `/credentials/${names.fromApiKey}`, + "Update Credential", + ); + expect(federated).toEqual({ + credential_name: names.fromApiKey, + credential_values: { + anthropic_federation_rule_id: ruleId, + anthropic_identity_token_file: tokenFile, + }, + credential_info: { custom_llm_provider: "anthropic" }, + credential_values_to_delete: ["api_key"], + }); + await expect(page.getByText(UPDATED_TOAST)).toBeVisible(); + await expect( + page.locator("tr", { hasText: names.fromApiKey }), + ).toContainText(FEDERATION_BADGE); + await expectStored(request, names.fromApiKey, { + anthropic_federation_rule_id: ruleId, + anthropic_identity_token_file: masked(tokenFile), + }); + + const clearDialog = await openEditDialog(page, names.clearWorkspace); + await fillFields(clearDialog, { anthropic_federation_workspace_id: "" }); + const cleared = await submitCredential( + page, + clearDialog, + "PATCH", + `/credentials/${names.clearWorkspace}`, + "Update Credential", + ); + expect(cleared).toEqual({ + credential_name: names.clearWorkspace, + credential_values: {}, + credential_info: { custom_llm_provider: "anthropic" }, + credential_values_to_delete: ["anthropic_federation_workspace_id"], + }); + await expect(page.getByText(UPDATED_TOAST)).toBeVisible(); + await expectStored(request, names.clearWorkspace, { + anthropic_federation_rule_id: ruleId, + anthropic_identity_token_file: masked(tokenFile), + }); + + const sourceDialog = await openEditDialog(page, names.switchSource); + await pickIdentitySource( + page, + sourceDialog, + "Identity token secret reference", + ); + const reference = `oidc/env/ANTHROPIC_IDENTITY_${suffix.replace(/\W/g, "_")}`; + await fillFields(sourceDialog, { anthropic_identity_token: reference }); + const switched = await submitCredential( + page, + sourceDialog, + "PATCH", + `/credentials/${names.switchSource}`, + "Update Credential", + ); + expect(switched).toEqual({ + credential_name: names.switchSource, + credential_values: { anthropic_identity_token: reference }, + credential_info: { custom_llm_provider: "anthropic" }, + credential_values_to_delete: ["anthropic_identity_token_file"], + }); + await expect(page.getByText(UPDATED_TOAST)).toBeVisible(); + await expectStored(request, names.switchSource, { + anthropic_federation_rule_id: ruleId, + anthropic_identity_token: masked(reference), + }); + + const providerDialog = await openEditDialog(page, names.switchProvider); + await pickProvider(page, providerDialog, OPENAI_LABEL); + const openai = { + api_key: `sk-openai-${suffix}`, + api_base: `https://openai-${suffix}.example/v1`, + }; + await fillFields(providerDialog, openai); + const moved = await submitCredential( + page, + providerDialog, + "PATCH", + `/credentials/${names.switchProvider}`, + "Update Credential", + ); + expect(moved.credential_values).toEqual(openai); + expect(moved.credential_info).toEqual({ + custom_llm_provider: OPENAI_LABEL, + }); + expect([...(moved.credential_values_to_delete ?? [])].sort()).toEqual([ + "anthropic_federation_rule_id", + "anthropic_identity_token_file", + "anthropic_organization_id", + ]); + await expect(page.getByText(UPDATED_TOAST)).toBeVisible(); + const onOpenai = await expectStored(request, names.switchProvider, { + api_key: masked(openai.api_key), + api_base: openai.api_base, + }); + expect(onOpenai.credential_info.custom_llm_provider).toBe(OPENAI_LABEL); + await expect( + page.locator("tr", { hasText: names.switchProvider }), + ).not.toContainText(FEDERATION_BADGE); + } finally { + await deleteCredentials(request, Object.values(names)); + } +}); + +test("a proxy admin saves a federation credential from the Add Model tab and the model is created against it", async ({ + page, + request, +}) => { + test.slow(); + const suffix = uniqueSuffix(); + const credentialName = `int-wif-model-${suffix}`; + const modelName = `int-wif-model-${suffix}`; + const ids = { + anthropic_federation_rule_id: `fdrl_${suffix}`, + anthropic_organization_id: `org-${suffix}`, + }; + const tokenFile = `/opt/int-wif-${suffix}/token`; + let deploymentId = ""; + try { + await logInAsAdmin(page); + await openModelsTab(page, "Add Model"); + await pickProvider(page, page.locator("body"), ANTHROPIC_LABEL); + await expect(page.getByText(FEDERATION_HELP)).toBeVisible(); + await page + .getByRole("button", { name: FEDERATION_BUTTON, exact: true }) + .click(); + + const dialog = page.getByRole("dialog", { name: "Add New Credential" }); + await expect(dialog).toBeVisible(); + const provider = dialog.getByPlaceholder("Select a provider"); + await expect(provider).toHaveValue(ANTHROPIC_LABEL); + await expect(provider).toBeDisabled(); + await expect(dialog.locator("#anthropic_auth_method")).toContainText( + FEDERATION_BADGE, + ); + await dialog + .getByPlaceholder("Enter a friendly name for these credentials") + .fill(credentialName); + await fillFields(dialog, { + ...ids, + anthropic_identity_token_file: tokenFile, + }); + const created = await submitCredential( + page, + dialog, + "POST", + "/credentials", + "Add Credential", + ); + expect(created).toEqual({ + credential_name: credentialName, + credential_values: { ...ids, anthropic_identity_token_file: tokenFile }, + credential_info: { custom_llm_provider: ANTHROPIC_LABEL }, + }); + await expect(page.getByText(ADDED_TOAST)).toBeVisible(); + await expect(dialog).toBeHidden(); + await expect(page.locator("#litellm_credential_name")).toHaveValue( + credentialName, + ); + await expect(page.locator("#api_key")).toHaveCount(0); + + await pickOption( + page, + page.getByRole("combobox", { name: "Select models" }), + "Custom Model Name (Enter below)", + ); + await page.keyboard.press("Escape"); + await page.getByPlaceholder("Enter custom model name").fill(modelName); + + await expectStored(request, credentialName, { + ...ids, + anthropic_identity_token_file: masked(tokenFile), + }); + + const probe = await captureRequestBody( + page, + { method: "POST", urlIncludes: "/health/test_connection" }, + () => page.getByTestId("test-connect-btn").click(), + ); + expect(probe.litellm_params.litellm_credential_name).toBe(credentialName); + expect(probe.litellm_params.custom_llm_provider).toBe("anthropic"); + expect("api_key" in probe.litellm_params).toBe(false); + const results = page.getByRole("dialog", { + name: "Connection Test Results", + }); + await expect(results.getByTestId("connection-failure-msg")).toBeVisible({ + timeout: 60_000, + }); + await expect(results.getByText(ALLOWLIST_VARIABLE)).toBeVisible(); + await expect(results.getByText(tokenFile)).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(results).toBeHidden(); + + const creation = page.waitForResponse( + (response) => + response.request().method() === "POST" && + response.url().includes("/model/new"), + ); + const added = await captureRequestBody( + page, + { method: "POST", urlIncludes: "/model/new" }, + () => page.getByTestId("add-model-btn").click(), + ); + const creationResponse = await creation; + expect( + creationResponse.status(), + `POST /model/new: ${await creationResponse.text()}`, + ).toBe(200); + deploymentId = ((await creationResponse.json()) as ModelCreated).model_id; + expect(deploymentId).not.toBe(""); + expect(added.model_name).toBe(modelName); + expect(added.litellm_params.litellm_credential_name).toBe(credentialName); + expect(added.litellm_params.custom_llm_provider).toBe("anthropic"); + expect("api_key" in added.litellm_params).toBe(false); + + const findDeployment = async (): Promise => { + const info = await readBack<{ data: Deployment[] }>(page, "/model/info"); + return info.data.find( + (deployment) => deployment.model_name === modelName, + ); + }; + await expect + .poll(async () => (await findDeployment())?.model_info.id ?? "", { + timeout: 70_000, + message: "the deployment never appeared in /model/info", + }) + .not.toBe(""); + const deployment = await findDeployment(); + expect(deployment?.model_info.id).toBe(deploymentId); + expect(deployment?.litellm_params.litellm_credential_name).toBe( + credentialName, + ); + expect(deployment?.litellm_params.custom_llm_provider).toBe("anthropic"); + expect("api_key" in (deployment?.litellm_params ?? {})).toBe(false); + } finally { + if (deploymentId) { + await postAsMaster(request, "/model/delete", { id: deploymentId }); + } + await deleteCredentials(request, [credentialName]); + } +}); + +test("a team admin who is not a proxy admin gets no federation shortcut on the Add Model tab", async ({ + page, + request, +}) => { + const suffix = uniqueSuffix(); + const userId = `int-wif-team-admin-${suffix}`; + const email = `${userId}@integration.example`; + const password = `Int-Wif-${suffix}!`; + const teamAlias = `int-wif-team-${suffix}`; + let teamId = ""; + try { + await postAsMaster(request, "/user/new", { + user_id: userId, + user_email: email, + user_role: "internal_user", + auto_create_key: false, + }); + await setInvitedUserPassword(request, userId, password); + teamId = ( + await postAsMaster<{ team_id: string }>(request, "/team/new", { + team_alias: teamAlias, + members_with_roles: [{ role: "admin", user_id: userId }], + }) + ).team_id; + + await logInThroughLoginPage(page, email, password); + await openModelsTab(page, "Add Model"); + await expect(page.getByText("Team Selection Required")).toBeVisible(); + await page.getByPlaceholder("Search or select a team").click(); + await page + .getByRole("option") + .filter({ hasText: teamAlias }) + .first() + .click(); + await pickProvider(page, page.locator("body"), ANTHROPIC_LABEL); + await expect(page.getByPlaceholder("Select a provider")).toHaveValue( + ANTHROPIC_LABEL, + ); + await expect(page.locator("#api_key")).toBeVisible(); + await expect( + page.getByRole("button", { name: FEDERATION_BUTTON, exact: true }), + ).toHaveCount(0); + await expect(page.getByText(FEDERATION_HELP)).toHaveCount(0); + } finally { + if (teamId) { + await postAsMaster(request, "/team/delete", { team_ids: [teamId] }); + } + await postAsMaster(request, "/user/delete", { user_ids: [userId] }); + } +}); diff --git a/tests/e2e/ui/tests/integrationCritical/expected.json b/tests/e2e/ui/tests/integrationCritical/expected.json index 81d3be247fd..117df8d4892 100644 --- a/tests/e2e/ui/tests/integrationCritical/expected.json +++ b/tests/e2e/ui/tests/integrationCritical/expected.json @@ -10,5 +10,9 @@ "tests/e2e/ui/tests/integrationCritical/costOptimizationModelGroups.spec.ts::cache leakage by model merges a deployment's resolved and requested model names into its model group", "tests/e2e/ui/tests/integrationCritical/logsDrawerCredentialCanary.spec.ts::the Logs drawer renders the stored request without the deployment api_key", "tests/e2e/ui/tests/integrationCritical/toolPoliciesUserColumn.spec.ts::the Tool Policies page names the user behind the key that discovered a tool", - "tests/e2e/ui/tests/integrationCritical/teamMetadataEmptyKey.spec.ts::the team settings form skips a metadata row with an empty key and saving drops the key" + "tests/e2e/ui/tests/integrationCritical/teamMetadataEmptyKey.spec.ts::the team settings form skips a metadata row with an empty key and saving drops the key", + "tests/e2e/ui/tests/integrationCritical/anthropicFederationCredential.spec.ts::the Add Credential dialog stores each federation identity source with only the values it needs and refuses the invalid ones", + "tests/e2e/ui/tests/integrationCritical/anthropicFederationCredential.spec.ts::the Edit Credential dialog sends only the changed values and deletes the keys the new choice leaves behind", + "tests/e2e/ui/tests/integrationCritical/anthropicFederationCredential.spec.ts::a proxy admin saves a federation credential from the Add Model tab and the model is created against it", + "tests/e2e/ui/tests/integrationCritical/anthropicFederationCredential.spec.ts::a team admin who is not a proxy admin gets no federation shortcut on the Add Model tab" ] diff --git a/tests/integration/management/test_credential_federation_values.py b/tests/integration/management/test_credential_federation_values.py new file mode 100644 index 00000000000..3f812c87c59 --- /dev/null +++ b/tests/integration/management/test_credential_federation_values.py @@ -0,0 +1,819 @@ +from __future__ import annotations + +import json +import uuid +from collections.abc import Callable, Iterator, Mapping, Sequence +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from functools import partial +from pathlib import Path +from types import MappingProxyType +from typing import Final, TypeVar +from urllib.parse import parse_qs + +import anthropic +import httpx +import jwt +import openai +import psutil +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec +from pydantic import JsonValue + +from litellm.types.llms.anthropic import ANTHROPIC_OAUTH_TOKEN_PREFIX +from tests.integration._support.anthropic_thinking import ( + answer, + identity, + marker_of, + message_body, + message_events, + prompt, + stream_reply, + streams, + text_events, +) +from tests.integration._support.client import ( + JSON_OBJECT, + Gateway, + Scenario, + eventually, + gateway_from_environment, + object_value, + string_value, +) +from tests.integration._support.database import read_rows +from tests.integration._support.process import OwnedProxy, owned_proxy_process +from tests.integration._support.wire import Reply, Request, Wire, wire_server + +T = TypeVar("T") + +SOURCES: Final = ("token_file", "secret_reference", "internal_issuer", "keycloak", "environment") +CLIENTS: Final = ("chat", "messages", "messages_stream") + +_CLOSE: Final = MappingProxyType({"Connection": "close"}) +_PROVIDER: Final = MappingProxyType({"custom_llm_provider": "anthropic"}) +_SENSITIVE: Final = ( + "authorization", + "token", + "key", + "secret", + "vertex_credentials", + "credentials", + "password", + "passwd", +) +_DEFAULT_TOKEN_FILE: Final = "/var/run/secrets/integration/anthropic-identity-token" +_DEFAULT_KEYCLOAK_URL: Final = "https://keycloak.integration.invalid/realms/integration/protocol/openid-connect/token" +_KEYCLOAK_TARGET: Final = "/realms/integration/protocol/openid-connect/token" +_KEYCLOAK_ASSERTION: Final = "keycloak-scripted-assertion" +_ISSUER: Final = "https://issuer.integration.invalid" +_SUBJECT: Final = "workload-integration" +_AUDIENCE: Final = "https://api.anthropic.com" +_TTL_SECONDS: Final = 900 +_IDENTITY_TOKEN_VARIABLE: Final = "INTEGRATION_WIF_IDENTITY_TOKEN" +_SIGNING_KEY_VARIABLE: Final = "INTEGRATION_WIF_SIGNING_KEY" +_KEYCLOAK_SECRET_VARIABLE: Final = "INTEGRATION_WIF_KEYCLOAK_SECRET" +_JWT_BEARER: Final = "urn:ietf:params:oauth:grant-type:jwt-bearer" +_MODEL: Final = "anthropic/claude-haiku-4-5" +_CREDENTIAL_QUERY: Final = ( + 'SELECT credential_values, credential_info FROM "LiteLLM_CredentialsTable" WHERE credential_name = %s' +) + + +def _ids(rule_id: JsonValue) -> dict[str, JsonValue]: + return { + "anthropic_federation_rule_id": rule_id, + "anthropic_organization_id": "org-integration", + "anthropic_service_account_id": "svac-integration", + "anthropic_federation_workspace_id": "wrkspc-integration", + } + + +def _shape( + source: str, + rule_id: JsonValue, + *, + token_file: str = _DEFAULT_TOKEN_FILE, + keycloak_token_url: str = _DEFAULT_KEYCLOAK_URL, +) -> dict[str, JsonValue]: + match source: + case "token_file": + return {**_ids(rule_id), "anthropic_identity_token_file": token_file} + case "secret_reference": + return {**_ids(rule_id), "anthropic_identity_token": f"oidc/env/{_IDENTITY_TOKEN_VARIABLE}"} + case "internal_issuer": + return { + **_ids(rule_id), + "anthropic_identity_source": "internal_issuer", + "anthropic_issuer_url": _ISSUER, + "anthropic_issuer_subject": _SUBJECT, + "anthropic_issuer_audience": _AUDIENCE, + "anthropic_issuer_ttl_seconds": _TTL_SECONDS, + "anthropic_issuer_signing_key_ref": f"os.environ/{_SIGNING_KEY_VARIABLE}", + } + case "keycloak": + return { + **_ids(rule_id), + "anthropic_identity_source": "keycloak", + "anthropic_keycloak_token_url": keycloak_token_url, + "anthropic_keycloak_client_id": "litellm-integration", + "anthropic_keycloak_client_secret_ref": f"os.environ/{_KEYCLOAK_SECRET_VARIABLE}", + "anthropic_keycloak_auth_method": "client_secret_post", + "anthropic_keycloak_scope": "openid", + } + case "environment": + return _ids(rule_id) + case _: + pytest.fail(f"unknown identity source {source!r}") + + +def _sensitive(key: str) -> bool: + return any(word in key.lower() for word in _SENSITIVE) + + +def _masked(value: JsonValue) -> JsonValue: + if not isinstance(value, str): + return value + return "*****" if len(value) <= 4 else f"{value[:2]}****{value[-2:]}" + + +def _masked_view(values: Mapping[str, JsonValue]) -> dict[str, JsonValue]: + return {key: _masked(value) if _sensitive(key) else value for key, value in values.items()} + + +def _credential_name() -> str: + return f"federation-{uuid.uuid4().hex}" + + +def _create_body(name: str, values: Mapping[str, JsonValue]) -> dict[str, JsonValue]: + return {"credential_name": name, "credential_values": dict(values), "credential_info": dict(_PROVIDER)} + + +def _create(gateway: Gateway, scenario: Scenario, values: Mapping[str, JsonValue]) -> str: + name: Final = _credential_name() + scenario.cleanups.callback(_delete_if_present, gateway, name) + gateway.post("/credentials", _create_body(name, values)) + return name + + +def _delete_if_present(gateway: Gateway, name: str) -> None: + response: Final = gateway.request("DELETE", f"/credentials/{name}") + assert response.status_code in (200, 404), response.text + + +def _patch( + gateway: Gateway, + name: str, + values: Mapping[str, JsonValue], + delete: Sequence[str] = (), + *, + key: str | None = None, +) -> httpx.Response: + return gateway.request( + "PATCH", + f"/credentials/{name}", + { + "credential_name": name, + "credential_values": dict(values), + "credential_info": dict(_PROVIDER), + "credential_values_to_delete": list(delete), + }, + key=key, + ) + + +def _stored_values(gateway: Gateway, name: str, *, key: str | None = None) -> tuple[int, JsonValue]: + response: Final = gateway.request("GET", f"/credentials/by_name/{name}", key=key, headers=_CLOSE) + if response.status_code != 200: + return response.status_code, response.text + return 200, JSON_OBJECT.validate_json(response.content)["credential_values"] + + +def _listed_values(gateway: Gateway, name: str) -> JsonValue: + response: Final = gateway.request("GET", "/credentials", headers=_CLOSE) + assert response.status_code == 200, response.text + listed: Final = JSON_OBJECT.validate_json(response.content)["credentials"] + assert isinstance(listed, list), listed + matching: Final = tuple(object_value(entry) for entry in listed if object_value(entry)["credential_name"] == name) + return matching[0]["credential_values"] if matching else None + + +def _stable(read: Callable[[], T], satisfied: Callable[[T], bool], *, seconds: float = 70) -> T: + def spread_over_workers() -> tuple[T, ...]: + with ThreadPoolExecutor(max_workers=10) as pool: + return tuple(pool.map(lambda _: read(), range(10))) + + return eventually( + spread_over_workers, + lambda samples: all(satisfied(sample) for sample in samples), + seconds=seconds, + )[-1] + + +def _converged(gateway: Gateway, name: str, expected: Mapping[str, JsonValue], *, key: str | None = None) -> None: + masked: Final = _masked_view(expected) + _stable(partial(_stored_values, gateway, name, key=key), lambda sample: sample == (200, masked)) + + +def _db_row(name: str) -> tuple[dict[str, JsonValue], dict[str, JsonValue]]: + rows: Final = read_rows(_CREDENTIAL_QUERY, (name,)) + assert len(rows) == 1, rows + return object_value(rows[0]["credential_values"]), object_value(rows[0]["credential_info"]) + + +def _assert_encrypted_at_rest(name: str, submitted: Mapping[str, JsonValue]) -> None: + stored, info = _db_row(name) + assert info == dict(_PROVIDER), info + assert stored.keys() == submitted.keys(), stored + dumped: Final = json.dumps(stored) + for key, value in submitted.items(): + if not isinstance(value, str): + assert stored[key] == value, (key, stored[key]) + continue + assert stored[key] != value, key + if _sensitive(key): + assert value not in dumped, key + + +def _without(values: Mapping[str, JsonValue], *keys: str) -> dict[str, JsonValue]: + return {key: value for key, value in values.items() if key not in keys} + + +def _listed_models(gateway: Gateway) -> frozenset[str]: + response: Final = gateway.request("GET", "/model/info", headers=_CLOSE) + assert response.status_code == 200, response.text + entries: Final = JSON_OBJECT.validate_json(response.content)["data"] + assert isinstance(entries, list), entries + return frozenset(string_value(object_value(entry)["model_name"]) for entry in entries) + + +def _deployments_visible(gateway: Gateway, names: Sequence[str]) -> None: + wanted: Final = frozenset(names) + _stable(partial(_listed_models, gateway), lambda listed: wanted <= listed) + + +def _federated_deployment(gateway: Gateway, scenario: Scenario, credential: str, api_base: str) -> str: + name: Final = f"integration-{uuid.uuid4().hex}" + created: Final = gateway.post( + "/model/new", + { + "model_name": name, + "litellm_params": {"model": _MODEL, "api_base": api_base, "litellm_credential_name": credential}, + "model_info": {}, + }, + ) + scenario.cleanups.callback(scenario.delete_model, string_value(object_value(created["model_info"])["id"])) + return name + + +def _chat(gateway: Gateway, model: str, marker: str) -> httpx.Response: + return gateway.request( + "POST", + "/v1/chat/completions", + {"model": model, "messages": [{"role": "user", "content": prompt(marker)}]}, + headers=_CLOSE, + ) + + +def _chat_outcome(gateway: Gateway, model: str) -> tuple[int, bool]: + marker: Final = uuid.uuid4().hex + response: Final = _chat(gateway, model, marker) + if response.status_code != 200: + return response.status_code, False + choices: Final = JSON_OBJECT.validate_json(response.content)["choices"] + assert isinstance(choices, list), choices + return 200, object_value(object_value(choices[0])["message"])["content"] == answer(marker) + + +@pytest.mark.parametrize("source", SOURCES) +def test_federation_shape_round_trips(gateway: Gateway, source: str) -> None: + with gateway.scenario() as scenario: + values: Final = _shape(source, f"fdrl-{uuid.uuid4().hex}") + name: Final = _create(gateway, scenario, values) + _converged(gateway, name, values) + _stable(partial(_listed_values, gateway, name), lambda listed: listed == _masked_view(values)) + _assert_encrypted_at_rest(name, values) + + +def test_patch_sets_and_deletes_values(gateway: Gateway) -> None: + with gateway.scenario() as scenario: + values: Final = _shape("token_file", f"fdrl-{uuid.uuid4().hex}") + name: Final = _create(gateway, scenario, values) + _converged(gateway, name, values) + response: Final = _patch( + gateway, + name, + {"anthropic_federation_workspace_id": "wrkspc-updated"}, + ("anthropic_service_account_id",), + ) + assert response.status_code == 200, response.text + expected: Final = _without( + {**values, "anthropic_federation_workspace_id": "wrkspc-updated"}, "anthropic_service_account_id" + ) + _converged(gateway, name, expected) + _assert_encrypted_at_rest(name, expected) + + +def test_patch_with_empty_values_only_deletes(gateway: Gateway) -> None: + with gateway.scenario() as scenario: + values: Final = _shape("token_file", f"fdrl-{uuid.uuid4().hex}") + name: Final = _create(gateway, scenario, values) + _converged(gateway, name, values) + response: Final = _patch(gateway, name, {}, ("anthropic_federation_workspace_id",)) + assert response.status_code == 200, response.text + expected: Final = _without(values, "anthropic_federation_workspace_id") + _converged(gateway, name, expected) + _assert_encrypted_at_rest(name, expected) + + +def test_patch_overlapping_set_and_delete_is_refused(gateway: Gateway) -> None: + with gateway.scenario() as scenario: + values: Final = _shape("token_file", f"fdrl-{uuid.uuid4().hex}") + name: Final = _create(gateway, scenario, values) + _converged(gateway, name, values) + before: Final = _db_row(name) + response: Final = _patch( + gateway, + name, + {"anthropic_federation_workspace_id": "wrkspc-overlap"}, + ("anthropic_federation_workspace_id",), + ) + assert response.status_code == 400, response.text + assert "credential_values_to_delete overlaps credential_values for key(s)" in response.text, response.text + assert "anthropic_federation_workspace_id" in response.text, response.text + assert _db_row(name) == before + _converged(gateway, name, values) + + +_MALFORMED: Final[Mapping[str, JsonValue]] = MappingProxyType( + {"string": "fdrl-malformed", "list": ["fdrl-malformed"], "empty": {}, "null": None} +) + + +@pytest.mark.parametrize("shape", tuple(_MALFORMED)) +def test_malformed_credential_values_are_refused(gateway: Gateway, shape: str) -> None: + name: Final = _credential_name() + with gateway.scenario() as scenario: + scenario.cleanups.callback(_delete_if_present, gateway, name) + response: Final = gateway.request( + "POST", + "/credentials", + {"credential_name": name, "credential_values": _MALFORMED[shape], "credential_info": dict(_PROVIDER)}, + ) + assert response.status_code == 422, response.text + assert read_rows(_CREDENTIAL_QUERY, (name,)) == [] + assert _stored_values(gateway, name)[0] == 404 + + +def test_oversized_and_non_string_ids_round_trip(gateway: Gateway) -> None: + with gateway.scenario() as scenario: + oversized: Final = _shape("environment", "f" * 5120) + numeric: Final = _shape("environment", 424242) + oversized_name: Final = _create(gateway, scenario, oversized) + numeric_name: Final = _create(gateway, scenario, numeric) + _converged(gateway, oversized_name, oversized) + _converged(gateway, numeric_name, numeric) + _assert_encrypted_at_rest(oversized_name, oversized) + _assert_encrypted_at_rest(numeric_name, numeric) + + +def test_duplicate_create_conflicts_and_repeated_patch_is_idempotent(gateway: Gateway) -> None: + with gateway.scenario() as scenario: + values: Final = _shape("token_file", f"fdrl-{uuid.uuid4().hex}") + name: Final = _create(gateway, scenario, values) + duplicate: Final = gateway.request( + "POST", "/credentials", _create_body(name, _shape("token_file", f"fdrl-{uuid.uuid4().hex}")) + ) + assert duplicate.status_code == 409, duplicate.text + assert ( + f"Credential '{name}' already exists. Update it with PATCH /credentials/{name}, or delete it first." + in duplicate.text + ), duplicate.text + _converged(gateway, name, values) + _assert_encrypted_at_rest(name, values) + statuses: Final = tuple( + _patch(gateway, name, {"anthropic_federation_workspace_id": "wrkspc-twice"}).status_code for _ in range(2) + ) + assert statuses == (200, 200), statuses + expected: Final = {**values, "anthropic_federation_workspace_id": "wrkspc-twice"} + _converged(gateway, name, expected) + _assert_encrypted_at_rest(name, expected) + + +@pytest.mark.parametrize("grant", ("routed", "plain")) +def test_non_admin_cannot_write_federation_fields(gateway: Gateway, grant: str) -> None: + with gateway.scenario() as scenario: + values: Final = _shape("token_file", f"fdrl-{uuid.uuid4().hex}") + name: Final = _create(gateway, scenario, values) + _converged(gateway, name, values) + before: Final = _db_row(name) + model: Final = scenario.model() + user_id: Final = scenario.user(user_role="internal_user") + key: Final = ( + scenario.key(user_id=user_id, models=[model], allowed_routes=["/credentials*", "/v1/chat/completions"]) + if grant == "routed" + else scenario.key(user_id=user_id, models=[model]) + ) + intruder: Final = _credential_name() + scenario.cleanups.callback(_delete_if_present, gateway, intruder) + attempts: Final = ( + ( + "create", + gateway.request( + "POST", + "/credentials", + _create_body(intruder, _shape("token_file", f"fdrl-{uuid.uuid4().hex}")), + key=key, + ), + ), + ("set", _patch(gateway, name, {"anthropic_federation_rule_id": "fdrl-hijacked"}, key=key)), + ("unset", _patch(gateway, name, {}, ("anthropic_identity_token_file",), key=key)), + ("delete", gateway.request("DELETE", f"/credentials/{name}", key=key)), + ("jwks", gateway.request("GET", f"/credentials/{name}/jwks", key=key)), + ) + refused: Final = 403 if grant == "routed" else 401 + assert tuple((label, response.status_code) for label, response in attempts) == tuple( + (label, refused) for label, _ in attempts + ), tuple((label, response.text) for label, response in attempts) + if grant == "routed": + assert all("Only proxy admins" in response.text for _, response in attempts), tuple( + response.text for _, response in attempts + ) + listing: Final = gateway.request("GET", "/credentials", key=key, headers=_CLOSE) + assert listing.status_code == (200 if grant == "routed" else 401), listing.text + assert _DEFAULT_TOKEN_FILE not in listing.text, listing.text + if grant == "routed": + _converged(gateway, name, values, key=key) + else: + assert _stored_values(gateway, name, key=key)[0] == 401 + assert _db_row(name) == before + assert read_rows(_CREDENTIAL_QUERY, (intruder,)) == [] + _converged(gateway, name, values) + gateway.chat(model, key=key) + + +def test_untrusted_exchange_host_is_refused_before_any_exchange(gateway: Gateway) -> None: + with wire_server(lambda request: Reply(status=500, body=b'{"error": "never reached"}')) as wire: + with gateway.scenario() as scenario: + values: Final = _shape("token_file", f"fdrl-{uuid.uuid4().hex}") + name: Final = _create(gateway, scenario, values) + model: Final = _federated_deployment(gateway, scenario, name, wire.url) + _deployments_visible(gateway, (model,)) + response: Final = _chat(gateway, model, uuid.uuid4().hex) + assert response.status_code == 401, response.text + port: Final = httpx.URL(wire.url).port + assert "refused to use host '" in response.text, response.text + assert f":{port}'" in response.text, response.text + assert "LITELLM_ANTHROPIC_WIF_ALLOWED_HOSTS" in response.text, response.text + assert wire.drain() == () + assert wire.connections() == 0 + + +def test_concurrent_credential_writes_converge_across_workers(gateway: Gateway) -> None: + with gateway.scenario() as scenario: + names: Final = tuple(_credential_name() for _ in range(10)) + for name in names: + scenario.cleanups.callback(_delete_if_present, gateway, name) + + def lane(name: str) -> tuple[int, int, int, int]: + created: Final = gateway.request( + "POST", "/credentials", _create_body(name, _shape("token_file", f"fdrl-{name}")) + ) + updated: Final = _patch(gateway, name, {"anthropic_federation_workspace_id": f"wrkspc-{name}"}) + trimmed: Final = _patch(gateway, name, {}, ("anthropic_service_account_id",)) + read: Final = gateway.request("GET", f"/credentials/by_name/{name}", headers=_CLOSE) + return created.status_code, updated.status_code, trimmed.status_code, read.status_code + + with ThreadPoolExecutor(max_workers=10) as pool: + outcomes: Final = tuple(pool.map(lane, names)) + assert all(outcome[:3] == (200, 200, 200) for outcome in outcomes), outcomes + assert all(outcome[3] in (200, 404) for outcome in outcomes), outcomes + for name in names: + expected: Final = _without( + {**_shape("token_file", f"fdrl-{name}"), "anthropic_federation_workspace_id": f"wrkspc-{name}"}, + "anthropic_service_account_id", + ) + _converged(gateway, name, expected) + _assert_encrypted_at_rest(name, expected) + + +@dataclass(frozen=True, slots=True) +class _Peer: + wire: Wire + seen: list[Request] + + def requests(self) -> tuple[Request, ...]: + self.seen.extend(self.wire.drain()) + return tuple(self.seen) + + +def _exchange_reply(request: Request) -> Reply: + grant: Final = JSON_OBJECT.validate_json(request.body) + minted: Final = { + "access_token": f"{ANTHROPIC_OAUTH_TOKEN_PREFIX}01-exchanged-{string_value(grant['federation_rule_id'])}", + "token_type": "Bearer", + "expires_in": 3600, + } + return Reply(body=json.dumps(minted).encode()) + + +def _federation_peer(request: Request) -> Reply: + if request.target == "/v1/oauth/token": + return _exchange_reply(request) + if request.target == _KEYCLOAK_TARGET: + return Reply( + body=json.dumps({"access_token": _KEYCLOAK_ASSERTION, "token_type": "Bearer", "expires_in": 60}).encode() + ) + marker: Final = marker_of(request) + if streams(request): + return stream_reply(request, message_events(marker, (text_events(0, answer(marker)),))) + return Reply(body=message_body(marker)) + + +def _es256_private_key_pem() -> str: + return ( + ec.generate_private_key(ec.SECP256R1()) + .private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()) + .decode() + ) + + +@dataclass(frozen=True, slots=True) +class _Secrets: + allowed_dir: Path + token_file: Path + identity_token: str + environment_token: str + keycloak_secret: str + signing_key_pem: str + + def overrides(self) -> Mapping[str, str]: + return MappingProxyType( + { + "LITELLM_ANTHROPIC_WIF_ALLOWED_HOSTS": "127.0.0.1", + "LITELLM_OIDC_ALLOWED_CREDENTIAL_DIRS": str(self.allowed_dir), + "PROXY_CONFIG_RELOAD_INTERVAL_SECONDS": "2", + _IDENTITY_TOKEN_VARIABLE: self.identity_token, + _SIGNING_KEY_VARIABLE: self.signing_key_pem, + _KEYCLOAK_SECRET_VARIABLE: self.keycloak_secret, + "ANTHROPIC_IDENTITY_TOKEN": self.environment_token, + } + ) + + +_REMOVED_ENVIRONMENT: Final = ("ANTHROPIC_IDENTITY_TOKEN_FILE", "ANTHROPIC_API_KEY", "ANTHROPIC_API_BASE") + + +def _secrets(directory: Path) -> _Secrets: + allowed: Final = directory / "secrets" + allowed.mkdir() + token_file: Final = allowed / "anthropic-identity-token" + token_file.write_text(f"file-token-{uuid.uuid4().hex}") + return _Secrets( + allowed_dir=allowed, + token_file=token_file, + identity_token=f"env-token-{uuid.uuid4().hex}", + environment_token=f"ambient-token-{uuid.uuid4().hex}", + keycloak_secret=f"keycloak-secret-{uuid.uuid4().hex}", + signing_key_pem=_es256_private_key_pem(), + ) + + +@dataclass(frozen=True, slots=True) +class FederationRig: + owned: OwnedProxy + peer: _Peer + secrets: _Secrets + rule_ids: Mapping[str, str] + credentials: Mapping[str, str] + deployments: Mapping[str, str] + + +@pytest.fixture(scope="module") +def federation(tmp_path_factory: pytest.TempPathFactory) -> Iterator[FederationRig]: + directory: Final = tmp_path_factory.mktemp("federation").resolve() + secrets: Final = _secrets(directory) + with gateway_from_environment() as gateway, wire_server(_federation_peer) as wire: + with owned_proxy_process( + gateway, directory, secrets.overrides(), remove_environment=_REMOVED_ENVIRONMENT, workers=2 + ) as owned: + with owned.gateway.scenario() as scenario: + rule_ids: Final = {source: f"fdrl-{source}-{uuid.uuid4().hex}" for source in SOURCES} + credentials: Final = { + source: _create( + owned.gateway, + scenario, + _shape( + source, + rule_ids[source], + token_file=str(secrets.token_file), + keycloak_token_url=f"{wire.url}{_KEYCLOAK_TARGET}", + ), + ) + for source in SOURCES + } + deployments: Final = { + source: _federated_deployment(owned.gateway, scenario, credentials[source], wire.url) + for source in SOURCES + } + _deployments_visible(owned.gateway, tuple(deployments.values())) + yield FederationRig( + owned=owned, + peer=_Peer(wire, []), + secrets=secrets, + rule_ids=MappingProxyType(rule_ids), + credentials=MappingProxyType(credentials), + deployments=MappingProxyType(deployments), + ) + + +def _call(rig: FederationRig, client: str, model: str, marker: str) -> None: + base_url: Final = str(rig.owned.gateway.client.base_url) + key: Final = rig.owned.gateway.key + match client: + case "chat": + completion: Final = openai.OpenAI( + base_url=base_url + "/v1", api_key=key, max_retries=0 + ).chat.completions.create(model=model, messages=[{"role": "user", "content": prompt(marker)}]) + assert completion.choices[0].message.content == answer(marker), completion + case "messages": + message: Final = anthropic.Anthropic(base_url=base_url, api_key=key, max_retries=0).messages.create( + model=model, max_tokens=64, messages=[{"role": "user", "content": prompt(marker)}] + ) + assert message.id == identity(marker), message + assert [block.text for block in message.content if block.type == "text"] == [answer(marker)], message + case "messages_stream": + with anthropic.Anthropic(base_url=base_url, api_key=key, max_retries=0).messages.stream( + model=model, max_tokens=64, messages=[{"role": "user", "content": prompt(marker)}] + ) as stream: + final: Final = stream.get_final_message() + assert final.id == identity(marker), final + assert [block.text for block in final.content if block.type == "text"] == [answer(marker)], final + case _: + pytest.fail(f"unknown client {client!r}") + + +def _assert_assertion(rig: FederationRig, source: str, assertion: str, upstream: Sequence[Request]) -> None: + match source: + case "token_file": + assert assertion == rig.secrets.token_file.read_text() + case "secret_reference": + assert assertion == rig.secrets.identity_token + case "environment": + assert assertion == rig.secrets.environment_token + case "keycloak": + assert assertion == _KEYCLOAK_ASSERTION + grants: Final = tuple(request for request in upstream if request.target == _KEYCLOAK_TARGET) + assert grants, upstream + for grant in grants: + assert grant.headers.get("content-type") == "application/x-www-form-urlencoded", grant.headers + assert "authorization" not in grant.headers, grant.headers + assert parse_qs(grant.body.decode()) == { + "grant_type": ["client_credentials"], + "client_id": ["litellm-integration"], + "client_secret": [rig.secrets.keycloak_secret], + "scope": ["openid"], + }, grant.body + case "internal_issuer": + exported: Final = rig.owned.gateway.get(f"/credentials/{rig.credentials[source]}/jwks")["keys"] + assert isinstance(exported, list) and len(exported) == 1, exported + jwk: Final = object_value(exported[0]) + header: Final = jwt.get_unverified_header(assertion) + assert (header["alg"], header["kid"]) == ("ES256", jwk["kid"]), (header, jwk) + claims: Final = jwt.decode( + assertion, + jwt.PyJWK(dict(jwk)).key, + algorithms=["ES256"], + audience=_AUDIENCE, + issuer=_ISSUER, + options={"verify_exp": False}, + ) + assert claims["sub"] == _SUBJECT, claims + assert claims["exp"] - claims["iat"] == _TTL_SECONDS, claims + assert claims["jti"], claims + case _: + pytest.fail(f"unknown identity source {source!r}") + + +@pytest.mark.timeout(240) +@pytest.mark.parametrize("client", CLIENTS) +@pytest.mark.parametrize("source", SOURCES) +def test_federated_exchange(federation: FederationRig, source: str, client: str) -> None: + marker: Final = uuid.uuid4().hex + rule_id: Final = federation.rule_ids[source] + _call(federation, client, federation.deployments[source], marker) + upstream: Final = federation.peer.requests() + sent: Final = tuple( + request for request in upstream if request.target == "/v1/messages" and marker in request.body.decode() + ) + assert len(sent) == 1, upstream + assert sent[0].headers.get("authorization") == f"Bearer {ANTHROPIC_OAUTH_TOKEN_PREFIX}01-exchanged-{rule_id}", sent[ + 0 + ].headers + assert "oauth-2025-04-20" in sent[0].headers.get("anthropic-beta", ""), sent[0].headers + assert "x-api-key" not in sent[0].headers, sent[0].headers + exchanges: Final = tuple( + JSON_OBJECT.validate_json(request.body) for request in upstream if request.target == "/v1/oauth/token" + ) + mine: Final = tuple(grant for grant in exchanges if grant["federation_rule_id"] == rule_id) + assert mine, exchanges + for grant in mine: + assert grant["grant_type"] == _JWT_BEARER, grant + assert (grant["organization_id"], grant["service_account_id"], grant["workspace_id"]) == ( + "org-integration", + "svac-integration", + "wrkspc-integration", + ), grant + _assert_assertion(federation, source, string_value(grant["assertion"]), upstream) + + +@pytest.mark.timeout(240) +def test_token_file_outside_allowed_dirs_is_refused_before_any_exchange( + federation: FederationRig, tmp_path: Path +) -> None: + stray: Final = tmp_path.resolve() / "anthropic-identity-token" + stray.write_text("stray-token") + rule_id: Final = f"fdrl-stray-{uuid.uuid4().hex}" + marker: Final = uuid.uuid4().hex + owned: Final = federation.owned + with owned.gateway.scenario() as scenario: + name: Final = _create(owned.gateway, scenario, _shape("token_file", rule_id, token_file=str(stray))) + model: Final = _federated_deployment(owned.gateway, scenario, name, federation.peer.wire.url) + _deployments_visible(owned.gateway, (model,)) + response: Final = _chat(owned.gateway, model, marker) + assert response.status_code == 401, response.text + assert "LITELLM_OIDC_ALLOWED_CREDENTIAL_DIRS" in response.text, response.text + upstream: Final = federation.peer.requests() + assert not any(rule_id in request.body.decode() for request in upstream), upstream + assert not any(marker in request.body.decode() for request in upstream), upstream + assert owned.gateway.request("GET", "/health/liveliness").status_code == 200 + control: Final = scenario.model() + assert _chat_outcome(owned.gateway, control)[0] == 200 + + +def _is_worker(child: psutil.Process) -> bool: + try: + return "spawn_main" in " ".join(child.cmdline()) and child.status() != psutil.STATUS_ZOMBIE + except psutil.Error: + return False + + +def _workers(owned: OwnedProxy) -> tuple[psutil.Process, ...]: + return tuple(child for child in psutil.Process(owned.process.pid).children() if _is_worker(child)) + + +@pytest.mark.timeout(240) +def test_worker_kill_mid_credential_burst(gateway: Gateway, tmp_path: Path) -> None: + directory: Final = tmp_path.resolve() + secrets: Final = _secrets(directory) + with wire_server(_federation_peer) as wire: + with owned_proxy_process( + gateway, directory, secrets.overrides(), remove_environment=_REMOVED_ENVIRONMENT, workers=2 + ) as owned: + with owned.gateway.scenario() as scenario: + rule_id: Final = f"fdrl-burst-{uuid.uuid4().hex}" + credential: Final = _create( + owned.gateway, scenario, _shape("token_file", rule_id, token_file=str(secrets.token_file)) + ) + model: Final = _federated_deployment(owned.gateway, scenario, credential, wire.url) + _deployments_visible(owned.gateway, (model,)) + assert _chat_outcome(owned.gateway, model) == (200, True) + names: Final = tuple(_credential_name() for _ in range(24)) + for name in names: + scenario.cleanups.callback(_delete_if_present, owned.gateway, name) + + def create(name: str) -> tuple[str, int | str]: + try: + response: Final = owned.gateway.request( + "POST", "/credentials", _create_body(name, _shape("token_file", f"fdrl-{name}")) + ) + except httpx.TransportError as error: + return name, type(error).__name__ + return name, response.status_code + + victim: Final = eventually(lambda: _workers(owned), lambda workers: len(workers) == 2)[0] + with ThreadPoolExecutor(max_workers=8) as pool: + futures: Final = tuple(pool.submit(create, name) for name in names) + victim.kill() + outcomes: Final = tuple(future.result() for future in futures) + assert all(status == 200 or isinstance(status, str) for _, status in outcomes), outcomes + landed: Final = tuple(name for name, status in outcomes if status == 200) + for name in landed: + assert len(read_rows(_CREDENTIAL_QUERY, (name,))) == 1, name + respawned: Final = eventually( + lambda: frozenset(worker.pid for worker in _workers(owned)), + lambda pids: len(pids) == 2 and victim.pid not in pids, + seconds=30, + ) + assert f"Child process [{victim.pid}] died" in owned.log.read_text(), respawned + with httpx.Client( + base_url=owned.gateway.client.base_url, + timeout=15, + trust_env=False, + limits=httpx.Limits(max_keepalive_connections=0), + ) as fresh: + survivor: Final = Gateway(fresh, owned.gateway.key, owned.gateway.upstream_url) + for name in landed: + _converged(survivor, name, _shape("token_file", f"fdrl-{name}")) + _stable(partial(_chat_outcome, survivor, model), lambda outcome: outcome == (200, True)) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/credentials/useCredentials.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/credentials/useCredentials.ts index e3266de4fbc..bdbf4445514 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/credentials/useCredentials.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/credentials/useCredentials.ts @@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { createQueryKeys } from "../common/queryKeysFactory"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -const credentialsKeys = createQueryKeys("credentials"); +export const credentialsKeys = createQueryKeys("credentials"); export const useCredentials = () => { const { accessToken } = useAuthorized(); diff --git a/ui/litellm-dashboard/src/components/add_model/AddModelForm.integration.test.tsx b/ui/litellm-dashboard/src/components/add_model/AddModelForm.integration.test.tsx index 536bc231008..12efc5beb7c 100644 --- a/ui/litellm-dashboard/src/components/add_model/AddModelForm.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AddModelForm.integration.test.tsx @@ -1,8 +1,8 @@ -import { renderHook, screen, waitFor, renderWithProviders } from "../../../tests/test-utils"; +import { fireEvent, renderHook, screen, waitFor, within, renderWithProviders } from "../../../tests/test-utils"; import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import type { Team } from "../key_team_helpers/key_list"; -import type { CredentialItem } from "../networking"; +import { credentialCreateCall, type CredentialItem } from "../networking"; import { Providers } from "../provider_info_helpers"; import { projectMountedValues, useMountRegistry, type MountedFormValues } from "../common_components/MountedFormField"; import { useForm } from "react-hook-form"; @@ -34,6 +34,7 @@ vi.mock("../networking", async () => { ], }), testConnectionRequest: vi.fn().mockResolvedValue({ status: "success" }), + credentialCreateCall: vi.fn().mockResolvedValue({ success: true }), getProviderCreateMetadata: vi.fn().mockResolvedValue([ { provider: "OpenAI", @@ -355,6 +356,74 @@ describe("AddModelForm", () => { }); }); + describe("workload identity federation entry point", () => { + const renderAsRole = async (userRole: string, selectedProvider: Providers) => { + const mockUseAuthorized = vi.mocked(await import("@/app/(dashboard)/hooks/useAuthorized")); + mockUseAuthorized.default.mockReturnValue(mockAuthorizedUser(userRole, "user-1", true)); + const props = { ...createTestProps(userRole, "user-1", false), selectedProvider }; + renderWithProviders(); + await screen.findByText("Existing Credentials"); + return props; + }; + + const fill = (label: string | RegExp, value: string) => + fireEvent.change(screen.getByLabelText(label), { target: { value } }); + + it("saves the federation fields as a credential and attaches it to the model", async () => { + const user = userEvent.setup(); + const props = await renderAsRole("proxy_admin", Providers.Anthropic); + + await user.click(screen.getByRole("button", { name: "Use workload identity federation" })); + expect(await screen.findByRole("combobox", { name: /^Authentication:/ })).toHaveTextContent( + "Workload identity federation", + ); + fill("Credential Name:", "anthropic-federated"); + fill(/Federation Rule ID/, "fdrl_new"); + fill(/Organization ID/, "org-new"); + fill(/Identity Token File/, "/var/run/secrets/anthropic/token"); + await user.click(screen.getByRole("button", { name: "Add Credential" })); + + await waitFor(() => { + expect(credentialCreateCall).toHaveBeenCalledWith("test-access-token", { + credential_name: "anthropic-federated", + credential_values: { + anthropic_federation_rule_id: "fdrl_new", + anthropic_organization_id: "org-new", + anthropic_identity_token_file: "/var/run/secrets/anthropic/token", + }, + credential_info: { custom_llm_provider: Providers.Anthropic }, + }); + }); + await waitFor(() => { + expect(props.form.getValues("litellm_credential_name")).toBe("anthropic-federated"); + }); + expect(props.mountedValues()).not.toHaveProperty("anthropic_federation_rule_id"); + }); + + it("keeps the credential dialog on the model's provider", async () => { + const user = userEvent.setup(); + await renderAsRole("proxy_admin", Providers.Anthropic); + + await user.click(screen.getByRole("button", { name: "Use workload identity federation" })); + + const providerSelect = within(await screen.findByRole("dialog")).getByPlaceholderText("Select a provider"); + expect(providerSelect).toHaveValue("Anthropic"); + expect(providerSelect).toBeDisabled(); + }); + + it("is not offered for a provider without federation support", async () => { + await renderAsRole("proxy_admin", Providers.OpenAI); + + expect(screen.queryByRole("button", { name: "Use workload identity federation" })).not.toBeInTheDocument(); + }); + + it("is not offered to an admin who is not a proxy admin", async () => { + await renderAsRole("org_admin", Providers.Anthropic); + + expect(screen.queryByRole("button", { name: "Use workload identity federation" })).not.toBeInTheDocument(); + }); + }); + describe("cache control bindings reach the parent form store", () => { const renderWithForm = async () => { const mockUseAuthorized = vi.mocked(await import("@/app/(dashboard)/hooks/useAuthorized")); diff --git a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx index 30b4911da3e..a416bf87602 100644 --- a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx @@ -24,7 +24,10 @@ import { type MountedFormValues, } from "../common_components/MountedFormField"; import type { Team } from "../key_team_helpers/key_list"; -import { type CredentialItem, type ProviderCreateInfo, modelAvailableCall } from "../networking"; +import { type CredentialItem, type ProviderCreateInfo, credentialCreateCall, modelAvailableCall } from "../networking"; +import CredentialModal from "../model_add/CredentialModal"; +import { isAnthropicProvider } from "../model_add/anthropic_federation"; +import { buildCredential, withoutRestrictedFields } from "../model_add/credential_form_helpers"; import { ProviderLogo } from "../molecules/models/ProviderLogo"; import AccessGroupTagsCombobox from "./AccessGroupTagsCombobox"; import AdvancedSettings from "./advanced_settings"; @@ -34,6 +37,11 @@ import ConnectionErrorDisplay from "./model_connection_test"; import ProviderSpecificFields from "./provider_specific_fields"; import { TEST_MODES } from "./add_model_modes"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { credentialsKeys } from "@/app/(dashboard)/hooks/credentials/useCredentials"; +import { extractProxyErrorMessage } from "@/lib/http/client"; +import { toast } from "@/lib/toast"; +import { isProxyAdminRole } from "@/utils/roles"; +import { useQueryClient } from "@tanstack/react-query"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; interface AddModelFormProps { @@ -91,6 +99,23 @@ const AddModelForm: React.FC = ({ const guardrailsList = guardrailsData?.guardrails.map((g) => g.guardrail_name); const { data: tagsList } = useTags(); const selectedCredentialName = useWatch({ control: form.control, name: "litellm_credential_name" }); + const queryClient = useQueryClient(); + const [isFederatedCredentialModalOpen, setIsFederatedCredentialModalOpen] = useState(false); + const canCreateFederatedCredential = isProxyAdminRole(userRole ?? "") && isAnthropicProvider(selectedProvider); + + const handleCreateFederatedCredential = async (values: Record) => { + const credential = buildCredential(values, withoutRestrictedFields(values)); + try { + await credentialCreateCall(accessToken, credential); + } catch (error) { + toast.error(extractProxyErrorMessage(error)); + return; + } + toast.success("Credential added successfully"); + setIsFederatedCredentialModalOpen(false); + await queryClient.invalidateQueries({ queryKey: credentialsKeys.all }); + form.setValue("litellm_credential_name", credential.credential_name, { shouldDirty: true }); + }; const handleTestConnection = async () => { setIsTestingConnection(true); @@ -317,6 +342,20 @@ const AddModelForm: React.FC = ({
+ {canCreateFederatedCredential && ( +
+ + Workload identity federation is saved as a credential, then attached to this model. + + +
+ )} )}
@@ -447,6 +486,17 @@ const AddModelForm: React.FC = ({ + {isFederatedCredentialModalOpen && ( + setIsFederatedCredentialModalOpen(false)} + onSubmit={handleCreateFederatedCredential} + /> + )} {/* Test Connection Results Modal */} void) => { @@ -80,7 +81,7 @@ const mapFieldMetadataToUiField = (field: ProviderCredentialFieldMetadata): Prov const providerFieldsByDisplayName: Record = {}; -const ProviderSpecificFields: React.FC = ({ selectedProvider }) => { +const ProviderSpecificFields: React.FC = ({ selectedProvider, hiddenFieldKeys }) => { const selectedProviderEnum = Providers[selectedProvider as keyof typeof Providers] as Providers; const form = useFormContext(); const credentialsFileRef = React.useRef(null); @@ -130,7 +131,7 @@ const ProviderSpecificFields: React.FC = ({ selecte Object.assign(providerFieldsByDisplayName, cacheEntries); }, [cacheEntries]); - const allFields = React.useMemo(() => { + const providerFields = React.useMemo(() => { if (selectedProvider === null) return []; // First try to resolve from the in-memory cache. We support both the // enum/display-name form and the raw provider slug (e.g. "petals"). @@ -165,6 +166,11 @@ const ProviderSpecificFields: React.FC = ({ selecte return mapped; }, [selectedProviderEnum, selectedProvider, providerMetadata]); + const allFields = React.useMemo( + () => (hiddenFieldKeys ? providerFields.filter((field) => !hiddenFieldKeys.includes(field.key)) : providerFields), + [providerFields, hiddenFieldKeys], + ); + const hasApiVersionField = React.useMemo(() => allFields.some((field) => field.key === "api_version"), [allFields]); const lastInferredApiVersionRef = React.useRef(null); @@ -286,7 +292,7 @@ const ProviderSpecificFields: React.FC = ({ selecte

)} {allFields.map((field) => ( - + void; + storedValues: Record; +} + +const IDENTITY_SOURCE_SELECT_ID = "anthropic_federation_identity_source"; +const STORED_VALUE_MESSAGE_FIELD_KEY = FEDERATION_CORE_FIELDS[0].key; + +const fieldRules = (field: FederationField, storedValue: unknown, identitySource: IdentitySourceId) => ({ + validate: { + ...(field.required ? { required: requiredFederationValue } : {}), + ...(field.key === STORED_VALUE_MESSAGE_FIELD_KEY ? { stored: validateFederationValueStored(identitySource) } : {}), + ...(field.key === "anthropic_identity_token" ? { reference: validateIdentityTokenReference } : {}), + ...(field.control === "integer" ? { ttl: validateIssuerTtlSeconds } : {}), + masked: validateMaskedValueUntouched(storedValue), + }, +}); + +const selectItems = (field: FederationField, value: unknown) => [ + ...(isMaskedSecret(value) ? [{ value: value as string, label: `Stored: ${value as string}` }] : []), + ...(field.options ?? []).map((option) => ({ value: option, label: option })), +]; + +const renderControl = (field: FederationField, control: MountedFieldControlProps) => { + if (field.control === "select") { + const items = selectItems(field, control.value); + return ( + + ); + } + return ( + + ); +}; + +export default function AnthropicFederationFields({ + identitySource, + onIdentitySourceChange, + storedValues, +}: AnthropicFederationFieldsProps) { + const sourceFields = identitySourceById(identitySource).fields; + const identitySourceItems = identitySourceOptions(storedValues); + + const renderField = (field: FederationField) => ( + + {(control) => renderControl(field, control)} + + ); + + return ( + <> + {FEDERATION_CORE_FIELDS.map(renderField)} +
+ + + {identitySource === "unrecognized" && ( +

+ This form does not offer the stored identity source. Saving keeps it and its settings as stored. Pick + another source to replace it. +

+ )} + {identitySource === "environment" && ( +

+ The proxy reads ANTHROPIC_IDENTITY_TOKEN_FILE or ANTHROPIC_IDENTITY_TOKEN from its own environment. +

+ )} +
+ {sourceFields.map(renderField)} + + ); +} diff --git a/ui/litellm-dashboard/src/components/model_add/CredentialModal.integration.test.tsx b/ui/litellm-dashboard/src/components/model_add/CredentialModal.integration.test.tsx new file mode 100644 index 00000000000..d1b54219dd3 --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_add/CredentialModal.integration.test.tsx @@ -0,0 +1,444 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { chooseSelectOption } from "../../../tests/test-utils"; +import { Providers } from "../provider_info_helpers"; +import { CredentialItem } from "../networking"; +import CredentialModal from "./CredentialModal"; + +vi.mock("../networking", async () => { + const actual = await vi.importActual("../networking"); + return { + ...actual, + getProviderCreateMetadata: vi.fn().mockResolvedValue([ + { + provider: "OpenAI", + provider_display_name: Providers.OpenAI, + litellm_provider: "openai", + credential_fields: [ + { key: "api_base", label: "API Base", field_type: "text", default_value: "https://api.openai.com/v1" }, + { key: "api_key", label: "OpenAI API Key", field_type: "password" }, + ], + }, + { + provider: "Anthropic", + provider_display_name: Providers.Anthropic, + litellm_provider: "anthropic", + credential_fields: [ + { key: "api_base", label: "Upstream API Base", field_type: "text" }, + { key: "api_key", label: "API Key", field_type: "password" }, + ], + }, + { + provider: "Azure", + provider_display_name: Providers.Azure, + litellm_provider: "azure", + credential_fields: [ + { key: "api_base", label: "Azure API Base", field_type: "text" }, + { key: "api_version", label: "API Version", field_type: "text" }, + { key: "api_key", label: "Azure API Key", field_type: "password" }, + ], + }, + ]), + }; +}); + +const federatedCredential: CredentialItem = { + credential_name: "anthropic-federated", + credential_values: { + anthropic_federation_rule_id: "fdrl_stored", + anthropic_organization_id: "org-stored", + anthropic_identity_source: "keycloak", + anthropic_keycloak_token_url: "http****", + anthropic_keycloak_client_id: "lite****", + anthropic_keycloak_client_secret_ref: "os.e****", + }, + credential_info: { custom_llm_provider: "anthropic" }, +}; + +const azureCredential: CredentialItem = { + credential_name: "azure-prod", + credential_values: { api_base: "https://corp.openai.azure.com", api_version: "2024-10-21", api_key: "sk-1****" }, + credential_info: { custom_llm_provider: "Azure" }, +}; + +const keyAndFederationCredential: CredentialItem = { + credential_name: "anthropic-key-and-federation", + credential_values: { + api_key: "sk-a****", + anthropic_federation_rule_id: "fdrl_stored", + anthropic_organization_id: "org-stored", + anthropic_identity_token_file: "/var/run/secrets/anthropic/token", + }, + credential_info: { custom_llm_provider: "anthropic" }, +}; + +const unknownSourceCredential: CredentialItem = { + credential_name: "anthropic-unknown-source", + credential_values: { + anthropic_federation_rule_id: "fdrl_stored", + anthropic_organization_id: "org-stored", + anthropic_identity_source: "spiffe", + }, + credential_info: { custom_llm_provider: "anthropic" }, +}; + +const renderModal = (props: Partial> = {}) => { + const onSubmit = vi.fn(); + render( + + + , + ); + return onSubmit; +}; + +const fill = (label: string | RegExp, value: string) => + fireEvent.change(screen.getByLabelText(label), { target: { value } }); + +const chooseOption = (user: ReturnType, select: RegExp, option: string | RegExp) => + chooseSelectOption(user, screen.getByRole("combobox", { name: select }), option); + +const chooseProvider = async (user: ReturnType, provider: string) => { + const providerSelect = screen.getByRole("combobox", { name: /Provider/ }); + await user.clear(providerSelect); + await user.type(providerSelect, provider); + const options = await screen.findAllByRole("option"); + await user.click(options.find((option) => option.textContent === provider) ?? options[0]); +}; + +describe("CredentialModal with Anthropic workload identity federation", () => { + it("does not carry the previous provider's base URL into the Anthropic form or its payload", async () => { + const user = userEvent.setup(); + const onSubmit = renderModal(); + expect(await screen.findByLabelText("API Base")).toHaveValue("https://api.openai.com/v1"); + + const providerSelect = screen.getByRole("combobox", { name: /Provider/ }); + await user.click(providerSelect); + await user.type(providerSelect, "Anthropic"); + await user.click((await screen.findAllByRole("option"))[0]); + + expect(await screen.findByLabelText("Upstream API Base")).toHaveValue(""); + fill("Credential Name:", "anthropic-key"); + await user.click(screen.getByRole("button", { name: "Add Credential" })); + expect(onSubmit).toHaveBeenCalledWith({ credential_name: "anthropic-key", custom_llm_provider: "Anthropic" }, []); + }); + + it("creates a federated credential from the federation fields and never sends an API key", async () => { + const user = userEvent.setup(); + const onSubmit = renderModal({ initialProvider: "Anthropic" }); + await screen.findByLabelText("API Key"); + fill("API Key", "sk-ant-typed-before-switching"); + + await chooseOption(user, /^Authentication:/, "Workload identity federation"); + + expect(screen.queryByLabelText("API Key")).not.toBeInTheDocument(); + fill("Credential Name:", "anthropic-federated"); + fill(/Federation Rule ID/, "fdrl_new"); + fill(/Organization ID/, "org-new"); + fill(/Identity Token File/, "/var/run/secrets/anthropic/token"); + await user.click(screen.getByRole("button", { name: "Add Credential" })); + + const expectedPayload = { + credential_name: "anthropic-federated", + custom_llm_provider: "Anthropic", + anthropic_federation_rule_id: "fdrl_new", + anthropic_organization_id: "org-new", + anthropic_identity_token_file: "/var/run/secrets/anthropic/token", + }; + expect(onSubmit).toHaveBeenCalledWith(expectedPayload, []); + }); + + it("names the identity source when the admin picks Keycloak", async () => { + const user = userEvent.setup(); + const onSubmit = renderModal({ initialProvider: "Anthropic", initialAuthMethod: "federation" }); + await screen.findByLabelText("Upstream API Base"); + + await chooseOption(user, /Identity Source/, "Keycloak client credentials"); + + expect(screen.queryByLabelText(/Identity Token File/)).not.toBeInTheDocument(); + fill("Credential Name:", "anthropic-keycloak"); + fill(/Federation Rule ID/, "fdrl_new"); + fill(/Organization ID/, "org-new"); + fill(/Keycloak Token URL/, "https://keycloak.example.com/realms/litellm/protocol/openid-connect/token"); + fill(/Keycloak Client ID/, "litellm-proxy"); + fill(/Client Secret Reference/, "os.environ/KEYCLOAK_CLIENT_SECRET"); + await user.click(screen.getByRole("button", { name: "Add Credential" })); + + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + anthropic_identity_source: "keycloak", + anthropic_keycloak_client_secret_ref: "os.environ/KEYCLOAK_CLIENT_SECRET", + }), + [], + ); + }); + + it("refuses to save a federated credential that is missing its identity token file", async () => { + const user = userEvent.setup(); + const onSubmit = renderModal({ initialProvider: "Anthropic", initialAuthMethod: "federation" }); + await screen.findByLabelText("Upstream API Base"); + fill("Credential Name:", "anthropic-federated"); + fill(/Federation Rule ID/, "fdrl_new"); + + await user.click(screen.getByRole("button", { name: "Add Credential" })); + + expect(await screen.findAllByText("Required")).not.toHaveLength(0); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("shows a stored federated credential's fields and writes nothing when it is saved untouched", async () => { + const user = userEvent.setup(); + const onSubmit = renderModal({ mode: "edit", existingCredential: federatedCredential }); + + expect(await screen.findByRole("combobox", { name: /^Authentication:/ })).toHaveTextContent( + "Workload identity federation", + ); + expect(screen.getByRole("combobox", { name: /Identity Source/ })).toHaveTextContent("Keycloak client credentials"); + expect(screen.getByLabelText(/Federation Rule ID/)).toHaveValue("fdrl_stored"); + expect(screen.getByLabelText(/Client Secret Reference/)).toHaveValue("os.e****"); + expect(screen.queryByLabelText("API Key")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Update Credential" })); + + expect(onSubmit).toHaveBeenCalledWith( + { credential_name: "anthropic-federated", custom_llm_provider: "anthropic" }, + [], + ); + }); + + it("sends only the federation value the admin edited", async () => { + const user = userEvent.setup(); + const onSubmit = renderModal({ mode: "edit", existingCredential: federatedCredential }); + await screen.findByLabelText("Upstream API Base"); + fill(/Organization ID/, "org-edited"); + + await user.click(screen.getByRole("button", { name: "Update Credential" })); + + expect(onSubmit).toHaveBeenCalledWith( + { + credential_name: "anthropic-federated", + custom_llm_provider: "anthropic", + anthropic_organization_id: "org-edited", + }, + [], + ); + }); + + it("deletes the stored federation values when the admin switches the credential to an API key", async () => { + const user = userEvent.setup(); + const onSubmit = renderModal({ mode: "edit", existingCredential: federatedCredential }); + await screen.findByLabelText("Upstream API Base"); + + await chooseOption(user, /^Authentication:/, "API key"); + await screen.findByLabelText("API Key"); + fill("API Key", "sk-ant-replacement"); + await user.click(screen.getByRole("button", { name: "Update Credential" })); + + const [values, valuesToDelete] = onSubmit.mock.calls[0]; + expect(values).toEqual({ + credential_name: "anthropic-federated", + custom_llm_provider: "anthropic", + api_key: "sk-ant-replacement", + }); + expect([...valuesToDelete].sort()).toEqual(Object.keys(federatedCredential.credential_values).sort()); + }); + + it("drops another provider's stored values when the admin turns the credential into a federated Anthropic one", async () => { + const user = userEvent.setup(); + const onSubmit = renderModal({ mode: "edit", existingCredential: azureCredential }); + expect(await screen.findByLabelText("Azure API Base")).toHaveValue("https://corp.openai.azure.com"); + + await chooseProvider(user, "Anthropic"); + expect(await screen.findByLabelText("Upstream API Base")).toHaveValue(""); + await chooseOption(user, /^Authentication:/, "Workload identity federation"); + fill(/Federation Rule ID/, "fdrl_new"); + fill(/Organization ID/, "org-new"); + fill(/Identity Token File/, "/var/run/secrets/anthropic/token"); + await user.click(screen.getByRole("button", { name: "Update Credential" })); + + const [values, valuesToDelete] = onSubmit.mock.calls[0]; + const expectedValues = { + credential_name: "azure-prod", + custom_llm_provider: "Anthropic", + anthropic_federation_rule_id: "fdrl_new", + anthropic_organization_id: "org-new", + anthropic_identity_token_file: "/var/run/secrets/anthropic/token", + }; + expect(values).toEqual(expectedValues); + expect([...valuesToDelete].sort()).toEqual(["api_base", "api_key", "api_version"]); + }); + + it("shows the stored values again when the admin returns to the credential's own provider", async () => { + const user = userEvent.setup(); + const onSubmit = renderModal({ mode: "edit", existingCredential: azureCredential }); + await screen.findByLabelText("Azure API Base"); + + await chooseProvider(user, "Anthropic"); + await screen.findByLabelText("Upstream API Base"); + await chooseProvider(user, "Azure"); + + expect(await screen.findByLabelText("Azure API Base")).toHaveValue("https://corp.openai.azure.com"); + await user.click(screen.getByRole("button", { name: "Update Credential" })); + expect(onSubmit).toHaveBeenCalledWith({ credential_name: "azure-prod", custom_llm_provider: "Azure" }, []); + }); + + it("deletes the stored base URL when the admin clears it on a federated credential", async () => { + const user = userEvent.setup(); + const stored = { + ...federatedCredential, + credential_values: { ...federatedCredential.credential_values, api_base: "https://gateway.example.com" }, + }; + const onSubmit = renderModal({ mode: "edit", existingCredential: stored }); + expect(await screen.findByLabelText("Upstream API Base")).toHaveValue("https://gateway.example.com"); + + fill("Upstream API Base", ""); + await user.click(screen.getByRole("button", { name: "Update Credential" })); + + expect(onSubmit).toHaveBeenCalledWith( + { credential_name: "anthropic-federated", custom_llm_provider: "anthropic" }, + ["api_base"], + ); + }); + + it("deletes the stored federation values when the admin moves a federated credential to another provider", async () => { + const user = userEvent.setup(); + const onSubmit = renderModal({ mode: "edit", existingCredential: federatedCredential }); + await screen.findByLabelText(/Federation Rule ID/); + + await chooseProvider(user, "OpenAI"); + await screen.findByLabelText("OpenAI API Key"); + fill("OpenAI API Key", "sk-openai-new"); + await user.click(screen.getByRole("button", { name: "Update Credential" })); + + const [values, valuesToDelete] = onSubmit.mock.calls[0]; + expect(values).toMatchObject({ custom_llm_provider: "OpenAI", api_key: "sk-openai-new" }); + expect([...valuesToDelete].sort()).toEqual(Object.keys(federatedCredential.credential_values).sort()); + }); + + it("opens a credential that stores an API key next to federation values as the API key credential the proxy uses", async () => { + const user = userEvent.setup(); + const onSubmit = renderModal({ mode: "edit", existingCredential: keyAndFederationCredential }); + expect(await screen.findByRole("combobox", { name: /^Authentication:/ })).toHaveTextContent("API key"); + expect(await screen.findByLabelText("API Key")).toHaveValue("sk-a****"); + + await chooseOption(user, /^Authentication:/, "Workload identity federation"); + expect(screen.getByLabelText(/Federation Rule ID/)).toHaveValue("fdrl_stored"); + await user.click(screen.getByRole("button", { name: "Update Credential" })); + + expect(onSubmit).toHaveBeenCalledWith( + { credential_name: "anthropic-key-and-federation", custom_llm_provider: "anthropic" }, + ["api_key"], + ); + }); + + it("keeps what the admin typed in the form after submitting, so a failed save loses nothing", async () => { + const user = userEvent.setup(); + const onSubmit = renderModal({ initialProvider: "Anthropic", initialAuthMethod: "federation" }); + await screen.findByLabelText("Upstream API Base"); + fill("Credential Name:", "anthropic-federated"); + fill(/Federation Rule ID/, "fdrl_new"); + fill(/Organization ID/, "org-new"); + fill(/Identity Token File/, "/var/run/secrets/anthropic/token"); + + await user.click(screen.getByRole("button", { name: "Add Credential" })); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(screen.getByLabelText("Credential Name:")).toHaveValue("anthropic-federated"); + expect(screen.getByLabelText(/Federation Rule ID/)).toHaveValue("fdrl_new"); + expect(screen.getByLabelText(/Identity Token File/)).toHaveValue("/var/run/secrets/anthropic/token"); + }); + + it("refuses a required federation field that holds only whitespace", async () => { + const user = userEvent.setup(); + const onSubmit = renderModal({ initialProvider: "Anthropic", initialAuthMethod: "federation" }); + await screen.findByLabelText("Upstream API Base"); + await chooseOption(user, /Identity Source/, "Token signed by LiteLLM (internal issuer)"); + fill("Credential Name:", "anthropic-issuer"); + fill(/Federation Rule ID/, "fdrl_new"); + fill(/Organization ID/, "org-new"); + fill(/Issuer URL/, "https://litellm.example.com"); + fill(/^Subject/, " "); + fill(/Signing Key Reference/, "os.environ/ANTHROPIC_ISSUER_SIGNING_KEY"); + + await user.click(screen.getByRole("button", { name: "Add Credential" })); + + expect(await screen.findByText("Required")).toBeInTheDocument(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("shows an identity source it does not recognize as stored and leaves it alone on save", async () => { + const user = userEvent.setup(); + const onSubmit = renderModal({ mode: "edit", existingCredential: unknownSourceCredential }); + + const sourceSelect = await screen.findByRole("combobox", { name: /Identity Source/ }); + expect(sourceSelect).toHaveTextContent("spiffe"); + expect(sourceSelect).not.toHaveTextContent("Proxy environment variables"); + fill(/Organization ID/, "org-edited"); + await user.click(screen.getByRole("button", { name: "Update Credential" })); + + expect(onSubmit).toHaveBeenCalledWith( + { + credential_name: "anthropic-unknown-source", + custom_llm_provider: "anthropic", + anthropic_organization_id: "org-edited", + }, + [], + ); + }); + + it("replaces an identity source it does not recognize when the admin picks one it offers", async () => { + const user = userEvent.setup(); + const onSubmit = renderModal({ mode: "edit", existingCredential: unknownSourceCredential }); + await screen.findByRole("combobox", { name: /Identity Source/ }); + + await chooseOption(user, /Identity Source/, "Proxy environment variables"); + await user.click(screen.getByRole("button", { name: "Update Credential" })); + + expect(onSubmit).toHaveBeenCalledWith( + { credential_name: "anthropic-unknown-source", custom_llm_provider: "anthropic" }, + ["anthropic_identity_source"], + ); + }); + + it("refuses a proxy environment credential with no ids until the admin enters one", async () => { + const user = userEvent.setup(); + const onSubmit = renderModal({ initialProvider: "Anthropic", initialAuthMethod: "federation" }); + await screen.findByLabelText("Upstream API Base"); + await chooseOption(user, /Identity Source/, "Proxy environment variables"); + fill("Credential Name:", "anthropic-env"); + + await user.click(screen.getByRole("button", { name: "Add Credential" })); + + expect(await screen.findByText(/Enter at least one of these ids/)).toBeInTheDocument(); + expect(onSubmit).not.toHaveBeenCalled(); + + fill(/Organization ID/, "org-new"); + await user.click(screen.getByRole("button", { name: "Add Credential" })); + + expect(onSubmit).toHaveBeenCalledWith( + { credential_name: "anthropic-env", custom_llm_provider: "Anthropic", anthropic_organization_id: "org-new" }, + [], + ); + }); + + it("saves a federated credential that leaves the rule id and organization id to the proxy environment", async () => { + const user = userEvent.setup(); + const onSubmit = renderModal({ initialProvider: "Anthropic", initialAuthMethod: "federation" }); + await screen.findByLabelText("Upstream API Base"); + fill("Credential Name:", "anthropic-env-ids"); + fill(/Identity Token File/, "/var/run/secrets/anthropic/token"); + + await user.click(screen.getByRole("button", { name: "Add Credential" })); + + expect(onSubmit).toHaveBeenCalledWith( + { + credential_name: "anthropic-env-ids", + custom_llm_provider: "Anthropic", + anthropic_identity_token_file: "/var/run/secrets/anthropic/token", + }, + [], + ); + }); +}); diff --git a/ui/litellm-dashboard/src/components/model_add/CredentialModal.tsx b/ui/litellm-dashboard/src/components/model_add/CredentialModal.tsx index fdc02b4fc41..202274d2aa2 100644 --- a/ui/litellm-dashboard/src/components/model_add/CredentialModal.tsx +++ b/ui/litellm-dashboard/src/components/model_add/CredentialModal.tsx @@ -17,7 +17,20 @@ import { import { CredentialItem } from "../networking"; import { Providers } from "../provider_info_helpers"; import { Logo } from "@/components/molecules/logo/Logo"; -import { resetCredentialFormOnProviderChange } from "./credential_form_helpers"; +import { resetCredentialFormOnProviderChange, withoutRestrictedFields } from "./credential_form_helpers"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import AnthropicFederationFields from "./AnthropicFederationFields"; +import { + buildCreateCredentialValues, + buildCredentialPatch, + buildProviderChangePatch, + inferAuthMethod, + inferIdentitySource, + isAnthropicProvider, + isFederatedCredential, + type AnthropicAuthMethod, + type IdentitySourceId, +} from "./anthropic_federation"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; const providerOptions: SearchSelectOption[] = Object.entries(Providers).map(([providerEnum, providerDisplayName]) => ({ @@ -26,44 +39,84 @@ const providerOptions: SearchSelectOption[] = Object.entries(Providers).map(([pr icon: , })); +const AUTH_METHOD_SELECT_ID = "anthropic_auth_method"; +const API_KEY_FIELDS: readonly string[] = ["api_key"]; +const NO_HIDDEN_FIELDS: readonly string[] = []; + +const authMethodItems: { value: AnthropicAuthMethod; label: string }[] = [ + { value: "api_key", label: "API key" }, + { value: "federation", label: "Workload identity federation" }, +]; + interface CredentialModalProps { open: boolean; onCancel: () => void; - onSubmit: (values: any) => void; + onSubmit: (values: Record, valuesToDelete: readonly string[]) => void; mode: "add" | "edit"; existingCredential?: CredentialItem | null; + initialProvider?: string | null; + initialAuthMethod?: AnthropicAuthMethod; + providerLocked?: boolean; } +const sameProvider = (left: string | null | undefined, right: string | null | undefined): boolean => + (left ?? "").toLowerCase() === (right ?? "").toLowerCase(); + +const initialFormValues = ( + existingCredential: CredentialItem | null | undefined, + initialProvider: string | null | undefined, +): MountedFormValues | undefined => { + if (existingCredential) { + return { + credential_name: existingCredential.credential_name, + custom_llm_provider: existingCredential.credential_info.custom_llm_provider, + ...Object.fromEntries( + Object.entries(existingCredential.credential_values || {}).map(([key, value]) => [key, value ?? null]), + ), + }; + } + return initialProvider ? { custom_llm_provider: initialProvider } : undefined; +}; + export default function CredentialModal({ open, onCancel, onSubmit, mode, existingCredential = null, + initialProvider = null, + initialAuthMethod, + providerLocked = false, }: CredentialModalProps) { const isEdit = mode === "edit"; const [selectedProvider, setSelectedProvider] = useState( - (existingCredential?.credential_info.custom_llm_provider as Providers) ?? Providers.OpenAI, + (existingCredential?.credential_info.custom_llm_provider as Providers) ?? initialProvider ?? Providers.OpenAI, ); + const storedProvider = existingCredential?.credential_info.custom_llm_provider ?? null; + const storedValues: Record = existingCredential?.credential_values ?? {}; + const storedSelection = { + authMethod: inferAuthMethod(storedValues), + identitySource: inferIdentitySource(storedValues), + }; + const [authMethod, setAuthMethod] = useState( + existingCredential ? storedSelection.authMethod : initialAuthMethod ?? "api_key", + ); + const [identitySource, setIdentitySource] = useState( + isFederatedCredential(storedValues) ? storedSelection.identitySource : "token_file", + ); + const isAnthropic = isAnthropicProvider(selectedProvider); + const selection = { authMethod: isAnthropic ? authMethod : ("api_key" as const), identitySource }; - const initialValues = existingCredential - ? { - credential_name: existingCredential.credential_name, - custom_llm_provider: existingCredential.credential_info.custom_llm_provider, - ...Object.fromEntries( - Object.entries(existingCredential.credential_values || {}).map(([key, value]) => [key, value ?? null]), - ), - } - : undefined; + const initialValues = initialFormValues(existingCredential, initialProvider); const form = useForm({ mode: "onChange", defaultValues: initialValues }); const registry = useMountRegistry(); - const formAdapter = { + const formAdapterFor = (provider: string | null) => ({ getFieldValue: (field: string) => form.getValues(field), - resetFields: () => form.reset(), + resetFields: () => form.reset(isEdit && !sameProvider(provider, storedProvider) ? {} : initialValues), setFieldValue: (field: string, value: unknown) => form.setValue(field, value), - }; + }); const handleSubmit = async () => { const isValid = await form.trigger(registry.mountedNames() as string[]); @@ -71,14 +124,18 @@ export default function CredentialModal({ return; } const values = projectMountedValues(registry, form.getValues); - const filteredValues = Object.entries(values).reduce((acc, [key, value]) => { - if (value !== "" && value !== undefined && value !== null) { - acc[key] = value; - } - return acc; - }, {} as any); - onSubmit(filteredValues); - form.reset(); + const meta = { + credential_name: values.credential_name, + custom_llm_provider: values.custom_llm_provider, + }; + if (!isEdit) { + onSubmit({ ...meta, ...buildCreateCredentialValues(withoutRestrictedFields(values), selection) }, []); + return; + } + const patch = sameProvider(selectedProvider, storedProvider) + ? buildCredentialPatch(storedValues, withoutRestrictedFields(values), storedSelection, selection) + : buildProviderChangePatch(storedValues, withoutRestrictedFields(values), selection); + onSubmit({ ...meta, ...patch.credential_values }, patch.credential_values_to_delete); }; const closeAndReset = () => { @@ -132,15 +189,54 @@ export default function CredentialModal({ placeholder="Select a provider" options={providerOptions} value={typeof control.value === "string" ? control.value : null} + disabled={providerLocked} onValueChange={(value) => { control.onChange(value); - resetCredentialFormOnProviderChange(formAdapter, value, setSelectedProvider); + resetCredentialFormOnProviderChange(formAdapterFor(value), value, setSelectedProvider); }} /> )}
- + {isAnthropic && ( +
+ + +
+ )} + + + + {selection.authMethod === "federation" && ( + + )}
diff --git a/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.test.tsx b/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.test.tsx index 92885262dca..e6138151391 100644 --- a/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.test.tsx +++ b/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.test.tsx @@ -39,7 +39,7 @@ vi.mock("./CredentialModal", () => ({ }: { mode: "add" | "edit"; open: boolean; - onSubmit: (values: Record) => void; + onSubmit: (values: Record, valuesToDelete?: readonly string[]) => void; }) { if (!open) { return null; @@ -54,9 +54,19 @@ vi.mock("./CredentialModal", () => ({ } : { credential_name: "new-cred", custom_llm_provider: "openai" }; return ( - + <> + + + ); }, })); @@ -198,6 +208,37 @@ describe("CredentialsPanel", () => { expect(payload.credential_values).toEqual({ api_base: "https://proxy.e2e.example.com/v1" }); }); + it("sends the values to delete with the update and omits the field when there are none", async () => { + const user = userEvent.setup(); + mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" }); + mockUseCredentials.mockReturnValue({ data: { credentials }, isLoading: false, refetch: vi.fn() }); + vi.mocked(credentialUpdateCall).mockResolvedValue(undefined as never); + + renderPanel(); + + await user.click(screen.getByTestId("credential-actions-openai-key")); + await user.click(await screen.findByTestId("credential-action-edit")); + await user.click(screen.getByTestId("credential-modal-edit-switch-to-federation")); + await waitFor(() => { + expect(credentialUpdateCall).toHaveBeenCalledTimes(1); + }); + const [, , federatedPayload] = vi.mocked(credentialUpdateCall).mock.calls[0]; + expect(federatedPayload.credential_values).toEqual({ + api_base: "https://proxy.e2e.example.com/v1", + anthropic_federation_rule_id: "fdrl_new", + }); + expect(federatedPayload.credential_values_to_delete).toEqual(["api_key"]); + + await user.click(screen.getByTestId("credential-actions-openai-key")); + await user.click(await screen.findByTestId("credential-action-edit")); + await user.click(screen.getByTestId("credential-modal-edit-submit")); + await waitFor(() => { + expect(credentialUpdateCall).toHaveBeenCalledTimes(2); + }); + const [, , plainPayload] = vi.mocked(credentialUpdateCall).mock.calls[1]; + expect(plainPayload).not.toHaveProperty("credential_values_to_delete"); + }); + describe("Admin Viewer write-action gating", () => { // Admin Viewer can VIEW credentials but must not add / edit / delete them. it("hides the Add Credential button but still lists credentials", () => { diff --git a/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.tsx b/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.tsx index 80a8216dbaf..30a6a643d00 100644 --- a/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.tsx +++ b/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.tsx @@ -19,19 +19,7 @@ import DeleteResourceModal from "../common_components/DeleteResourceModal"; import { toast } from "@/lib/toast"; import CredentialModal from "./CredentialModal"; import CredentialsTable from "./CredentialsTable"; - -const restrictedFields = ["credential_name", "custom_llm_provider"]; - -const buildCredential = (values: Record, credentialValues: Record) => ({ - credential_name: values.credential_name as string, - credential_values: credentialValues, - credential_info: { - custom_llm_provider: values.custom_llm_provider as string, - }, -}); - -const withoutRestrictedFields = (values: Record): Record => - Object.fromEntries(Object.entries(values).filter(([key]) => !restrictedFields.includes(key))); +import { buildCredential, withoutRestrictedFields } from "./credential_form_helpers"; export default function CredentialsPanel() { const { accessToken, userRole } = useAuthorized(); @@ -47,13 +35,16 @@ export default function CredentialsPanel() { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isCredentialDeleting, setIsCredentialDeleting] = useState(false); - const handleUpdateCredential = async (values: Record) => { + const handleUpdateCredential = async (values: Record, valuesToDelete: readonly string[] = []) => { if (!accessToken) { return; } try { const newCredential = buildCredential(values, stripMaskedSecrets(withoutRestrictedFields(values))); - await credentialUpdateCall(accessToken, values.credential_name as string, newCredential); + await credentialUpdateCall(accessToken, values.credential_name as string, { + ...newCredential, + ...(valuesToDelete.length > 0 ? { credential_values_to_delete: valuesToDelete } : {}), + }); toast.success("Credential updated successfully"); setIsUpdateModalOpen(false); await refetchCredentials(); diff --git a/ui/litellm-dashboard/src/components/model_add/CredentialsTable.test.tsx b/ui/litellm-dashboard/src/components/model_add/CredentialsTable.test.tsx index f7f6b26fcdc..63399e01b18 100644 --- a/ui/litellm-dashboard/src/components/model_add/CredentialsTable.test.tsx +++ b/ui/litellm-dashboard/src/components/model_add/CredentialsTable.test.tsx @@ -63,6 +63,26 @@ describe("CredentialsTable", () => { expect(screen.getByText("Azure")).toBeInTheDocument(); }); + it("should mark only the credential that stores federation values as federated", () => { + const credentials: CredentialItem[] = [ + { + credential_name: "a-anthropic-federated", + credential_values: { anthropic_federation_rule_id: "fdrl_stored", anthropic_organization_id: "org-stored" }, + credential_info: { custom_llm_provider: "anthropic" }, + }, + { + credential_name: "b-anthropic-key", + credential_values: { api_key: "sk-a****" }, + credential_info: { custom_llm_provider: "anthropic" }, + }, + ]; + render(); + const [federatedRow, apiKeyRow] = screen.getAllByRole("row").slice(1); + expect(within(federatedRow).getByText("a-anthropic-federated")).toBeInTheDocument(); + expect(within(federatedRow).getByText("Workload identity federation")).toBeInTheDocument(); + expect(within(apiKeyRow).queryByText("Workload identity federation")).not.toBeInTheDocument(); + }); + it("should render a dash when a credential has no provider", () => { const credentials: CredentialItem[] = [ { credential_name: "no-provider", credential_values: {}, credential_info: {} }, diff --git a/ui/litellm-dashboard/src/components/model_add/CredentialsTableColumns.tsx b/ui/litellm-dashboard/src/components/model_add/CredentialsTableColumns.tsx index 048ad16177d..69ea0acd330 100644 --- a/ui/litellm-dashboard/src/components/model_add/CredentialsTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/model_add/CredentialsTableColumns.tsx @@ -7,6 +7,7 @@ import { CredentialItem } from "@/components/networking"; import { getProviderLogoAndName } from "@/components/provider_info_helpers"; import { DataTableSortHeader } from "@/components/shared/DataTable"; import { IdentityCell } from "@/components/shared/table_cells"; +import { Badge } from "@/components/ui/badge"; import { buttonVariants } from "@/components/ui/button"; import { DropdownMenu, @@ -18,7 +19,9 @@ import { import { cn } from "@/lib/cva.config"; import { copyToClipboard } from "@/utils/dataUtils"; -function CredentialProviderCell({ provider }: { provider: string | undefined }) { +import { isFederatedCredential } from "./anthropic_federation"; + +function CredentialProviderCell({ provider, federated }: { provider: string | undefined; federated: boolean }) { if (!provider) { return -; } @@ -36,6 +39,7 @@ function CredentialProviderCell({ provider }: { provider: string | undefined }) /> ) : null} {displayName || provider} + {federated && Workload identity federation}
); } @@ -110,9 +114,14 @@ export const getCredentialsTableColumns = ({ accessorKey: "credential_info.custom_llm_provider", meta: { title: "Provider" }, header: "Provider", - size: 200, + size: 320, enableSorting: false, - cell: ({ row }) => , + cell: ({ row }) => ( + + ), }, ]; diff --git a/ui/litellm-dashboard/src/components/model_add/anthropic_federation.test.ts b/ui/litellm-dashboard/src/components/model_add/anthropic_federation.test.ts new file mode 100644 index 00000000000..afc9f27fe19 --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_add/anthropic_federation.test.ts @@ -0,0 +1,335 @@ +import { describe, expect, it } from "vitest"; +import { + buildCreateCredentialValues, + buildCredentialPatch, + buildProviderChangePatch, + inferAuthMethod, + identitySourceOptions, + inferIdentitySource, + isAnthropicProvider, + isFederatedCredential, + MAX_ISSUER_TTL_SECONDS, + validateFederationValueStored, + validateIdentityTokenReference, + validateIssuerTtlSeconds, + validateMaskedValueUntouched, +} from "./anthropic_federation"; + +const apiKeySelection = { authMethod: "api_key", identitySource: "token_file" } as const; +const tokenFileSelection = { authMethod: "federation", identitySource: "token_file" } as const; +const internalIssuerSelection = { authMethod: "federation", identitySource: "internal_issuer" } as const; +const keycloakSelection = { authMethod: "federation", identitySource: "keycloak" } as const; +const environmentSelection = { authMethod: "federation", identitySource: "environment" } as const; + +const storedTokenFile = { + api_base: "https://api.anthropic.com", + anthropic_federation_rule_id: "fdrl_stored", + anthropic_organization_id: "org-stored", + anthropic_federation_workspace_id: "wrkspc_stored", + anthropic_identity_token_file: "/var****", +}; + +const storedKeycloak = { + anthropic_federation_rule_id: "fdrl_stored", + anthropic_organization_id: "org-stored", + anthropic_identity_source: "keycloak", + anthropic_keycloak_token_url: "http****", + anthropic_keycloak_client_id: "lite****", + anthropic_keycloak_client_secret_ref: "os.e****", + anthropic_keycloak_auth_method: "clie****", + anthropic_keycloak_scope: "open****", +}; + +describe("isAnthropicProvider", () => { + it.each(["Anthropic", "anthropic"])("accepts %s", (provider) => { + expect(isAnthropicProvider(provider)).toBe(true); + }); + + it.each(["OpenAI", "ANTHROPIC_TEXT", "Anthropic Text", null, undefined])("rejects %s", (provider) => { + expect(isAnthropicProvider(provider)).toBe(false); + }); +}); + +describe("reading a stored credential", () => { + it("treats a credential with any federation value as federated", () => { + expect(isFederatedCredential({ anthropic_federation_rule_id: "fdrl_1" })).toBe(true); + expect(inferAuthMethod({ anthropic_identity_source: "keycloak" })).toBe("federation"); + }); + + it("treats an api key credential, an empty one, and a missing one as not federated", () => { + expect(isFederatedCredential({ api_key: "sk-1****", api_base: "https://api.anthropic.com" })).toBe(false); + expect(isFederatedCredential({ anthropic_federation_rule_id: "" })).toBe(false); + expect(inferAuthMethod(undefined)).toBe("api_key"); + }); + + it("lets a declared identity source win over a leftover token file", () => { + expect( + inferIdentitySource({ anthropic_identity_source: "internal_issuer", anthropic_identity_token_file: "/var****" }), + ).toBe("internal_issuer"); + expect(inferIdentitySource(storedKeycloak)).toBe("keycloak"); + }); + + it("prefers the token file over the secret reference, the order the proxy resolves them in", () => { + expect( + inferIdentitySource({ anthropic_identity_token_file: "/var****", anthropic_identity_token: "oidc****" }), + ).toBe("token_file"); + expect(inferIdentitySource({ anthropic_identity_token: "oidc****" })).toBe("secret_reference"); + }); + + it("falls back to the proxy environment when no identity value is stored", () => { + expect(inferIdentitySource({ anthropic_federation_rule_id: "fdrl_1" })).toBe("environment"); + }); + + it("keeps a stored identity source it does not offer as its own option, never as another source", () => { + expect(inferIdentitySource({ anthropic_identity_source: "spiffe" })).toBe("unrecognized"); + expect(identitySourceOptions({ anthropic_identity_source: "spiffe" })[0]).toEqual({ + value: "unrecognized", + label: "Stored: spiffe", + }); + expect(identitySourceOptions(storedKeycloak).map((option) => option.value)).not.toContain("unrecognized"); + }); + + it("opens a credential that stores an api key next to federation values as an api key credential", () => { + expect(inferAuthMethod({ api_key: "sk-1****", anthropic_federation_rule_id: "fdrl_1" })).toBe("api_key"); + expect(inferAuthMethod({ api_key: "", anthropic_federation_rule_id: "fdrl_1" })).toBe("federation"); + }); +}); + +describe("field validation", () => { + it.each(["oidc/env/ANTHROPIC_IDENTITY_TOKEN", "oidc/file//var/run/secrets/token", "oidc/github/api.anthropic.com"])( + "accepts the secret reference %s", + (reference) => { + expect(validateIdentityTokenReference(reference)).toBe(true); + }, + ); + + it.each(["eyJhbGciOiJSUzI1NiJ9.payload.signature", "oidc/env_path/TOKEN_PATH", "os.environ/TOKEN"])( + "refuses %s, which the proxy would reject on every request", + (reference) => { + expect(validateIdentityTokenReference(reference)).toEqual(expect.stringContaining("oidc/")); + }, + ); + + it("leaves an untouched masked reference and an empty one to the other rules", () => { + expect(validateIdentityTokenReference("oidc****")).toBe(true); + expect(validateIdentityTokenReference("")).toBe(true); + }); + + it.each(["1", "300", String(MAX_ISSUER_TTL_SECONDS), 300, ""])("accepts the lifetime %s", (ttl) => { + expect(validateIssuerTtlSeconds(ttl)).toBe(true); + }); + + it.each(["0", "-5", "1.5", "abc", String(MAX_ISSUER_TTL_SECONDS + 1)])("refuses the lifetime %s", (ttl) => { + expect(validateIssuerTtlSeconds(ttl)).toEqual(expect.stringContaining("whole number")); + }); + + it("refuses a hidden stored value that was only partly edited", () => { + const rule = validateMaskedValueUntouched("os.e****"); + expect(rule("os.e****")).toBe(true); + expect(rule("os.environ/NEW_REF")).toBe(true); + expect(rule("os.e****_NEW")).toEqual(expect.stringContaining("Replace the whole value")); + }); + + it("refuses the proxy environment source when every id is blank, since the proxy rejects a credential with no values", () => { + const rule = validateFederationValueStored("environment"); + const blankIds = { + api_base: "https://gateway.example.com", + anthropic_federation_rule_id: "", + anthropic_organization_id: " ", + anthropic_service_account_id: undefined, + }; + expect(rule("", blankIds)).toEqual(expect.stringContaining("at least one")); + expect(rule("", { ...blankIds, anthropic_federation_workspace_id: "wrkspc_1" })).toBe(true); + expect(validateFederationValueStored("token_file")("", blankIds)).toBe(true); + }); +}); + +describe("buildCreateCredentialValues", () => { + it("sends the typed federation values and nothing for the fields left empty", () => { + const typedValues = { + api_base: "", + anthropic_federation_rule_id: " fdrl_new\n", + anthropic_organization_id: "org-new", + anthropic_service_account_id: "", + anthropic_federation_workspace_id: undefined, + anthropic_identity_token_file: "/var/run/secrets/anthropic/token", + }; + expect(buildCreateCredentialValues(typedValues, tokenFileSelection)).toEqual({ + anthropic_federation_rule_id: "fdrl_new", + anthropic_organization_id: "org-new", + anthropic_identity_token_file: "/var/run/secrets/anthropic/token", + }); + }); + + it("names the identity source and sends the lifetime as a number for the internal issuer", () => { + const typedValues = { + anthropic_federation_rule_id: "fdrl_new", + anthropic_organization_id: "org-new", + anthropic_issuer_url: "https://litellm.example.com", + anthropic_issuer_subject: "litellm-proxy", + anthropic_issuer_ttl_seconds: "120", + anthropic_issuer_signing_key_ref: "os.environ/ISSUER_KEY", + }; + const values = buildCreateCredentialValues(typedValues, internalIssuerSelection); + expect(values.anthropic_identity_source).toBe("internal_issuer"); + expect(values.anthropic_issuer_ttl_seconds).toBe(120); + }); + + it("does not name an identity source for an api key credential and keeps its values as typed", () => { + expect(buildCreateCredentialValues({ api_key: " sk-ant-typed ", api_base: "" }, apiKeySelection)).toEqual({ + api_key: " sk-ant-typed ", + }); + }); +}); + +describe("buildCredentialPatch", () => { + it("writes nothing when the admin saves a federated credential untouched", () => { + expect( + buildCredentialPatch(storedTokenFile, { ...storedTokenFile }, tokenFileSelection, tokenFileSelection), + ).toEqual({ credential_values: {}, credential_values_to_delete: [] }); + expect(buildCredentialPatch(storedKeycloak, { ...storedKeycloak }, keycloakSelection, keycloakSelection)).toEqual({ + credential_values: {}, + credential_values_to_delete: [], + }); + }); + + it("sends only the value the admin changed", () => { + expect( + buildCredentialPatch( + storedTokenFile, + { ...storedTokenFile, anthropic_organization_id: "org-edited" }, + tokenFileSelection, + tokenFileSelection, + ), + ).toEqual({ credential_values: { anthropic_organization_id: "org-edited" }, credential_values_to_delete: [] }); + }); + + it("replaces a hidden stored value when the admin types a new one", () => { + const patch = buildCredentialPatch( + storedTokenFile, + { ...storedTokenFile, anthropic_identity_token_file: "/run/secrets/new-token" }, + tokenFileSelection, + tokenFileSelection, + ); + expect(patch.credential_values).toEqual({ anthropic_identity_token_file: "/run/secrets/new-token" }); + }); + + it("deletes every stored value the admin cleared, a base URL included", () => { + expect( + buildCredentialPatch( + { ...storedTokenFile, api_base: "https://gateway.example.com" }, + { ...storedTokenFile, api_base: "", anthropic_federation_workspace_id: " " }, + tokenFileSelection, + tokenFileSelection, + ), + ).toEqual({ + credential_values: {}, + credential_values_to_delete: ["api_base", "anthropic_federation_workspace_id"], + }); + }); + + it("deletes every stored value the admin did not re-enter when the provider changed", () => { + const stored = { api_base: "https://corp.openai.azure.com", api_version: "2024-10-21", api_key: "sk-1****" }; + const typed = { anthropic_federation_rule_id: "fdrl_1", anthropic_identity_token_file: "/run/secrets/token" }; + expect(buildProviderChangePatch(stored, typed, tokenFileSelection)).toEqual({ + credential_values: typed, + credential_values_to_delete: ["api_base", "api_version", "api_key"], + }); + }); + + it("does not treat an unchanged stored lifetime as an edit", () => { + const stored = { + ...storedTokenFile, + anthropic_identity_source: "internal_issuer", + anthropic_issuer_ttl_seconds: 300, + }; + const patch = buildCredentialPatch( + stored, + { anthropic_issuer_ttl_seconds: "300" }, + internalIssuerSelection, + internalIssuerSelection, + ); + expect(patch.credential_values).toEqual({}); + }); + + it("drops the other source's stored values when the admin switches identity source", () => { + const typedValues = { + anthropic_federation_rule_id: "fdrl_stored", + anthropic_organization_id: "org-stored", + anthropic_issuer_url: "https://litellm.example.com", + anthropic_issuer_subject: "litellm-proxy", + anthropic_issuer_signing_key_ref: "os.environ/ISSUER_KEY", + }; + const patch = buildCredentialPatch(storedKeycloak, typedValues, keycloakSelection, internalIssuerSelection); + const expectedValues = { + 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_KEY", + }; + expect(patch.credential_values).toEqual(expectedValues); + expect([...patch.credential_values_to_delete].sort()).toEqual([ + "anthropic_keycloak_auth_method", + "anthropic_keycloak_client_id", + "anthropic_keycloak_client_secret_ref", + "anthropic_keycloak_scope", + "anthropic_keycloak_token_url", + ]); + }); + + it("drops the declared source when the admin switches to a token file or the proxy environment", () => { + const toEnvironment = buildCredentialPatch(storedKeycloak, {}, keycloakSelection, environmentSelection); + expect(toEnvironment.credential_values).toEqual({}); + expect(toEnvironment.credential_values_to_delete).toContain("anthropic_identity_source"); + expect(toEnvironment.credential_values_to_delete).not.toContain("anthropic_federation_rule_id"); + }); + + it("deletes the stored api key when the admin switches the credential to federation", () => { + const typedValues = { + api_base: "https://api.anthropic.com", + anthropic_federation_rule_id: "fdrl_new", + anthropic_organization_id: "org-new", + anthropic_identity_token_file: "/var/run/secrets/anthropic/token", + }; + const patch = buildCredentialPatch( + { api_key: "sk-a****", api_base: "https://api.anthropic.com" }, + typedValues, + apiKeySelection, + tokenFileSelection, + ); + expect(patch.credential_values_to_delete).toEqual(["api_key"]); + expect(patch.credential_values).toEqual({ + anthropic_federation_rule_id: "fdrl_new", + anthropic_organization_id: "org-new", + anthropic_identity_token_file: "/var/run/secrets/anthropic/token", + }); + }); + + it("deletes every stored federation value when the admin switches the credential to an api key", () => { + const patch = buildCredentialPatch(storedKeycloak, { api_key: "sk-ant-new" }, keycloakSelection, apiKeySelection); + expect(patch.credential_values).toEqual({ api_key: "sk-ant-new" }); + expect([...patch.credential_values_to_delete].sort()).toEqual(Object.keys(storedKeycloak).sort()); + }); + + it("leaves a stored api key alone when the admin keeps a federated credential federated", () => { + const stored = { ...storedTokenFile, api_key: "sk-a****" }; + const patch = buildCredentialPatch( + stored, + { ...storedTokenFile, anthropic_organization_id: "org-edited" }, + tokenFileSelection, + tokenFileSelection, + ); + expect(patch.credential_values_to_delete).toEqual([]); + }); + + it("never names one key as both a write and a delete", () => { + const patch = buildCredentialPatch( + storedKeycloak, + { ...storedKeycloak, anthropic_identity_token_file: "/var/run/secrets/anthropic/token" }, + keycloakSelection, + tokenFileSelection, + ); + const written = Object.keys(patch.credential_values); + expect(patch.credential_values_to_delete.filter((key) => written.includes(key))).toEqual([]); + }); +}); diff --git a/ui/litellm-dashboard/src/components/model_add/anthropic_federation.ts b/ui/litellm-dashboard/src/components/model_add/anthropic_federation.ts new file mode 100644 index 00000000000..1e1678853b7 --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_add/anthropic_federation.ts @@ -0,0 +1,379 @@ +import { isMaskedSecret } from "@/utils/maskedSecretUtils"; + +export type AnthropicAuthMethod = "api_key" | "federation"; + +export type IdentitySourceId = + | "token_file" + | "secret_reference" + | "internal_issuer" + | "keycloak" + | "environment" + | "unrecognized"; + +export interface FederationField { + readonly key: string; + readonly label: string; + readonly tooltip: string; + readonly placeholder?: string; + readonly required: boolean; + readonly control: "text" | "integer" | "select"; + readonly options?: readonly string[]; +} + +export interface IdentitySource { + readonly id: IdentitySourceId; + readonly label: string; + readonly fixedValues: Readonly>; + readonly fields: readonly FederationField[]; +} + +export interface CredentialValuesPatch { + readonly credential_values: Record; + readonly credential_values_to_delete: readonly string[]; +} + +export const MAX_ISSUER_TTL_SECONDS = 3600; + +const IDENTITY_SOURCE_KEY = "anthropic_identity_source"; +const API_KEY = "api_key"; +const ACCEPTED_REFERENCE_PREFIX = "oidc/"; +const REJECTED_REFERENCE_PREFIX = "oidc/env_path/"; + +export const FEDERATION_CORE_FIELDS: readonly FederationField[] = [ + { + key: "anthropic_federation_rule_id", + label: "Federation Rule ID", + tooltip: + "The fdrl_ id of the federation rule, from Settings > Workload identity in the Claude Console. Leave empty when the proxy sets ANTHROPIC_FEDERATION_RULE_ID in its environment.", + placeholder: "fdrl_...", + required: false, + control: "text", + }, + { + key: "anthropic_organization_id", + label: "Organization ID", + tooltip: + "The Anthropic organization the federation rule belongs to, shown on the rule's detail page. Leave empty when the proxy sets ANTHROPIC_ORGANIZATION_ID in its environment.", + required: false, + control: "text", + }, + { + key: "anthropic_service_account_id", + label: "Service Account ID", + tooltip: "The svac_ id the federation rule targets. Anthropic's reference lists it as required.", + placeholder: "svac_...", + required: false, + control: "text", + }, + { + key: "anthropic_federation_workspace_id", + label: "Workspace ID", + tooltip: + "Set this when the federation rule is enabled in more than one workspace: the wrkspc_ id to mint tokens for, or 'default'.", + placeholder: "wrkspc_...", + required: false, + control: "text", + }, +]; + +export const IDENTITY_SOURCES: readonly IdentitySource[] = [ + { + id: "token_file", + label: "Identity token file", + fixedValues: {}, + fields: [ + { + key: "anthropic_identity_token_file", + label: "Identity Token File", + tooltip: + "Absolute path on the proxy host of a file holding the identity token, for example a projected Kubernetes service account token. It must sit under an allowed credential directory.", + placeholder: "/var/run/secrets/anthropic/token", + required: true, + control: "text", + }, + ], + }, + { + id: "secret_reference", + label: "Identity token secret reference", + fixedValues: {}, + fields: [ + { + key: "anthropic_identity_token", + label: "Identity Token Reference", + tooltip: + "An oidc/ secret reference the proxy resolves on each exchange, such as oidc/env/VAR_NAME, oidc/github/, or oidc/google/. Raw tokens are not accepted.", + placeholder: "oidc/env/ANTHROPIC_IDENTITY_TOKEN", + required: true, + control: "text", + }, + ], + }, + { + id: "internal_issuer", + label: "Token signed by LiteLLM (internal issuer)", + fixedValues: { [IDENTITY_SOURCE_KEY]: "internal_issuer" }, + fields: [ + { + key: "anthropic_issuer_url", + label: "Issuer URL", + tooltip: "The issuer registered on the federation rule. The proxy puts it in the token's iss claim.", + placeholder: "https://litellm.example.com", + required: true, + control: "text", + }, + { + key: "anthropic_issuer_subject", + label: "Subject", + tooltip: "The sub claim the federation rule matches.", + required: true, + control: "text", + }, + { + key: "anthropic_issuer_audience", + label: "Audience", + tooltip: "Optional aud claim, when the federation rule checks one.", + required: false, + control: "text", + }, + { + key: "anthropic_issuer_ttl_seconds", + label: "Token Lifetime (seconds)", + tooltip: `Optional. How long each signed token is valid, from 1 to ${MAX_ISSUER_TTL_SECONDS} seconds. The proxy uses its default when this is empty.`, + required: false, + control: "integer", + }, + { + key: "anthropic_issuer_signing_key_ref", + label: "Signing Key Reference", + tooltip: + "A secret reference to the PEM private key the proxy signs with, such as os.environ/VAR_NAME. Enter the reference, never the key itself.", + placeholder: "os.environ/ANTHROPIC_ISSUER_SIGNING_KEY", + required: true, + control: "text", + }, + ], + }, + { + id: "keycloak", + label: "Keycloak client credentials", + fixedValues: { [IDENTITY_SOURCE_KEY]: "keycloak" }, + fields: [ + { + key: "anthropic_keycloak_token_url", + label: "Keycloak Token URL", + tooltip: "The realm's token endpoint the proxy requests an identity token from.", + placeholder: "https://keycloak.example.com/realms//protocol/openid-connect/token", + required: true, + control: "text", + }, + { + key: "anthropic_keycloak_client_id", + label: "Keycloak Client ID", + tooltip: "The client the proxy authenticates as.", + required: true, + control: "text", + }, + { + key: "anthropic_keycloak_client_secret_ref", + label: "Client Secret Reference", + tooltip: + "A secret reference to the client secret, such as os.environ/VAR_NAME. Enter the reference, never the secret itself.", + placeholder: "os.environ/KEYCLOAK_CLIENT_SECRET", + required: true, + control: "text", + }, + { + key: "anthropic_keycloak_auth_method", + label: "Client Authentication Method", + tooltip: "Optional. How the client secret is sent to Keycloak. The proxy uses client_secret_basic when unset.", + required: false, + control: "select", + options: ["client_secret_basic", "client_secret_post"], + }, + { + key: "anthropic_keycloak_scope", + label: "Scope", + tooltip: "Optional scope to request with the token.", + required: false, + control: "text", + }, + ], + }, + { + id: "environment", + label: "Proxy environment variables", + fixedValues: {}, + fields: [], + }, +]; + +const IDENTITY_SOURCE_VALUE_KEYS: readonly string[] = [ + IDENTITY_SOURCE_KEY, + ...IDENTITY_SOURCES.flatMap((source) => source.fields.map((field) => field.key)), +]; + +export const FEDERATION_VALUE_KEYS: readonly string[] = [ + ...FEDERATION_CORE_FIELDS.map((field) => field.key), + ...IDENTITY_SOURCE_VALUE_KEYS, +]; + +const UNRECOGNIZED_IDENTITY_SOURCE: IdentitySource = { id: "unrecognized", label: "", fixedValues: {}, fields: [] }; + +const isBlank = (value: unknown): boolean => { + if (typeof value === "string") { + return value.trim() === ""; + } + return value === undefined || value === null; +}; + +export const identitySourceById = (id: IdentitySourceId): IdentitySource => + IDENTITY_SOURCES.find((source) => source.id === id) ?? UNRECOGNIZED_IDENTITY_SOURCE; + +export const isAnthropicProvider = (provider: string | null | undefined): boolean => + provider !== null && provider !== undefined && provider.toLowerCase() === "anthropic"; + +export const isFederatedCredential = (credentialValues: Record | null | undefined): boolean => + FEDERATION_VALUE_KEYS.some((key) => !isBlank(credentialValues?.[key])); + +export const inferAuthMethod = (credentialValues: Record | null | undefined): AnthropicAuthMethod => + isBlank(credentialValues?.[API_KEY]) && isFederatedCredential(credentialValues) ? "federation" : "api_key"; + +export const inferIdentitySource = (credentialValues: Record | null | undefined): IdentitySourceId => { + const values = credentialValues ?? {}; + const declared = values[IDENTITY_SOURCE_KEY]; + if (!isBlank(declared)) { + return ( + IDENTITY_SOURCES.find((source) => source.fixedValues[IDENTITY_SOURCE_KEY] === declared)?.id ?? "unrecognized" + ); + } + if (!isBlank(values.anthropic_identity_token_file)) { + return "token_file"; + } + if (!isBlank(values.anthropic_identity_token)) { + return "secret_reference"; + } + return "environment"; +}; + +export const identitySourceOptions = ( + storedValues: Record, +): readonly { value: IdentitySourceId; label: string }[] => [ + ...(inferIdentitySource(storedValues) === "unrecognized" + ? [{ value: "unrecognized" as const, label: `Stored: ${String(storedValues[IDENTITY_SOURCE_KEY])}` }] + : []), + ...IDENTITY_SOURCES.map((source) => ({ value: source.id, label: source.label })), +]; + +export const requiredFederationValue = (value: unknown): string | true => (isBlank(value) ? "Required" : true); + +export const validateFederationValueStored = + (identitySource: IdentitySourceId) => + (_value: unknown, formValues: Record): string | true => + identitySource === "environment" && FEDERATION_CORE_FIELDS.every((field) => isBlank(formValues[field.key])) + ? "Enter at least one of these ids, or pick an identity source that stores a token. The proxy rejects a credential with no values" + : true; + +export const validateIdentityTokenReference = (value: unknown): string | true => { + if (typeof value !== "string" || isBlank(value) || isMaskedSecret(value)) { + return true; + } + return value.startsWith(ACCEPTED_REFERENCE_PREFIX) && !value.startsWith(REJECTED_REFERENCE_PREFIX) + ? true + : "Enter an oidc/ secret reference such as oidc/env/VAR_NAME. Raw tokens and oidc/env_path/ references are not accepted"; +}; + +export const validateIssuerTtlSeconds = (value: unknown): string | true => { + if (isBlank(value)) { + return true; + } + const seconds = Number(value); + return Number.isInteger(seconds) && seconds >= 1 && seconds <= MAX_ISSUER_TTL_SECONDS + ? true + : `Enter a whole number of seconds from 1 to ${MAX_ISSUER_TTL_SECONDS}`; +}; + +export const validateMaskedValueUntouched = + (storedValue: unknown) => + (value: unknown): string | true => + isMaskedSecret(value) && value !== storedValue + ? "This stored value is hidden. Replace the whole value to change it" + : true; + +const toStoredType = (field: FederationField | undefined, value: unknown): unknown => { + if (field === undefined || isBlank(value)) { + return field === undefined ? value : ""; + } + if (field.control === "integer") { + return Number(value); + } + return typeof value === "string" ? value.trim() : value; +}; + +const federationFieldsByKey: ReadonlyMap = new Map( + [...FEDERATION_CORE_FIELDS, ...IDENTITY_SOURCES.flatMap((source) => source.fields)].map((field) => [ + field.key, + field, + ]), +); + +interface CredentialSelection { + readonly authMethod: AnthropicAuthMethod; + readonly identitySource: IdentitySourceId; +} + +const fixedValuesFor = (selection: CredentialSelection): Readonly> => + selection.authMethod === "federation" ? identitySourceById(selection.identitySource).fixedValues : {}; + +const typedFormValues = (formValues: Record): Record => + Object.fromEntries( + Object.entries(formValues) + .map(([key, value]) => [key, toStoredType(federationFieldsByKey.get(key), value)] as const) + .filter(([, value]) => value !== "" && value !== undefined && value !== null), + ); + +export const buildCreateCredentialValues = ( + formValues: Record, + selection: CredentialSelection, +): Record => ({ ...typedFormValues(formValues), ...fixedValuesFor(selection) }); + +const keysLeftBehind = (initial: CredentialSelection, selection: CredentialSelection): readonly string[] => { + if (selection.authMethod !== initial.authMethod) { + return selection.authMethod === "federation" ? [API_KEY] : FEDERATION_VALUE_KEYS; + } + if (selection.authMethod !== "federation" || selection.identitySource === initial.identitySource) { + return []; + } + const source = identitySourceById(selection.identitySource); + const kept = new Set([...source.fields.map((field) => field.key), ...Object.keys(source.fixedValues)]); + return IDENTITY_SOURCE_VALUE_KEYS.filter((key) => !kept.has(key)); +}; + +const changedValues = (stored: Record, desired: Record): Record => + Object.fromEntries(Object.entries(desired).filter(([key, value]) => !isMaskedSecret(value) && value !== stored[key])); + +export const buildProviderChangePatch = ( + stored: Record, + formValues: Record, + selection: CredentialSelection, +): CredentialValuesPatch => { + const desired = { ...typedFormValues(formValues), ...fixedValuesFor(selection) }; + return { + credential_values: changedValues(stored, desired), + credential_values_to_delete: Object.keys(stored).filter((key) => !(key in desired)), + }; +}; + +export const buildCredentialPatch = ( + stored: Record, + formValues: Record, + initial: CredentialSelection, + selection: CredentialSelection, +): CredentialValuesPatch => { + const changed = changedValues(stored, { ...typedFormValues(formValues), ...fixedValuesFor(selection) }); + const cleared = Object.keys(formValues).filter((key) => isBlank(formValues[key]) && !isBlank(stored[key])); + const toDelete = [...keysLeftBehind(initial, selection), ...cleared].filter( + (key) => key in stored && !(key in changed), + ); + return { credential_values: changed, credential_values_to_delete: Array.from(new Set(toDelete)) }; +}; 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 e7b1e861811..4db1bb78371 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 @@ -21,6 +21,19 @@ interface CredentialFormAdapter { * The credential name is preserved because it's a user-supplied label * that shouldn't reset just because the admin re-selected a provider. */ +const restrictedFields: readonly string[] = ["credential_name", "custom_llm_provider"]; + +export const buildCredential = (values: Record, credentialValues: Record) => ({ + credential_name: values.credential_name as string, + credential_values: credentialValues, + credential_info: { + custom_llm_provider: values.custom_llm_provider as string, + }, +}); + +export const withoutRestrictedFields = (values: Record): Record => + Object.fromEntries(Object.entries(values).filter(([key]) => !restrictedFields.includes(key))); + export function resetCredentialFormOnProviderChange( form: CredentialFormAdapter, newProvider: string | null,