mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
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:
parent
8caf271e6c
commit
eb9355d11a
18 changed files with 3274 additions and 58 deletions
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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] });
|
||||
}
|
||||
});
|
||||
|
|
@ -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"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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: {} },
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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)) };
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue