mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
feat(ui): configure OpenAI workload identity federation from the LLM Credentials and Add Model forms (#45528)
* feat(ui): configure OpenAI workload identity federation from the LLM Credentials and Add Model forms * fix(ui): let a federated OpenAI credential omit the service account when the proxy env provides it * fix(ui): clear the federation API Base error when the admin switches the credential to an API key
This commit is contained in:
parent
cdba36a1f0
commit
a3236ba947
16 changed files with 949 additions and 411 deletions
|
|
@ -464,8 +464,32 @@ describe("AddModelForm", () => {
|
|||
expect(providerSelect).toBeDisabled();
|
||||
});
|
||||
|
||||
it("saves an OpenAI federated credential and attaches it to the model", async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = await renderAsRole("proxy_admin", Providers.OpenAI);
|
||||
|
||||
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:", "openai-federated");
|
||||
fill(/Service Account ID/, "svc_new");
|
||||
await user.click(screen.getByRole("button", { name: "Add Credential" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(credentialCreateCall).toHaveBeenCalledWith("test-access-token", {
|
||||
credential_name: "openai-federated",
|
||||
credential_values: { openai_service_account_id: "svc_new" },
|
||||
credential_info: { custom_llm_provider: Providers.OpenAI },
|
||||
});
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(props.form.getValues("litellm_credential_name")).toBe("openai-federated");
|
||||
});
|
||||
});
|
||||
|
||||
it("is not offered for a provider without federation support", async () => {
|
||||
await renderAsRole("proxy_admin", Providers.OpenAI);
|
||||
await renderAsRole("proxy_admin", Providers.OpenAI_Compatible);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Use workload identity federation" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import {
|
|||
import type { Team } from "../key_team_helpers/key_list";
|
||||
import { type CredentialItem, type ProviderCreateInfo, credentialCreateCall, modelAvailableCall } from "../networking";
|
||||
import CredentialModal from "../model_add/CredentialModal";
|
||||
import { isAnthropicProvider } from "../model_add/anthropic_federation";
|
||||
import { federatedProviderOf } from "../model_add/credential_federation";
|
||||
import { buildCredential, withoutRestrictedFields } from "../model_add/credential_form_helpers";
|
||||
import { ProviderLogo } from "../molecules/models/ProviderLogo";
|
||||
import AccessGroupTagsCombobox from "./AccessGroupTagsCombobox";
|
||||
|
|
@ -100,7 +100,8 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
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 canCreateFederatedCredential =
|
||||
isProxyAdminRole(userRole ?? "") && federatedProviderOf(selectedProvider) !== null;
|
||||
|
||||
const handleCreateFederatedCredential = async (values: Record<string, unknown>) => {
|
||||
const credential = buildCredential(values, withoutRestrictedFields(values));
|
||||
|
|
|
|||
|
|
@ -16,10 +16,12 @@ import {
|
|||
import { ProviderCredentialFieldMetadata } from "../networking";
|
||||
import { Providers } from "../provider_info_helpers";
|
||||
import { labelWithHint } from "@/components/shared/form/LabelWithHint";
|
||||
import type { ProviderFieldValidators } from "../model_add/credential_federation";
|
||||
|
||||
interface ProviderSpecificFieldsProps {
|
||||
selectedProvider: string | null;
|
||||
hiddenFieldKeys?: readonly string[];
|
||||
fieldValidators?: ProviderFieldValidators;
|
||||
}
|
||||
|
||||
const readTextFile = (file: File, onLoaded: (contents: string) => void) => {
|
||||
|
|
@ -81,7 +83,11 @@ const mapFieldMetadataToUiField = (field: ProviderCredentialFieldMetadata): Prov
|
|||
|
||||
const providerFieldsByDisplayName: Record<string, ProviderCredentialField[]> = {};
|
||||
|
||||
const ProviderSpecificFields: React.FC<ProviderSpecificFieldsProps> = ({ selectedProvider, hiddenFieldKeys }) => {
|
||||
const ProviderSpecificFields: React.FC<ProviderSpecificFieldsProps> = ({
|
||||
selectedProvider,
|
||||
hiddenFieldKeys,
|
||||
fieldValidators,
|
||||
}) => {
|
||||
const selectedProviderEnum = Providers[selectedProvider as keyof typeof Providers] as Providers;
|
||||
const form = useFormContext<MountedFormValues>();
|
||||
const credentialsFileRef = React.useRef<HTMLInputElement>(null);
|
||||
|
|
@ -297,7 +303,12 @@ const ProviderSpecificFields: React.FC<ProviderSpecificFieldsProps> = ({ selecte
|
|||
label={field.tooltip ? labelWithHint(field.label, field.tooltip) : field.label}
|
||||
name={field.key}
|
||||
required={field.required}
|
||||
rules={field.required ? { validate: { required: requiredRule("Required") } } : undefined}
|
||||
rules={{
|
||||
validate: {
|
||||
...(field.required ? { required: requiredRule("Required") } : {}),
|
||||
...(fieldValidators?.[field.key] ? { provider: fieldValidators[field.key] } : {}),
|
||||
},
|
||||
}}
|
||||
className={field.key === "vertex_credentials" ? "mb-0" : "mb-4"}
|
||||
>
|
||||
{(control) => renderFieldControl(field, control)}
|
||||
|
|
|
|||
|
|
@ -442,3 +442,143 @@ describe("CredentialModal with Anthropic workload identity federation", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
const openAIFederatedCredential: CredentialItem = {
|
||||
credential_name: "openai-federated",
|
||||
credential_values: {
|
||||
api_base: "https://api.openai.com/v1",
|
||||
openai_identity_provider_id: "idp_stored",
|
||||
openai_service_account_id: "svc_stored",
|
||||
openai_identity_token_file: "/var****",
|
||||
},
|
||||
credential_info: { custom_llm_provider: "openai" },
|
||||
};
|
||||
|
||||
describe("CredentialModal with OpenAI workload identity federation", () => {
|
||||
it("creates a federated OpenAI credential and never sends an API key", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = renderModal({ initialProvider: "OpenAI" });
|
||||
await screen.findByLabelText("OpenAI API Key");
|
||||
fill("OpenAI API Key", "sk-proj-typed-before-switching");
|
||||
|
||||
await chooseOption(user, /^Authentication:/, "Workload identity federation");
|
||||
|
||||
expect(screen.queryByLabelText("OpenAI API Key")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/only when OPENAI_API_KEY is unset/)).toBeInTheDocument();
|
||||
fill("Credential Name:", "openai-federated");
|
||||
fill(/Identity Provider ID/, "idp_new");
|
||||
fill(/Service Account ID/, " svc_new ");
|
||||
fill(/Identity Token File/, "/var/run/secrets/kubernetes.io/serviceaccount/token");
|
||||
await user.click(screen.getByRole("button", { name: "Add Credential" }));
|
||||
|
||||
const expectedPayload = {
|
||||
credential_name: "openai-federated",
|
||||
custom_llm_provider: "OpenAI",
|
||||
openai_identity_provider_id: "idp_new",
|
||||
openai_service_account_id: "svc_new",
|
||||
openai_identity_token_file: "/var/run/secrets/kubernetes.io/serviceaccount/token",
|
||||
};
|
||||
expect(onSubmit).toHaveBeenCalledWith(expectedPayload, []);
|
||||
});
|
||||
|
||||
it("saves a stored federated OpenAI credential untouched when its service account comes from the proxy environment", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { openai_service_account_id: _, ...valuesWithoutServiceAccount } =
|
||||
openAIFederatedCredential.credential_values;
|
||||
const onSubmit = renderModal({
|
||||
mode: "edit",
|
||||
existingCredential: { ...openAIFederatedCredential, credential_values: valuesWithoutServiceAccount },
|
||||
});
|
||||
await screen.findByLabelText(/Service Account ID/);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Update Credential" }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({ credential_name: "openai-federated", custom_llm_provider: "openai" }, []);
|
||||
});
|
||||
|
||||
it("refuses a base URL the proxy would not federate with, and drops that check once the admin picks an API key", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = renderModal({ initialProvider: "OpenAI", initialAuthMethod: "federation" });
|
||||
await screen.findByLabelText("API Base");
|
||||
fill("Credential Name:", "openai-gateway");
|
||||
fill(/Service Account ID/, "svc_new");
|
||||
fill("API Base", "https://gateway.example.com/v1");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Add Credential" }));
|
||||
|
||||
expect(await screen.findByText(/only reaches the OpenAI API/)).toBeInTheDocument();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
await chooseOption(user, /^Authentication:/, "API key");
|
||||
await screen.findByLabelText("OpenAI API Key");
|
||||
expect(screen.queryByText(/only reaches the OpenAI API/)).not.toBeInTheDocument();
|
||||
fill("OpenAI API Key", "sk-proj-new");
|
||||
await user.click(screen.getByRole("button", { name: "Add Credential" }));
|
||||
|
||||
const expectedPayload = {
|
||||
credential_name: "openai-gateway",
|
||||
custom_llm_provider: "OpenAI",
|
||||
api_base: "https://gateway.example.com/v1",
|
||||
api_key: "sk-proj-new",
|
||||
};
|
||||
expect(onSubmit).toHaveBeenCalledWith(expectedPayload, []);
|
||||
});
|
||||
|
||||
it("shows a stored federated OpenAI credential and writes nothing when it is saved untouched", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = renderModal({ mode: "edit", existingCredential: openAIFederatedCredential });
|
||||
|
||||
expect(await screen.findByRole("combobox", { name: /^Authentication:/ })).toHaveTextContent(
|
||||
"Workload identity federation",
|
||||
);
|
||||
expect(screen.getByLabelText(/Service Account ID/)).toHaveValue("svc_stored");
|
||||
expect(screen.getByLabelText(/Identity Token File/)).toHaveValue("/var****");
|
||||
expect(screen.queryByLabelText("OpenAI API Key")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Update Credential" }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({ credential_name: "openai-federated", custom_llm_provider: "openai" }, []);
|
||||
});
|
||||
|
||||
it("deletes the stored OpenAI federation values when the admin switches the credential to an API key", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = renderModal({ mode: "edit", existingCredential: openAIFederatedCredential });
|
||||
await screen.findByLabelText(/Service Account ID/);
|
||||
|
||||
await chooseOption(user, /^Authentication:/, "API key");
|
||||
await screen.findByLabelText("OpenAI API Key");
|
||||
fill("OpenAI API Key", "sk-proj-replacement");
|
||||
await user.click(screen.getByRole("button", { name: "Update Credential" }));
|
||||
|
||||
const [values, valuesToDelete] = onSubmit.mock.calls[0];
|
||||
expect(values).toEqual({
|
||||
credential_name: "openai-federated",
|
||||
custom_llm_provider: "openai",
|
||||
api_key: "sk-proj-replacement",
|
||||
});
|
||||
expect([...valuesToDelete].sort()).toEqual([
|
||||
"openai_identity_provider_id",
|
||||
"openai_identity_token_file",
|
||||
"openai_service_account_id",
|
||||
]);
|
||||
});
|
||||
|
||||
it("restores the stored federation settings when the admin returns to the credential's own provider", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = renderModal({ mode: "edit", existingCredential: federatedCredential });
|
||||
await screen.findByLabelText(/Federation Rule ID/);
|
||||
|
||||
await chooseProvider(user, "OpenAI");
|
||||
expect(await screen.findByRole("combobox", { name: /^Authentication:/ })).toHaveTextContent("API key");
|
||||
await chooseProvider(user, "Anthropic");
|
||||
|
||||
expect(await screen.findByRole("combobox", { name: /Identity Source/ })).toHaveTextContent(
|
||||
"Keycloak client credentials",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Update Credential" }));
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
{ credential_name: "anthropic-federated", custom_llm_provider: "Anthropic" },
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,18 +19,19 @@ import { Providers } from "../provider_info_helpers";
|
|||
import { Logo } from "@/components/molecules/logo/Logo";
|
||||
import { resetCredentialFormOnProviderChange, withoutRestrictedFields } from "./credential_form_helpers";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import AnthropicFederationFields from "./AnthropicFederationFields";
|
||||
import FederationFields from "./FederationFields";
|
||||
import { DEFAULT_IDENTITY_SOURCE, inferIdentitySource, type IdentitySourceId } from "./anthropic_federation";
|
||||
import {
|
||||
buildCreateCredentialValues,
|
||||
buildCredentialPatch,
|
||||
buildProviderChangePatch,
|
||||
federatedProviderOf,
|
||||
inferAuthMethod,
|
||||
inferIdentitySource,
|
||||
isAnthropicProvider,
|
||||
isFederatedCredential,
|
||||
type AnthropicAuthMethod,
|
||||
type IdentitySourceId,
|
||||
} from "./anthropic_federation";
|
||||
providerFieldValidators,
|
||||
selectionFor,
|
||||
type AuthMethod,
|
||||
} from "./credential_federation";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
||||
const providerOptions: SearchSelectOption[] = Object.entries(Providers).map(([providerEnum, providerDisplayName]) => ({
|
||||
|
|
@ -39,11 +40,11 @@ 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 AUTH_METHOD_SELECT_ID = "credential_auth_method";
|
||||
const API_KEY_FIELDS: readonly string[] = ["api_key"];
|
||||
const NO_HIDDEN_FIELDS: readonly string[] = [];
|
||||
|
||||
const authMethodItems: { value: AnthropicAuthMethod; label: string }[] = [
|
||||
const authMethodItems: { value: AuthMethod; label: string }[] = [
|
||||
{ value: "api_key", label: "API key" },
|
||||
{ value: "federation", label: "Workload identity federation" },
|
||||
];
|
||||
|
|
@ -55,7 +56,7 @@ interface CredentialModalProps {
|
|||
mode: "add" | "edit";
|
||||
existingCredential?: CredentialItem | null;
|
||||
initialProvider?: string | null;
|
||||
initialAuthMethod?: AnthropicAuthMethod;
|
||||
initialAuthMethod?: AuthMethod;
|
||||
providerLocked?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -111,18 +112,16 @@ export default function CredentialModal({
|
|||
);
|
||||
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 storedAuthMethod = inferAuthMethod(storedValues);
|
||||
const storedIdentitySource = isFederatedCredential(storedValues)
|
||||
? inferIdentitySource(storedValues)
|
||||
: DEFAULT_IDENTITY_SOURCE;
|
||||
const storedSelection = selectionFor(federatedProviderOf(storedProvider), storedAuthMethod, storedIdentitySource);
|
||||
const [authMethod, setAuthMethod] = useState<AuthMethod>(
|
||||
existingCredential ? storedAuthMethod : 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 [identitySource, setIdentitySource] = useState<IdentitySourceId>(storedIdentitySource);
|
||||
const selection = selectionFor(federatedProviderOf(selectedProvider), authMethod, identitySource);
|
||||
|
||||
const initialValues = initialFormValues(existingCredential, initialProvider);
|
||||
|
||||
|
|
@ -135,6 +134,18 @@ export default function CredentialModal({
|
|||
setFieldValue: (field: string, value: unknown) => form.setValue(field, value),
|
||||
});
|
||||
|
||||
const changeProvider = (provider: string | null) => {
|
||||
const backToStored = isEdit && sameProvider(provider, storedProvider);
|
||||
setAuthMethod(backToStored ? storedAuthMethod : "api_key");
|
||||
setIdentitySource(backToStored ? storedIdentitySource : DEFAULT_IDENTITY_SOURCE);
|
||||
resetCredentialFormOnProviderChange(formAdapterFor(provider), provider, setSelectedProvider);
|
||||
};
|
||||
|
||||
const changeAuthMethod = (method: AuthMethod) => {
|
||||
form.clearErrors(Object.keys(providerFieldValidators(selection)));
|
||||
setAuthMethod(method);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const isValid = await form.trigger(registry.mountedNames() as string[]);
|
||||
if (!isValid) {
|
||||
|
|
@ -234,24 +245,24 @@ export default function CredentialModal({
|
|||
disabled={providerLocked}
|
||||
onValueChange={(value) => {
|
||||
control.onChange(value);
|
||||
resetCredentialFormOnProviderChange(formAdapterFor(value), value, setSelectedProvider);
|
||||
changeProvider(value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
|
||||
{isAnthropic && (
|
||||
{federatedProviderOf(selectedProvider) !== null && (
|
||||
<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.",
|
||||
"Workload identity federation exchanges an identity token for a short-lived access token from the provider, so no API key is stored.",
|
||||
)}
|
||||
</label>
|
||||
<Select
|
||||
items={authMethodItems}
|
||||
value={authMethod}
|
||||
onValueChange={(value) => setAuthMethod(value as AnthropicAuthMethod)}
|
||||
onValueChange={(value) => changeAuthMethod(value as AuthMethod)}
|
||||
>
|
||||
<SelectTrigger id={AUTH_METHOD_SELECT_ID} className="w-full">
|
||||
<SelectValue />
|
||||
|
|
@ -270,11 +281,12 @@ export default function CredentialModal({
|
|||
<ProviderSpecificFields
|
||||
selectedProvider={selectedProvider}
|
||||
hiddenFieldKeys={selection.authMethod === "federation" ? API_KEY_FIELDS : NO_HIDDEN_FIELDS}
|
||||
fieldValidators={providerFieldValidators(selection)}
|
||||
/>
|
||||
|
||||
{selection.authMethod === "federation" && (
|
||||
<AnthropicFederationFields
|
||||
identitySource={identitySource}
|
||||
<FederationFields
|
||||
selection={selection}
|
||||
onIdentitySourceChange={setIdentitySource}
|
||||
storedValues={storedValues}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ describe("CredentialsTable", () => {
|
|||
expect(screen.getByText("Azure")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should mark only the credential that stores federation values as federated", () => {
|
||||
it("should mark only the credentials the proxy federates as federated", () => {
|
||||
const credentials: CredentialItem[] = [
|
||||
{
|
||||
credential_name: "a-anthropic-federated",
|
||||
|
|
@ -75,12 +75,23 @@ describe("CredentialsTable", () => {
|
|||
credential_values: { api_key: "sk-a****" },
|
||||
credential_info: { custom_llm_provider: "anthropic" },
|
||||
},
|
||||
{
|
||||
credential_name: "c-openai-federated",
|
||||
credential_values: { openai_service_account_id: "svc_stored" },
|
||||
credential_info: { custom_llm_provider: "openai" },
|
||||
},
|
||||
{
|
||||
credential_name: "d-openai-key-and-federation",
|
||||
credential_values: { api_key: "sk-p****", openai_service_account_id: "svc_stored" },
|
||||
credential_info: { custom_llm_provider: "openai" },
|
||||
},
|
||||
];
|
||||
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();
|
||||
const rows = screen.getAllByRole("row").slice(1);
|
||||
const federatedNames = rows
|
||||
.filter((row) => within(row).queryByText("Workload identity federation") !== null)
|
||||
.map((row) => within(row).getAllByRole("cell")[0].textContent);
|
||||
expect(federatedNames).toEqual(["a-anthropic-federated", "c-openai-federated"]);
|
||||
});
|
||||
|
||||
it("should render a dash when a credential has no provider", () => {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import {
|
|||
import { cn } from "@/lib/cva.config";
|
||||
import { copyToClipboard } from "@/utils/dataUtils";
|
||||
|
||||
import { isFederatedCredential } from "./anthropic_federation";
|
||||
import { inferAuthMethod } from "./credential_federation";
|
||||
|
||||
function CredentialProviderCell({ provider, federated }: { provider: string | undefined; federated: boolean }) {
|
||||
if (!provider) {
|
||||
|
|
@ -144,7 +144,7 @@ export const getCredentialsTableColumns = ({
|
|||
cell: ({ row }) => (
|
||||
<CredentialProviderCell
|
||||
provider={row.original.credential_info?.custom_llm_provider}
|
||||
federated={isFederatedCredential(row.original.credential_values)}
|
||||
federated={inferAuthMethod(row.original.credential_values) === "federation"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,17 +7,19 @@ import {
|
|||
FEDERATION_CORE_FIELDS,
|
||||
identitySourceById,
|
||||
identitySourceOptions,
|
||||
requiredFederationValue,
|
||||
validateFederationValueStored,
|
||||
validateIdentityTokenReference,
|
||||
validateIssuerTtlSeconds,
|
||||
validateMaskedValueUntouched,
|
||||
type FederationField,
|
||||
type IdentitySourceId,
|
||||
} from "./anthropic_federation";
|
||||
import type { FederatedSelection } from "./credential_federation";
|
||||
import { requiredFederationValue, validateMaskedValueUntouched, type FederationField } from "./federation_field";
|
||||
import { OPENAI_FEDERATION_FIELDS } from "./openai_federation";
|
||||
|
||||
interface AnthropicFederationFieldsProps {
|
||||
identitySource: IdentitySourceId;
|
||||
type FieldValidator = (value: unknown, formValues: Record<string, unknown>) => string | true;
|
||||
|
||||
interface FederationFieldsProps {
|
||||
selection: FederatedSelection;
|
||||
onIdentitySourceChange: (identitySource: IdentitySourceId) => void;
|
||||
storedValues: Record<string, unknown>;
|
||||
}
|
||||
|
|
@ -25,14 +27,13 @@ interface AnthropicFederationFieldsProps {
|
|||
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 anthropicValidators = (
|
||||
field: FederationField,
|
||||
identitySource: IdentitySourceId,
|
||||
): Readonly<Record<string, FieldValidator>> => ({
|
||||
...(field.key === STORED_VALUE_MESSAGE_FIELD_KEY ? { stored: validateFederationValueStored(identitySource) } : {}),
|
||||
...(field.key === "anthropic_identity_token" ? { reference: validateIdentityTokenReference } : {}),
|
||||
...(field.control === "integer" ? { ttl: validateIssuerTtlSeconds } : {}),
|
||||
});
|
||||
|
||||
const selectItems = (field: FederationField, value: unknown) => [
|
||||
|
|
@ -76,26 +77,52 @@ const renderControl = (field: FederationField, control: MountedFieldControlProps
|
|||
);
|
||||
};
|
||||
|
||||
export default function AnthropicFederationFields({
|
||||
identitySource,
|
||||
onIdentitySourceChange,
|
||||
storedValues,
|
||||
}: AnthropicFederationFieldsProps) {
|
||||
const sourceFields = identitySourceById(identitySource).fields;
|
||||
const identitySourceItems = identitySourceOptions(storedValues);
|
||||
interface FederationFieldInputProps {
|
||||
field: FederationField;
|
||||
storedValue: unknown;
|
||||
validators?: Readonly<Record<string, FieldValidator>>;
|
||||
}
|
||||
|
||||
const renderField = (field: FederationField) => (
|
||||
function FederationFieldInput({ field, storedValue, validators }: FederationFieldInputProps) {
|
||||
return (
|
||||
<MountedFormField
|
||||
key={field.key}
|
||||
label={labelWithHint(field.label, field.tooltip)}
|
||||
name={field.key}
|
||||
required={field.required}
|
||||
rules={fieldRules(field, storedValues[field.key], identitySource)}
|
||||
rules={{
|
||||
validate: {
|
||||
...(field.required ? { required: requiredFederationValue } : {}),
|
||||
...validators,
|
||||
masked: validateMaskedValueUntouched(storedValue),
|
||||
},
|
||||
}}
|
||||
className="mb-4"
|
||||
>
|
||||
{(control) => renderControl(field, control)}
|
||||
</MountedFormField>
|
||||
);
|
||||
}
|
||||
|
||||
interface AnthropicFederationFieldsProps {
|
||||
identitySource: IdentitySourceId;
|
||||
onIdentitySourceChange: (identitySource: IdentitySourceId) => void;
|
||||
storedValues: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function AnthropicFederationFields({
|
||||
identitySource,
|
||||
onIdentitySourceChange,
|
||||
storedValues,
|
||||
}: AnthropicFederationFieldsProps) {
|
||||
const identitySourceItems = identitySourceOptions(storedValues);
|
||||
const renderField = (field: FederationField) => (
|
||||
<FederationFieldInput
|
||||
key={field.key}
|
||||
field={field}
|
||||
storedValue={storedValues[field.key]}
|
||||
validators={anthropicValidators(field, identitySource)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -135,7 +162,36 @@ export default function AnthropicFederationFields({
|
|||
</p>
|
||||
)}
|
||||
</div>
|
||||
{sourceFields.map(renderField)}
|
||||
{identitySourceById(identitySource).fields.map(renderField)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function OpenAIFederationFields({ storedValues }: { storedValues: Record<string, unknown> }) {
|
||||
return (
|
||||
<>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
The proxy exchanges the identity token for an OpenAI access token only when OPENAI_API_KEY is unset in its
|
||||
environment. Otherwise it sends that key instead.
|
||||
</p>
|
||||
{OPENAI_FEDERATION_FIELDS.map((field) => (
|
||||
<FederationFieldInput key={field.key} field={field} storedValue={storedValues[field.key]} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FederationFields({ selection, onIdentitySourceChange, storedValues }: FederationFieldsProps) {
|
||||
switch (selection.provider) {
|
||||
case "anthropic":
|
||||
return (
|
||||
<AnthropicFederationFields
|
||||
identitySource={selection.identitySource}
|
||||
onIdentitySourceChange={onIdentitySourceChange}
|
||||
storedValues={storedValues}
|
||||
/>
|
||||
);
|
||||
case "openai":
|
||||
return <OpenAIFederationFields storedValues={storedValues} />;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,34 +1,14 @@
|
|||
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",
|
||||
|
|
@ -50,18 +30,7 @@ describe("isAnthropicProvider", () => {
|
|||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
describe("reading a stored identity source", () => {
|
||||
it("lets a declared identity source win over a leftover token file", () => {
|
||||
expect(
|
||||
inferIdentitySource({ anthropic_identity_source: "internal_issuer", anthropic_identity_token_file: "/var****" }),
|
||||
|
|
@ -88,11 +57,6 @@ describe("reading a stored credential", () => {
|
|||
});
|
||||
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", () => {
|
||||
|
|
@ -123,13 +87,6 @@ describe("field validation", () => {
|
|||
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 = {
|
||||
|
|
@ -143,193 +100,3 @@ describe("field validation", () => {
|
|||
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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { isMaskedSecret } from "@/utils/maskedSecretUtils";
|
||||
|
||||
export type AnthropicAuthMethod = "api_key" | "federation";
|
||||
import { isBlank, type FederationField } from "./federation_field";
|
||||
|
||||
export type IdentitySourceId =
|
||||
| "token_file"
|
||||
|
|
@ -10,16 +9,6 @@ export type IdentitySourceId =
|
|||
| "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;
|
||||
|
|
@ -27,15 +16,9 @@ export interface IdentitySource {
|
|||
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/";
|
||||
|
||||
|
|
@ -208,37 +191,31 @@ export const IDENTITY_SOURCES: readonly IdentitySource[] = [
|
|||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_IDENTITY_SOURCE: IdentitySourceId = "token_file";
|
||||
|
||||
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[] = [
|
||||
export const ANTHROPIC_FEDERATION_FIELDS: readonly FederationField[] = [
|
||||
...FEDERATION_CORE_FIELDS,
|
||||
...IDENTITY_SOURCES.flatMap((source) => source.fields),
|
||||
];
|
||||
|
||||
export const ANTHROPIC_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];
|
||||
|
|
@ -265,8 +242,6 @@ export const identitySourceOptions = (
|
|||
...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 =>
|
||||
|
|
@ -293,87 +268,8 @@ export const validateIssuerTtlSeconds = (value: unknown): string | 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);
|
||||
export const otherIdentitySourceKeys = (identitySource: IdentitySourceId): readonly string[] => {
|
||||
const source = identitySourceById(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)) };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,345 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildCreateCredentialValues,
|
||||
buildCredentialPatch,
|
||||
buildProviderChangePatch,
|
||||
federatedProviderOf,
|
||||
inferAuthMethod,
|
||||
isFederatedCredential,
|
||||
providerFieldValidators,
|
||||
selectionFor,
|
||||
} from "./credential_federation";
|
||||
|
||||
const apiKeySelection = { authMethod: "api_key" } as const;
|
||||
const tokenFileSelection = { authMethod: "federation", provider: "anthropic", identitySource: "token_file" } as const;
|
||||
const internalIssuerSelection = {
|
||||
authMethod: "federation",
|
||||
provider: "anthropic",
|
||||
identitySource: "internal_issuer",
|
||||
} as const;
|
||||
const keycloakSelection = { authMethod: "federation", provider: "anthropic", identitySource: "keycloak" } as const;
|
||||
const environmentSelection = {
|
||||
authMethod: "federation",
|
||||
provider: "anthropic",
|
||||
identitySource: "environment",
|
||||
} as const;
|
||||
const openAISelection = { authMethod: "federation", provider: "openai" } 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****",
|
||||
};
|
||||
|
||||
const storedOpenAI = {
|
||||
api_base: "https://api.openai.com/v1",
|
||||
openai_identity_provider_id: "idp_stored",
|
||||
openai_service_account_id: "svc_stored",
|
||||
openai_identity_token_file: "/var****",
|
||||
};
|
||||
|
||||
describe("federatedProviderOf", () => {
|
||||
it.each([
|
||||
["Anthropic", "anthropic"],
|
||||
["anthropic", "anthropic"],
|
||||
["OpenAI", "openai"],
|
||||
["openai", "openai"],
|
||||
])("offers federation for %s", (provider, expected) => {
|
||||
expect(federatedProviderOf(provider)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each(["OpenAI_Compatible", "OpenAI_Text", "Anthropic Text", "Azure", "", null, undefined])(
|
||||
"offers no federation for %s",
|
||||
(provider) => {
|
||||
expect(federatedProviderOf(provider)).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("selectionFor", () => {
|
||||
it("falls back to an api key for a provider without federation, whatever the auth method", () => {
|
||||
expect(selectionFor(null, "federation", "keycloak")).toEqual(apiKeySelection);
|
||||
expect(selectionFor("openai", "api_key", "keycloak")).toEqual(apiKeySelection);
|
||||
});
|
||||
|
||||
it("carries the identity source for Anthropic only", () => {
|
||||
expect(selectionFor("anthropic", "federation", "keycloak")).toEqual(keycloakSelection);
|
||||
expect(selectionFor("openai", "federation", "keycloak")).toEqual(openAISelection);
|
||||
});
|
||||
});
|
||||
|
||||
describe("providerFieldValidators", () => {
|
||||
it("checks the base URL of an OpenAI federated credential only", () => {
|
||||
expect(providerFieldValidators(openAISelection).api_base?.("https://gateway.example.com/v1")).toEqual(
|
||||
expect.stringContaining("OpenAI API"),
|
||||
);
|
||||
expect(providerFieldValidators(tokenFileSelection)).toEqual({});
|
||||
expect(providerFieldValidators(apiKeySelection)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
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("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");
|
||||
});
|
||||
|
||||
it("reads an OpenAI credential with federation values and no api key as federated", () => {
|
||||
expect(inferAuthMethod({ openai_service_account_id: "svc_1" })).toBe("federation");
|
||||
expect(inferAuthMethod({ api_key: "sk-p****", openai_service_account_id: "svc_1" })).toBe("api_key");
|
||||
expect(isFederatedCredential({ api_base: "https://api.openai.com/v1", openai_identity_token_file: "" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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("sends the trimmed OpenAI federation values without naming an Anthropic identity source", () => {
|
||||
const typedValues = {
|
||||
api_base: "https://api.openai.com/v1",
|
||||
organization: "",
|
||||
openai_identity_provider_id: "",
|
||||
openai_service_account_id: " svc_new ",
|
||||
openai_identity_token_file: "/var/run/secrets/kubernetes.io/serviceaccount/token\n",
|
||||
};
|
||||
expect(buildCreateCredentialValues(typedValues, openAISelection)).toEqual({
|
||||
api_base: "https://api.openai.com/v1",
|
||||
openai_service_account_id: "svc_new",
|
||||
openai_identity_token_file: "/var/run/secrets/kubernetes.io/serviceaccount/token",
|
||||
});
|
||||
});
|
||||
|
||||
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("writes nothing when the admin saves an OpenAI federated credential untouched", () => {
|
||||
expect(buildCredentialPatch(storedOpenAI, { ...storedOpenAI }, openAISelection, openAISelection)).toEqual({
|
||||
credential_values: {},
|
||||
credential_values_to_delete: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes the OpenAI federation values and keeps the base URL when the admin switches to an api key", () => {
|
||||
const patch = buildCredentialPatch(
|
||||
storedOpenAI,
|
||||
{ api_base: storedOpenAI.api_base, api_key: "sk-proj-new" },
|
||||
openAISelection,
|
||||
apiKeySelection,
|
||||
);
|
||||
expect(patch.credential_values).toEqual({ api_key: "sk-proj-new" });
|
||||
expect([...patch.credential_values_to_delete].sort()).toEqual([
|
||||
"openai_identity_provider_id",
|
||||
"openai_identity_token_file",
|
||||
"openai_service_account_id",
|
||||
]);
|
||||
});
|
||||
|
||||
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,145 @@
|
|||
import { isMaskedSecret } from "@/utils/maskedSecretUtils";
|
||||
import {
|
||||
ANTHROPIC_FEDERATION_FIELDS,
|
||||
ANTHROPIC_FEDERATION_VALUE_KEYS,
|
||||
identitySourceById,
|
||||
isAnthropicProvider,
|
||||
otherIdentitySourceKeys,
|
||||
type IdentitySourceId,
|
||||
} from "./anthropic_federation";
|
||||
import { isBlank, type FederationField } from "./federation_field";
|
||||
import { isOpenAIProvider, OPENAI_FEDERATION_FIELDS, validateOpenAIFederationApiBase } from "./openai_federation";
|
||||
|
||||
export type AuthMethod = "api_key" | "federation";
|
||||
|
||||
export type FederatedProvider = "anthropic" | "openai";
|
||||
|
||||
export type FederatedSelection =
|
||||
| { readonly authMethod: "federation"; readonly provider: "openai" }
|
||||
| { readonly authMethod: "federation"; readonly provider: "anthropic"; readonly identitySource: IdentitySourceId };
|
||||
|
||||
export type CredentialSelection = { readonly authMethod: "api_key" } | FederatedSelection;
|
||||
|
||||
export type ProviderFieldValidators = Readonly<Record<string, (value: unknown) => string | true>>;
|
||||
|
||||
export interface CredentialValuesPatch {
|
||||
readonly credential_values: Record<string, unknown>;
|
||||
readonly credential_values_to_delete: readonly string[];
|
||||
}
|
||||
|
||||
const API_KEY = "api_key";
|
||||
|
||||
const FEDERATION_VALUE_KEYS: readonly string[] = [
|
||||
...ANTHROPIC_FEDERATION_VALUE_KEYS,
|
||||
...OPENAI_FEDERATION_FIELDS.map((field) => field.key),
|
||||
];
|
||||
|
||||
const federationFieldsByKey: ReadonlyMap<string, FederationField> = new Map(
|
||||
[...ANTHROPIC_FEDERATION_FIELDS, ...OPENAI_FEDERATION_FIELDS].map((field) => [field.key, field]),
|
||||
);
|
||||
|
||||
const OPENAI_FEDERATION_PROVIDER_FIELD_VALIDATORS: ProviderFieldValidators = {
|
||||
api_base: validateOpenAIFederationApiBase,
|
||||
};
|
||||
|
||||
const NO_PROVIDER_FIELD_VALIDATORS: ProviderFieldValidators = {};
|
||||
|
||||
export const federatedProviderOf = (provider: string | null | undefined): FederatedProvider | null => {
|
||||
if (isAnthropicProvider(provider)) {
|
||||
return "anthropic";
|
||||
}
|
||||
return isOpenAIProvider(provider) ? "openai" : null;
|
||||
};
|
||||
|
||||
export const selectionFor = (
|
||||
provider: FederatedProvider | null,
|
||||
authMethod: AuthMethod,
|
||||
identitySource: IdentitySourceId,
|
||||
): CredentialSelection => {
|
||||
if (provider === null || authMethod === "api_key") {
|
||||
return { authMethod: "api_key" };
|
||||
}
|
||||
return provider === "anthropic"
|
||||
? { authMethod: "federation", provider, identitySource }
|
||||
: { authMethod: "federation", provider };
|
||||
};
|
||||
|
||||
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): AuthMethod =>
|
||||
isBlank(credentialValues?.[API_KEY]) && isFederatedCredential(credentialValues) ? "federation" : "api_key";
|
||||
|
||||
export const providerFieldValidators = (selection: CredentialSelection): ProviderFieldValidators =>
|
||||
selection.authMethod === "federation" && selection.provider === "openai"
|
||||
? OPENAI_FEDERATION_PROVIDER_FIELD_VALIDATORS
|
||||
: NO_PROVIDER_FIELD_VALIDATORS;
|
||||
|
||||
const identitySourceOf = (selection: CredentialSelection): IdentitySourceId | null =>
|
||||
selection.authMethod === "federation" && selection.provider === "anthropic" ? selection.identitySource : null;
|
||||
|
||||
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 fixedValuesFor = (selection: CredentialSelection): Readonly<Record<string, string>> => {
|
||||
const identitySource = identitySourceOf(selection);
|
||||
return identitySource === null ? {} : identitySourceById(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;
|
||||
}
|
||||
const identitySource = identitySourceOf(selection);
|
||||
return identitySource === null || identitySource === identitySourceOf(initial)
|
||||
? []
|
||||
: otherIdentitySourceKeys(identitySource);
|
||||
};
|
||||
|
||||
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)) };
|
||||
};
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { requiredFederationValue, validateMaskedValueUntouched } from "./federation_field";
|
||||
|
||||
describe("federation field validation", () => {
|
||||
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.each(["", " ", "\n", undefined, null])("refuses the blank required value %j", (value) => {
|
||||
expect(requiredFederationValue(value)).toBe("Required");
|
||||
});
|
||||
|
||||
it("accepts a required value with text in it", () => {
|
||||
expect(requiredFederationValue(" svc_1 ")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { isMaskedSecret } from "@/utils/maskedSecretUtils";
|
||||
|
||||
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 const isBlank = (value: unknown): boolean => {
|
||||
if (typeof value === "string") {
|
||||
return value.trim() === "";
|
||||
}
|
||||
return value === undefined || value === null;
|
||||
};
|
||||
|
||||
export const requiredFederationValue = (value: unknown): string | true => (isBlank(value) ? "Required" : true);
|
||||
|
||||
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;
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { isOpenAIProvider, validateOpenAIFederationApiBase } from "./openai_federation";
|
||||
|
||||
describe("isOpenAIProvider", () => {
|
||||
it.each(["OpenAI", "openai"])("matches %s", (provider) => {
|
||||
expect(isOpenAIProvider(provider)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["OpenAI_Compatible", "OpenAI_Text", "Azure", "", null, undefined])("does not match %s", (provider) => {
|
||||
expect(isOpenAIProvider(provider)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateOpenAIFederationApiBase", () => {
|
||||
it.each([
|
||||
"",
|
||||
" ",
|
||||
undefined,
|
||||
"https://api.openai.com/v1",
|
||||
"https://api.openai.com",
|
||||
"https://us.api.openai.com/v1",
|
||||
"https://API.OPENAI.COM/v1",
|
||||
])("accepts %s, which the proxy sends the federated token to", (apiBase) => {
|
||||
expect(validateOpenAIFederationApiBase(apiBase)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"http://api.openai.com/v1",
|
||||
"https://api.openai.com.evil.example/v1",
|
||||
"https://evilapi.openai.com/v1",
|
||||
"https://openai.com/v1",
|
||||
"https://gateway.example.com/v1",
|
||||
"api.openai.com/v1",
|
||||
"not a url",
|
||||
])("refuses %s, which the proxy rejects for workload identity federation", (apiBase) => {
|
||||
expect(validateOpenAIFederationApiBase(apiBase)).toEqual(expect.stringContaining("only reaches the OpenAI API"));
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { isBlank, type FederationField } from "./federation_field";
|
||||
|
||||
const OPENAI_API_HOST = "api.openai.com";
|
||||
|
||||
export const OPENAI_FEDERATION_FIELDS: readonly FederationField[] = [
|
||||
{
|
||||
key: "openai_identity_provider_id",
|
||||
label: "Identity Provider ID",
|
||||
tooltip:
|
||||
"The workload identity provider registered in your OpenAI organization that trusts the token's issuer. Leave empty when the proxy sets OPENAI_IDENTITY_PROVIDER_ID in its environment.",
|
||||
required: false,
|
||||
control: "text",
|
||||
},
|
||||
{
|
||||
key: "openai_service_account_id",
|
||||
label: "Service Account ID",
|
||||
tooltip:
|
||||
"The OpenAI service account the verified identity acts as. Its project decides what the model can reach. Leave empty when the proxy sets OPENAI_SERVICE_ACCOUNT_ID in its environment.",
|
||||
required: false,
|
||||
control: "text",
|
||||
},
|
||||
{
|
||||
key: "openai_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. Leave empty when the proxy sets OPENAI_IDENTITY_TOKEN_FILE in its environment.",
|
||||
placeholder: "/var/run/secrets/kubernetes.io/serviceaccount/token",
|
||||
required: false,
|
||||
control: "text",
|
||||
},
|
||||
];
|
||||
|
||||
export const isOpenAIProvider = (provider: string | null | undefined): boolean => provider?.toLowerCase() === "openai";
|
||||
|
||||
const isOpenAIApiHost = (hostname: string): boolean =>
|
||||
hostname === OPENAI_API_HOST || hostname.endsWith(`.${OPENAI_API_HOST}`);
|
||||
|
||||
const targetsOpenAIApi = (apiBase: string): boolean => {
|
||||
const url = URL.canParse(apiBase) ? new URL(apiBase) : null;
|
||||
return url?.protocol === "https:" && isOpenAIApiHost(url.hostname);
|
||||
};
|
||||
|
||||
export const validateOpenAIFederationApiBase = (value: unknown): string | true =>
|
||||
typeof value !== "string" || isBlank(value) || targetsOpenAIApi(value)
|
||||
? true
|
||||
: `Workload identity federation only reaches the OpenAI API. Enter https://${OPENAI_API_HOST}/v1 or a regional *.${OPENAI_API_HOST} URL, or leave this empty`;
|
||||
Loading…
Add table
Reference in a new issue