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<string, any>, 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>
This commit is contained in:
devin-ai-integration[bot] 2026-10-07 13:38:58 -07:00 • committed by GitHub
parent 8caf271e6c
commit eb9355d11a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 3274 additions and 58 deletions

View file

@ -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<T = Record<string, any>>(
page: Page,
match: { method: string; urlIncludes: string },
action: () => Promise<void>,
): Promise<Record<string, any>> {
): Promise<T> {
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<string, any>;
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. */

View file

@ -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<string, unknown>;
interface StoredCredential {
credential_name: string;
credential_values: CredentialValues;
credential_info: { custom_llm_provider: string };
}
interface Deployment {
model_name: string;
litellm_params: Record<string, unknown>;
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<string, unknown>;
}
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<void> {
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<void> {
await navigateToPage(page, Page.Models);
await page.getByRole("tab", { name: tab, exact: true }).click();
}
async function pickOption(
page: PlaywrightPage,
trigger: Locator,
label: string,
): Promise<void> {
await trigger.click();
await page
.getByRole("option")
.filter({ hasText: exactText(label) })
.first()
.click();
}
async function pickProvider(
page: PlaywrightPage,
scope: Locator,
provider: string,
): Promise<void> {
await pickOption(page, scope.getByPlaceholder("Select a provider"), provider);
}
async function pickAuthMethod(
page: PlaywrightPage,
dialog: Locator,
label: string,
): Promise<void> {
await pickOption(page, dialog.locator("#anthropic_auth_method"), label);
}
async function pickIdentitySource(
page: PlaywrightPage,
dialog: Locator,
label: string,
): Promise<void> {
await pickOption(
page,
dialog.locator("#anthropic_federation_identity_source"),
label,
);
}
async function fillFields(
dialog: Locator,
values: Record<string, string>,
): Promise<void> {
for (const [key, value] of Object.entries(values)) {
await dialog.locator(`#${key}`).fill(value);
}
}
async function openAddCredentialDialog(
page: PlaywrightPage,
name: string,
): Promise<Locator> {
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<Locator> {
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<CredentialWrite> =>
captureRequestBody<CredentialWrite>(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<void> {
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<void> {
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<void> {
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<StoredCredential | null> {
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<StoredCredential> {
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<T = Record<string, unknown>>(
request: APIRequestContext,
route: string,
data: Record<string, unknown>,
): Promise<T> {
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<ModelWrite>(
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<ModelWrite>(
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<Deployment | undefined> => {
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] });
}
});

View file

@ -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"
]

View file

@ -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))

View file

@ -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();

View file

@ -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(<AddModelForm {...props} />);
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"));

View file

@ -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<AddModelFormProps> = ({
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<string, unknown>) => {
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<AddModelFormProps> = ({
<div className="grow border-t border-border"></div>
</div>
<ProviderSpecificFields selectedProvider={selectedProvider} />
{canCreateFederatedCredential && (
<div className="mb-4 flex flex-col items-start gap-2">
<span className="text-sm text-muted-foreground">
Workload identity federation is saved as a credential, then attached to this model.
</span>
<Button
type="button"
variant="outline"
onClick={() => setIsFederatedCredentialModalOpen(true)}
>
Use workload identity federation
</Button>
</div>
)}
</>
)}
<div className="flex items-center my-4">
@ -447,6 +486,17 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
</CardContent>
</Card>
{isFederatedCredentialModalOpen && (
<CredentialModal
open
mode="add"
initialProvider={selectedProvider}
initialAuthMethod="federation"
providerLocked
onCancel={() => setIsFederatedCredentialModalOpen(false)}
onSubmit={handleCreateFederatedCredential}
/>
)}
{/* Test Connection Results Modal */}
<Dialog
open={isResultModalVisible}

View file

@ -19,6 +19,7 @@ import { labelWithHint } from "@/components/shared/form/LabelWithHint";
interface ProviderSpecificFieldsProps {
selectedProvider: string | null;
hiddenFieldKeys?: readonly string[];
}
const readTextFile = (file: File, onLoaded: (contents: string) => void) => {
@ -80,7 +81,7 @@ const mapFieldMetadataToUiField = (field: ProviderCredentialFieldMetadata): Prov
const providerFieldsByDisplayName: Record<string, ProviderCredentialField[]> = {};
const ProviderSpecificFields: React.FC<ProviderSpecificFieldsProps> = ({ selectedProvider }) => {
const ProviderSpecificFields: React.FC<ProviderSpecificFieldsProps> = ({ selectedProvider, hiddenFieldKeys }) => {
const selectedProviderEnum = Providers[selectedProvider as keyof typeof Providers] as Providers;
const form = useFormContext<MountedFormValues>();
const credentialsFileRef = React.useRef<HTMLInputElement>(null);
@ -130,7 +131,7 @@ const ProviderSpecificFields: React.FC<ProviderSpecificFieldsProps> = ({ 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<ProviderSpecificFieldsProps> = ({ 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<string | null>(null);
@ -286,7 +292,7 @@ const ProviderSpecificFields: React.FC<ProviderSpecificFieldsProps> = ({ selecte
</p>
)}
{allFields.map((field) => (
<React.Fragment key={field.key}>
<React.Fragment key={`${selectedProvider}:${field.key}`}>
<MountedFormField
label={field.tooltip ? labelWithHint(field.label, field.tooltip) : field.label}
name={field.key}

View file

@ -0,0 +1,141 @@
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { labelWithHint } from "@/components/shared/form/LabelWithHint";
import { isMaskedSecret } from "@/utils/maskedSecretUtils";
import { MountedFormField, type MountedFieldControlProps } from "../common_components/MountedFormField";
import {
FEDERATION_CORE_FIELDS,
identitySourceById,
identitySourceOptions,
requiredFederationValue,
validateFederationValueStored,
validateIdentityTokenReference,
validateIssuerTtlSeconds,
validateMaskedValueUntouched,
type FederationField,
type IdentitySourceId,
} from "./anthropic_federation";
interface AnthropicFederationFieldsProps {
identitySource: IdentitySourceId;
onIdentitySourceChange: (identitySource: IdentitySourceId) => void;
storedValues: Record<string, unknown>;
}
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 (
<Select
items={items}
value={typeof control.value === "string" && control.value !== "" ? control.value : null}
onValueChange={(value) => control.onChange(value ?? "")}
>
<SelectTrigger id={control.id} onBlur={control.onBlur} className="w-full">
<SelectValue placeholder="Proxy default" />
</SelectTrigger>
<SelectContent>
<SelectItem value={null}>Proxy default</SelectItem>
{items.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
return (
<Input
id={control.id}
value={control.value === undefined || control.value === null ? "" : String(control.value)}
onChange={control.onChange}
onBlur={control.onBlur}
placeholder={field.placeholder}
inputMode={field.control === "integer" ? "numeric" : undefined}
aria-invalid={control["aria-invalid"]}
/>
);
};
export default function AnthropicFederationFields({
identitySource,
onIdentitySourceChange,
storedValues,
}: AnthropicFederationFieldsProps) {
const sourceFields = identitySourceById(identitySource).fields;
const identitySourceItems = identitySourceOptions(storedValues);
const renderField = (field: FederationField) => (
<MountedFormField
key={field.key}
label={labelWithHint(field.label, field.tooltip)}
name={field.key}
required={field.required}
rules={fieldRules(field, storedValues[field.key], identitySource)}
className="mb-4"
>
{(control) => renderControl(field, control)}
</MountedFormField>
);
return (
<>
{FEDERATION_CORE_FIELDS.map(renderField)}
<div className="mb-4 flex flex-col gap-2">
<label htmlFor={IDENTITY_SOURCE_SELECT_ID} className="text-sm font-medium">
{labelWithHint(
"Identity Source",
"Where the proxy gets the identity token it exchanges with Anthropic for an access token.",
)}
</label>
<Select
items={identitySourceItems}
value={identitySource}
onValueChange={(value) => onIdentitySourceChange(value as IdentitySourceId)}
>
<SelectTrigger id={IDENTITY_SOURCE_SELECT_ID} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{identitySourceItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
{identitySource === "unrecognized" && (
<p className="text-sm text-muted-foreground">
This form does not offer the stored identity source. Saving keeps it and its settings as stored. Pick
another source to replace it.
</p>
)}
{identitySource === "environment" && (
<p className="text-sm text-muted-foreground">
The proxy reads ANTHROPIC_IDENTITY_TOKEN_FILE or ANTHROPIC_IDENTITY_TOKEN from its own environment.
</p>
)}
</div>
{sourceFields.map(renderField)}
</>
);
}

View file

@ -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<React.ComponentProps<typeof CredentialModal>> = {}) => {
const onSubmit = vi.fn();
render(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } })}>
<CredentialModal open mode="add" onCancel={vi.fn()} onSubmit={onSubmit} {...props} />
</QueryClientProvider>,
);
return onSubmit;
};
const fill = (label: string | RegExp, value: string) =>
fireEvent.change(screen.getByLabelText(label), { target: { value } });
const chooseOption = (user: ReturnType<typeof userEvent.setup>, select: RegExp, option: string | RegExp) =>
chooseSelectOption(user, screen.getByRole("combobox", { name: select }), option);
const chooseProvider = async (user: ReturnType<typeof userEvent.setup>, 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",
},
[],
);
});
});

View file

@ -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: <Logo provider={providerEnum} label={providerDisplayName} className="w-5 h-5" />,
}));
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<string, unknown>, 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<string | null>(
(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<string, unknown> = existingCredential?.credential_values ?? {};
const storedSelection = {
authMethod: inferAuthMethod(storedValues),
identitySource: inferIdentitySource(storedValues),
};
const [authMethod, setAuthMethod] = useState<AnthropicAuthMethod>(
existingCredential ? storedSelection.authMethod : initialAuthMethod ?? "api_key",
);
const [identitySource, setIdentitySource] = useState<IdentitySourceId>(
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<MountedFormValues>({ 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);
}}
/>
)}
</MountedFormField>
<ProviderSpecificFields selectedProvider={selectedProvider} />
{isAnthropic && (
<div className="mb-4 flex flex-col gap-2">
<label htmlFor={AUTH_METHOD_SELECT_ID} className="text-sm font-medium">
{labelWithHint(
"Authentication:",
"Workload identity federation exchanges an identity token for a short-lived Anthropic access token, so no API key is stored.",
)}
</label>
<Select
items={authMethodItems}
value={authMethod}
onValueChange={(value) => setAuthMethod(value as AnthropicAuthMethod)}
>
<SelectTrigger id={AUTH_METHOD_SELECT_ID} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{authMethodItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<ProviderSpecificFields
selectedProvider={selectedProvider}
hiddenFieldKeys={selection.authMethod === "federation" ? API_KEY_FIELDS : NO_HIDDEN_FIELDS}
/>
{selection.authMethod === "federation" && (
<AnthropicFederationFields
identitySource={identitySource}
onIdentitySourceChange={setIdentitySource}
storedValues={storedValues}
/>
)}
<div className="flex justify-between items-center">
<SimpleTooltip content="Get help on our github">

View file

@ -39,7 +39,7 @@ vi.mock("./CredentialModal", () => ({
}: {
mode: "add" | "edit";
open: boolean;
onSubmit: (values: Record<string, unknown>) => void;
onSubmit: (values: Record<string, unknown>, valuesToDelete?: readonly string[]) => void;
}) {
if (!open) {
return null;
@ -54,9 +54,19 @@ vi.mock("./CredentialModal", () => ({
}
: { credential_name: "new-cred", custom_llm_provider: "openai" };
return (
<button data-testid={`credential-modal-${mode}-submit`} onClick={() => onSubmit(values)}>
submit {mode}
</button>
<>
<button data-testid={`credential-modal-${mode}-submit`} onClick={() => onSubmit(values)}>
submit {mode}
</button>
<button
data-testid={`credential-modal-${mode}-switch-to-federation`}
onClick={() =>
onSubmit({ ...values, api_key: undefined, anthropic_federation_rule_id: "fdrl_new" }, ["api_key"])
}
>
switch {mode} to federation
</button>
</>
);
},
}));
@ -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", () => {

View file

@ -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<string, unknown>, credentialValues: Record<string, unknown>) => ({
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<string, unknown>): Record<string, unknown> =>
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<string, unknown>) => {
const handleUpdateCredential = async (values: Record<string, unknown>, 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();

View file

@ -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(<CredentialsTable {...defaultProps} credentials={credentials} />);
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: {} },

View file

@ -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 <span className="text-sm text-muted-foreground">-</span>;
}
@ -36,6 +39,7 @@ function CredentialProviderCell({ provider }: { provider: string | undefined })
/>
) : null}
<span className="truncate text-sm">{displayName || provider}</span>
{federated && <Badge variant="secondary">Workload identity federation</Badge>}
</div>
);
}
@ -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 }) => <CredentialProviderCell provider={row.original.credential_info?.custom_llm_provider} />,
cell: ({ row }) => (
<CredentialProviderCell
provider={row.original.credential_info?.custom_llm_provider}
federated={isFederatedCredential(row.original.credential_values)}
/>
),
},
];

View file

@ -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([]);
});
});

View file

@ -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<Record<string, string>>;
readonly fields: readonly FederationField[];
}
export interface CredentialValuesPatch {
readonly credential_values: Record<string, unknown>;
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/<audience>, or oidc/google/<audience>. 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/<realm>/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<string, unknown> | null | undefined): boolean =>
FEDERATION_VALUE_KEYS.some((key) => !isBlank(credentialValues?.[key]));
export const inferAuthMethod = (credentialValues: Record<string, unknown> | null | undefined): AnthropicAuthMethod =>
isBlank(credentialValues?.[API_KEY]) && isFederatedCredential(credentialValues) ? "federation" : "api_key";
export const inferIdentitySource = (credentialValues: Record<string, unknown> | 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<string, unknown>,
): 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, unknown>): 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<string, FederationField> = 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<Record<string, string>> =>
selection.authMethod === "federation" ? identitySourceById(selection.identitySource).fixedValues : {};
const typedFormValues = (formValues: Record<string, unknown>): Record<string, unknown> =>
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<string, unknown>,
selection: CredentialSelection,
): Record<string, unknown> => ({ ...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<string, unknown>, desired: Record<string, unknown>): Record<string, unknown> =>
Object.fromEntries(Object.entries(desired).filter(([key, value]) => !isMaskedSecret(value) && value !== stored[key]));
export const buildProviderChangePatch = (
stored: Record<string, unknown>,
formValues: Record<string, unknown>,
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<string, unknown>,
formValues: Record<string, unknown>,
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)) };
};

View file

@ -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<string, unknown>, credentialValues: Record<string, unknown>) => ({
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<string, unknown>): Record<string, unknown> =>
Object.fromEntries(Object.entries(values).filter(([key]) => !restrictedFields.includes(key)));
export function resetCredentialFormOnProviderChange(
form: CredentialFormAdapter,
newProvider: string | null,