diff --git a/litellm/llms/anthropic/wif.py b/litellm/llms/anthropic/wif.py index 75b2be3ba0f..2c28aec0413 100644 --- a/litellm/llms/anthropic/wif.py +++ b/litellm/llms/anthropic/wif.py @@ -79,9 +79,22 @@ _KEYCLOAK_FIELD_MAP: Final[Mapping[str, str]] = MappingProxyType( "anthropic_keycloak_scope": "scope", } ) +_DENIAL_HINT: Final = ( + " Anthropic answers every denied exchange with the same 401; the reason (for example" + " workspace_id_required or jti_reused) is only shown in the Claude Console under" + " Settings > Workload identity, in the rule's authentication history." +) _WORKSPACE_HINT: Final = ( - " If the federation rule is scoped to a workspace, set ANTHROPIC_WORKSPACE_ID" - " (or the anthropic_workspace_id litellm param) to that workspace id." + " If the federation rule is enabled in more than one workspace, set anthropic_workspace_id" + " (or ANTHROPIC_WORKSPACE_ID) to the wrkspc_ id of the workspace to mint tokens for, or to 'default'." +) +_SERVICE_ACCOUNT_HINT: Final = ( + " Anthropic's reference lists service_account_id as required: set anthropic_service_account_id" + " (or ANTHROPIC_SERVICE_ACCOUNT_ID) to the svac_ id the federation rule targets." +) +_MISSING_IDS_HINT: Final = ( + " Copy them from the federation rule's detail page under Settings > Workload identity in the" + " Claude Console, or set ANTHROPIC_FEDERATION_RULE_ID and ANTHROPIC_ORGANIZATION_ID." ) _ALLOWLIST_HINT: Final = ( " Identity token files must sit under an allowed credential directory" @@ -112,6 +125,7 @@ def resolve_anthropic_wif_params(litellm_params: Mapping[str, object] | None) -> ) organization_id: Final = _config_value(litellm_params, "anthropic_organization_id", "ANTHROPIC_ORGANIZATION_ID") if federation_rule_id is None or organization_id is None: + _raise_if_identity_source_configured(litellm_params, federation_rule_id, organization_id) return None identity_source: Final = _resolve_identity_source(litellm_params) if identity_source is None: @@ -160,14 +174,47 @@ def _resolve_identity_source( keycloak_config: Final = _build_variant(KeycloakSource, params, _KEYCLOAK_FIELD_MAP) return identity_source_ref(keycloak_config), keycloak_assertion_source(keycloak_config) case _: - raise litellm.AuthenticationError( - message=( - f"{_IDENTITY_SOURCE_PARAM} must be one of " - f"{', '.join(kind.value for kind in AnthropicIdentitySourceKind)}; got {source_kind!r}." - ), - llm_provider="anthropic", - model="", - ) + _raise_unknown_source_kind(source_kind) + + +def _raise_unknown_source_kind(source_kind: str) -> NoReturn: + raise litellm.AuthenticationError( + message=( + f"{_IDENTITY_SOURCE_PARAM} must be one of " + f"{', '.join(kind.value for kind in AnthropicIdentitySourceKind)}; got {source_kind!r}." + ), + llm_provider="anthropic", + model="", + ) + + +def _raise_if_identity_source_configured( + litellm_params: Mapping[str, object] | None, federation_rule_id: str | None, organization_id: str | None +) -> None: + """A configured identity source is an explicit request to federate, so a missing rule or + organization id fails closed with the ids named, rather than silently skipping federation + and surfacing later as a missing API key.""" + source_kind: Final = _resolve_source_kind(litellm_params) + if source_kind is None: + return + if source_kind not in {kind.value for kind in AnthropicIdentitySourceKind}: + _raise_unknown_source_kind(source_kind) + missing: Final = tuple( + param + for param, value in ( + ("anthropic_federation_rule_id", federation_rule_id), + ("anthropic_organization_id", organization_id), + ) + if value is None + ) + raise litellm.AuthenticationError( + message=( + f"{_IDENTITY_SOURCE_PARAM} is {source_kind!r}, but {' and '.join(missing)} " + f"{'is' if len(missing) == 1 else 'are'} not set.{_MISSING_IDS_HINT}" + ), + llm_provider="anthropic", + model="", + ) def _resolve_source_kind(litellm_params: Mapping[str, object] | None) -> str | None: @@ -280,7 +327,12 @@ def _token_from_result(result: ExchangeResult, model: str, params: AnthropicWifP case MintedToken(): return result.access_token.get_secret_value() case _: - _raise_anthropic_wif_error(result, model=model, workspace_id_set=params.workspace_id is not None) + _raise_anthropic_wif_error( + result, + model=model, + workspace_id_set=params.workspace_id is not None, + service_account_id_set=params.service_account_id is not None, + ) def resolve_anthropic_base(api_base: str | None) -> str: @@ -404,15 +456,30 @@ def _validated_inline_ref(value: str) -> str: ) -def _raise_anthropic_wif_error(error: ExchangeError, model: str, workspace_id_set: bool) -> NoReturn: +def _raise_anthropic_wif_error( + error: ExchangeError, model: str, workspace_id_set: bool, service_account_id_set: bool +) -> NoReturn: + detail: Final = _error_detail( + error, workspace_id_set=workspace_id_set, service_account_id_set=service_account_id_set + ) raise litellm.AuthenticationError( - message=f"Anthropic workload identity federation failed. {_error_detail(error, workspace_id_set)}", + message=f"Anthropic workload identity federation failed. {detail}", llm_provider="anthropic", model=model, ) -def _error_detail(error: ExchangeError, workspace_id_set: bool) -> str: +def _denial_hints(workspace_id_set: bool, service_account_id_set: bool) -> str: + return "".join( + ( + _DENIAL_HINT, + "" if workspace_id_set else _WORKSPACE_HINT, + "" if service_account_id_set else _SERVICE_ACCOUNT_HINT, + ) + ) + + +def _error_detail(error: ExchangeError, workspace_id_set: bool, service_account_id_set: bool) -> str: match error: case AssertionSourceError() if error.kind == "disallowed_path": return f"Could not read the OIDC identity token from {error.source_ref}.{_ALLOWLIST_HINT}" @@ -421,8 +488,9 @@ def _error_detail(error: ExchangeError, workspace_id_set: bool) -> str: return f"{base} {error.detail}" if error.detail else base case InsecureTokenUrl(): return f"The token endpoint must use https; refusing to send the identity token to host {error.host!r}." - case TokenEndpointError() if error.status_code == 401 and not workspace_id_set: - return f"The token endpoint returned HTTP 401: {error.redacted_body}{_WORKSPACE_HINT}" + case TokenEndpointError() if error.status_code == 401: + hints: Final = _denial_hints(workspace_id_set, service_account_id_set) + return f"The token endpoint returned HTTP 401: {error.redacted_body}{hints}" case TokenEndpointError(): return f"The token endpoint returned HTTP {error.status_code}: {error.redacted_body}" case TokenTransportError(): diff --git a/litellm/proxy/public_endpoints/provider_create_fields.json b/litellm/proxy/public_endpoints/provider_create_fields.json index 39d0f81055f..0d0884b0f7c 100644 --- a/litellm/proxy/public_endpoints/provider_create_fields.json +++ b/litellm/proxy/public_endpoints/provider_create_fields.json @@ -372,7 +372,7 @@ "key": "anthropic_federation_rule_id", "label": "Federation Rule ID", "placeholder": null, - "tooltip": "The workload identity federation rule id, created in the Anthropic Console. With the LiteLLM-signed identity source you can leave this blank and fill it in on the next step, once the generated JWKS has been registered.", + "tooltip": "The fdrl_... id of the federation rule, shown on the rule's detail page under Settings > Workload identity in the Claude Console. With the LiteLLM-signed identity source, leave this blank until the generated JWKS is registered; the Register issuer step collects it.", "required": true, "field_type": "text", "options": null, @@ -382,7 +382,7 @@ "key": "anthropic_organization_id", "label": "Organization ID", "placeholder": null, - "tooltip": "The Anthropic organization id the federation rule belongs to.", + "tooltip": "The UUID of the Anthropic organization the federation rule belongs to, under Settings > Organization in the Claude Console. With the LiteLLM-signed identity source you can leave this blank and fill it in on the Register issuer step.", "required": true, "field_type": "text", "options": null, @@ -392,7 +392,7 @@ "key": "anthropic_service_account_id", "label": "Service Account ID", "placeholder": null, - "tooltip": "Optional. Required only if the federation rule is scoped to more than one service account.", + "tooltip": "The svac_... id of the service account the federation rule targets, shown on the rule's detail page in the Claude Console. Anthropic's reference lists it as required, though the exchange succeeds without it when the rule targets a single service account, so fill it in whenever you have it.", "required": false, "field_type": "text", "options": null, @@ -402,7 +402,7 @@ "key": "anthropic_workspace_id", "label": "Workspace ID", "placeholder": null, - "tooltip": "Optional. Set this if the federation rule is scoped to a workspace.", + "tooltip": "Required only when the federation rule is enabled in more than one workspace (for example 'All workspaces'); Anthropic then rejects the exchange with a 401 logged as workspace_id_required in the Console's authentication history. Set the wrkspc_... id of the workspace to mint tokens for, or the literal 'default'. Leave blank when the rule is enabled in a single workspace.", "required": false, "field_type": "text", "options": null, @@ -567,17 +567,18 @@ "label": "Workload Identity Federation (LiteLLM-signed)", "field_keys": [ "api_base", - "anthropic_federation_rule_id", - "anthropic_organization_id", - "anthropic_service_account_id", - "anthropic_workspace_id", "anthropic_issuer_url", "anthropic_issuer_subject", "anthropic_issuer_audience", "anthropic_issuer_ttl_seconds", - "anthropic_issuer_signing_key_ref" + "anthropic_issuer_signing_key_ref", + "anthropic_organization_id", + "anthropic_federation_rule_id", + "anthropic_service_account_id", + "anthropic_workspace_id" ], "optional_field_keys": [ + "anthropic_organization_id", "anthropic_federation_rule_id" ], "fixed_values": { diff --git a/tests/test_litellm/llms/anthropic/test_anthropic_wif.py b/tests/test_litellm/llms/anthropic/test_anthropic_wif.py index c72e2e81eb0..95b89e9cc71 100644 --- a/tests/test_litellm/llms/anthropic/test_anthropic_wif.py +++ b/tests/test_litellm/llms/anthropic/test_anthropic_wif.py @@ -659,7 +659,9 @@ class TestErrorMappingExhaustive: ) def test_every_variant_maps_to_authentication_error(self, error: ExchangeError): with pytest.raises(litellm.AuthenticationError) as exc_info: - _raise_anthropic_wif_error(error, model="claude-sonnet-4-5", workspace_id_set=False) + _raise_anthropic_wif_error( + error, model="claude-sonnet-4-5", workspace_id_set=False, service_account_id_set=False + ) assert exc_info.value.llm_provider == "anthropic" assert exc_info.value.model == "claude-sonnet-4-5" @@ -670,6 +672,7 @@ class TestErrorMappingExhaustive: AssertionSourceError(kind="unreadable", source_ref="oidc/keycloak/abc123", detail="invalid_client"), model="claude-sonnet-4-5", workspace_id_set=True, + service_account_id_set=True, ) assert "invalid_client" in exc_info.value.message @@ -682,6 +685,7 @@ class TestErrorMappingExhaustive: AssertionSourceError(kind="unreadable", source_ref="oidc/env/ANTHROPIC_IDENTITY_TOKEN"), model="claude-sonnet-4-5", workspace_id_set=True, + service_account_id_set=True, ) assert exc_info.value.message == ( @@ -738,32 +742,59 @@ class TestErrorMappingExhaustive: assert ".." not in exc_info.value.message -class TestWorkspaceHint: - def _raise_401(self, litellm_params: dict, monkeypatch: pytest.MonkeyPatch) -> str: +class TestDenialHints: + """Anthropic answers every denied exchange with an opaque 401 and logs the reason + (workspace_id_required, jti_reused, ...) only in the Console, so the error must say where + to look and name whichever optional id is still unset.""" + + BASE_PARAMS: Final = {"anthropic_federation_rule_id": "fdrl_1", "anthropic_organization_id": "org-1"} + + def _raise(self, litellm_params: dict, status_code: int, monkeypatch: pytest.MonkeyPatch) -> str: monkeypatch.setenv("ANTHROPIC_IDENTITY_TOKEN", "inline-jwt") - poster = ScriptedPoster([httpx.Response(401, json={"error": "invalid_grant"})]) + poster = ScriptedPoster([httpx.Response(status_code, json={"error": "invalid_grant"})]) engine = make_engine(poster) with pytest.raises(litellm.AuthenticationError) as exc_info: get_anthropic_wif_token(litellm_params, None, "claude-sonnet-4-5", engine) - assert len(poster.requests) == 2 return exc_info.value.message - def test_hint_when_workspace_unset(self, monkeypatch: pytest.MonkeyPatch): - message = self._raise_401( - {"anthropic_federation_rule_id": "fdrl_1", "anthropic_organization_id": "org-1"}, monkeypatch - ) + def test_401_points_at_console_authentication_history(self, monkeypatch: pytest.MonkeyPatch): + message = self._raise(self.BASE_PARAMS, 401, monkeypatch) + assert "authentication history" in message + assert "workspace_id_required" in message + + def test_500_carries_no_denial_hints(self, monkeypatch: pytest.MonkeyPatch): + message = self._raise(self.BASE_PARAMS, 500, monkeypatch) + assert "authentication history" not in message + assert "ANTHROPIC_WORKSPACE_ID" not in message + assert "ANTHROPIC_SERVICE_ACCOUNT_ID" not in message + + def test_hints_name_both_ids_when_both_unset(self, monkeypatch: pytest.MonkeyPatch): + message = self._raise(self.BASE_PARAMS, 401, monkeypatch) + assert "anthropic_workspace_id" in message + assert "ANTHROPIC_WORKSPACE_ID" in message + assert "anthropic_service_account_id" in message + assert "ANTHROPIC_SERVICE_ACCOUNT_ID" in message + + def test_no_workspace_hint_when_workspace_set(self, monkeypatch: pytest.MonkeyPatch): + message = self._raise({**self.BASE_PARAMS, "anthropic_workspace_id": "wrkspc_1"}, 401, monkeypatch) + assert "ANTHROPIC_WORKSPACE_ID" not in message + assert "ANTHROPIC_SERVICE_ACCOUNT_ID" in message + + def test_no_service_account_hint_when_service_account_set(self, monkeypatch: pytest.MonkeyPatch): + message = self._raise({**self.BASE_PARAMS, "anthropic_service_account_id": "svac_1"}, 401, monkeypatch) + assert "ANTHROPIC_SERVICE_ACCOUNT_ID" not in message assert "ANTHROPIC_WORKSPACE_ID" in message - def test_no_hint_when_workspace_set(self, monkeypatch: pytest.MonkeyPatch): - message = self._raise_401( - { - "anthropic_federation_rule_id": "fdrl_1", - "anthropic_organization_id": "org-1", - "anthropic_workspace_id": "wrkspc_1", - }, + def test_only_console_pointer_when_both_set(self, monkeypatch: pytest.MonkeyPatch): + message = self._raise( + {**self.BASE_PARAMS, "anthropic_workspace_id": "wrkspc_1", "anthropic_service_account_id": "svac_1"}, + 401, monkeypatch, ) + assert "authentication history" in message assert "ANTHROPIC_WORKSPACE_ID" not in message + assert "ANTHROPIC_SERVICE_ACCOUNT_ID" not in message + assert ".." not in message class TestFileRereadOnRefresh: @@ -1071,6 +1102,67 @@ class TestIdentitySourceValidationFailsClosed: assert secret_value not in exc_info.value.message +class TestMissingIdsFailClosedWhenIdentitySourceConfigured: + """An explicit identity source is a request to federate. Without the rule or organization id + the exchange cannot even be attempted, so resolution must say which ids are missing instead + of returning None and letting the request die later as a missing API key.""" + + INTERNAL_ISSUER_FIELDS: Final = { + "anthropic_identity_source": "internal_issuer", + "anthropic_issuer_url": "https://issuer.internal.example", + "anthropic_issuer_subject": "workload-a", + "anthropic_issuer_signing_key_ref": ISSUER_SIGNING_KEY_REF, + } + + def test_both_ids_missing_names_both(self): + with pytest.raises(litellm.AuthenticationError) as exc_info: + resolve_anthropic_wif_params(self.INTERNAL_ISSUER_FIELDS) + + message = exc_info.value.message + assert "'internal_issuer'" in message + assert "anthropic_federation_rule_id and anthropic_organization_id are not set" in message + assert "Settings > Workload identity" in message + assert "ANTHROPIC_FEDERATION_RULE_ID" in message + + def test_only_rule_id_missing_names_only_the_rule(self): + with pytest.raises(litellm.AuthenticationError) as exc_info: + resolve_anthropic_wif_params({**self.INTERNAL_ISSUER_FIELDS, "anthropic_organization_id": "org-1"}) + + assert "but anthropic_federation_rule_id is not set" in exc_info.value.message + + def test_only_organization_id_missing_names_only_the_org(self): + with pytest.raises(litellm.AuthenticationError) as exc_info: + resolve_anthropic_wif_params({**self.INTERNAL_ISSUER_FIELDS, "anthropic_federation_rule_id": "fdrl_1"}) + + assert "but anthropic_organization_id is not set" in exc_info.value.message + + def test_keycloak_source_fails_closed_too(self): + with pytest.raises(litellm.AuthenticationError, match="'keycloak', but anthropic_federation_rule_id"): + resolve_anthropic_wif_params( + {"anthropic_identity_source": "keycloak", "anthropic_organization_id": "org-1"} + ) + + def test_env_configured_source_fails_closed(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("ANTHROPIC_IDENTITY_SOURCE", "internal_issuer") + with pytest.raises(litellm.AuthenticationError, match="anthropic_organization_id is not set"): + resolve_anthropic_wif_params({"anthropic_federation_rule_id": "fdrl_1"}) + + def test_env_ids_satisfy_the_gate(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("ANTHROPIC_FEDERATION_RULE_ID", "fdrl_env") + monkeypatch.setenv("ANTHROPIC_ORGANIZATION_ID", "org-env") + params = resolve_anthropic_wif_params(self.INTERNAL_ISSUER_FIELDS) + assert params is not None + assert params.federation_rule_id == "fdrl_env" + + def test_unknown_source_with_missing_ids_reports_the_unknown_source(self): + with pytest.raises(litellm.AuthenticationError, match="must be one of internal_issuer, keycloak"): + resolve_anthropic_wif_params({"anthropic_identity_source": "bogus"}) + + def test_legacy_token_params_without_ids_still_return_none(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("ANTHROPIC_IDENTITY_SOURCE", "internal_issuer") + assert resolve_anthropic_wif_params({"anthropic_identity_token": "oidc/env/TOK"}) is None + + class TestConfigYamlShapedIdentitySources: """One litellm_params dict per identity source, shaped exactly like the model_list[].litellm_params block a proxy config.yaml carries -- proving an operator can diff --git a/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py b/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py index 0b7c348356d..1f6c6b1c558 100644 --- a/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py +++ b/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py @@ -1268,11 +1268,14 @@ def test_credential_variants_rejects_optional_field_keys_the_variant_does_not_mo ) -def test_anthropic_internal_issuer_variant_relaxes_only_the_federation_rule_id(): - """The federation rule id is read off the Anthropic Console only after the generated JWKS is - registered, and the JWKS only exists once the credential is saved, so demanding it up front - would deadlock first-time setup of the LiteLLM-signed variant. Every other variant collects it - on the one and only form it has, so there it stays required.""" +def test_anthropic_internal_issuer_variant_relaxes_only_the_ids_anthropic_generates_later(): + """The federation rule id and organization id are read off the Anthropic Console only after + the generated JWKS is registered, and the JWKS only exists once the credential is saved, so + demanding them up front would deadlock first-time setup of the LiteLLM-signed variant; the + wizard's Register issuer step collects them instead. Every other variant collects them on the + one and only form it has, so there they stay required. The service account id and workspace + id are optional everywhere: Anthropic mints without the former when the rule targets a single + service account and needs the latter only when the rule is enabled in more than one workspace.""" app_instance = FastAPI() app_instance.include_router(router) test_client = TestClient(app_instance) @@ -1286,9 +1289,26 @@ def test_anthropic_internal_issuer_variant_relaxes_only_the_federation_rule_id() variants_by_id = {v["id"]: v for v in variants_block["variants"]} assert field_defs_by_key["anthropic_federation_rule_id"]["required"] is True - assert variants_by_id["wif_internal_issuer"]["optional_field_keys"] == ["anthropic_federation_rule_id"] + assert field_defs_by_key["anthropic_organization_id"]["required"] is True + assert field_defs_by_key["anthropic_service_account_id"]["required"] is False + assert field_defs_by_key["anthropic_workspace_id"]["required"] is False + assert "more than one workspace" in field_defs_by_key["anthropic_workspace_id"]["tooltip"] + assert variants_by_id["wif_internal_issuer"]["optional_field_keys"] == [ + "anthropic_organization_id", + "anthropic_federation_rule_id", + ] + assert variants_by_id["wif_internal_issuer"]["field_keys"][-4:] == [ + "anthropic_organization_id", + "anthropic_federation_rule_id", + "anthropic_service_account_id", + "anthropic_workspace_id", + ] for variant_id in ("api_key", "wif_token", "wif_token_file", "wif_keycloak"): assert variants_by_id[variant_id]["optional_field_keys"] == [] + for variant_id in ("wif_token", "wif_token_file", "wif_keycloak"): + assert {"anthropic_organization_id", "anthropic_federation_rule_id"} <= set( + variants_by_id[variant_id]["field_keys"] + ) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/AddProviderPanel.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/AddProviderPanel.integration.test.tsx index b6cffd9cecb..bd0bdc45cd3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/AddProviderPanel.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/AddProviderPanel.integration.test.tsx @@ -67,6 +67,8 @@ vi.mock("@/app/(dashboard)/hooks/providers/useProviderFields", () => ({ tooltip: "Can be left blank and filled in once the JWKS is registered.", }, { key: "anthropic_organization_id", label: "Organization ID", field_type: "text", required: true }, + { key: "anthropic_service_account_id", label: "Service Account ID", field_type: "text", required: false }, + { key: "anthropic_workspace_id", label: "Workspace ID", field_type: "text", required: false }, { key: "anthropic_issuer_url", label: "Issuer URL", field_type: "text", required: true }, { key: "anthropic_issuer_subject", label: "Issuer Subject", field_type: "text", required: true }, { @@ -82,13 +84,15 @@ vi.mock("@/app/(dashboard)/hooks/providers/useProviderFields", () => ({ id: "wif_internal_issuer", label: "Workload Identity Federation (LiteLLM-signed)", field_keys: [ - "anthropic_federation_rule_id", - "anthropic_organization_id", "anthropic_issuer_url", "anthropic_issuer_subject", "anthropic_issuer_signing_key_ref", + "anthropic_organization_id", + "anthropic_federation_rule_id", + "anthropic_service_account_id", + "anthropic_workspace_id", ], - optional_field_keys: ["anthropic_federation_rule_id"], + optional_field_keys: ["anthropic_organization_id", "anthropic_federation_rule_id"], fixed_values: { anthropic_identity_source: "internal_issuer" }, }, ], @@ -124,6 +128,35 @@ const chooseProvider = async (user: ReturnType, name: st const rowFor = (upstreamId: string) => within(screen.getByRole("row", { name: (accessibleName) => accessibleName.startsWith(`${upstreamId} `) })); +const INTERNAL_ISSUER_CREATE_VALUES = { + anthropic_issuer_url: "https://proxy.example.com", + anthropic_issuer_subject: "litellm-proxy", + anthropic_issuer_signing_key_ref: "os.environ/SIGNING_KEY", + anthropic_identity_source: "internal_issuer", +}; + +const saveInternalIssuerCredential = async (user: ReturnType, name: string) => { + await chooseProvider(user, "Anthropic"); + await user.type(screen.getByLabelText("Credential name"), name); + await user.click(screen.getByRole("button", { name: /Next/ })); + await chooseSelectOption( + user, + await screen.findByRole("combobox", { name: "Authentication method" }), + "Workload Identity Federation (LiteLLM-signed)", + ); + fireEvent.change(await screen.findByLabelText("Issuer URL"), { target: { value: "https://proxy.example.com" } }); + fireEvent.change(screen.getByLabelText("Issuer Subject"), { target: { value: "litellm-proxy" } }); + fireEvent.change(screen.getByLabelText("Signing Key Reference"), { target: { value: "os.environ/SIGNING_KEY" } }); + await user.click(screen.getByRole("button", { name: "Save credential" })); + await screen.findByText("Register this JWKS with Anthropic"); +}; + +const fillFederationIds = (ids: Record) => { + for (const [label, value] of Object.entries(ids)) { + fireEvent.change(screen.getByLabelText(label), { target: { value } }); + } +}; + describe("AddProviderPanel", () => { beforeEach(() => { vi.clearAllMocks(); @@ -291,56 +324,150 @@ describe("AddProviderPanel", () => { ); }); - it("saves a LiteLLM-signed credential with a blank federation rule id, then PATCHes it from the JWKS step", async () => { + it("saves a LiteLLM-signed credential before any Anthropic id exists, then collects them all on the JWKS step", async () => { + discoverProviderModelsCall.mockResolvedValue({ models: ["claude-3-opus"] }); + const { user } = await setup(); + + await saveInternalIssuerCredential(user, "anthropic-wif"); + + // Every id comes off the Anthropic Console only once the JWKS below is registered, and the + // JWKS only exists once the credential is saved, so saving must not demand any of them first. + expect(credentialCreateCall).toHaveBeenCalledWith("test-access-token", { + credential_name: "anthropic-wif", + credential_values: INTERNAL_ISSUER_CREATE_VALUES, + credential_info: { custom_llm_provider: "anthropic" }, + }); + expect(getCredentialJwksCall).toHaveBeenCalledWith("test-access-token", "anthropic-wif"); + + expect(screen.getByText("Still needed before discovery: Organization ID, Federation Rule ID.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Next/ })).toBeDisabled(); + + fillFederationIds({ "Organization ID": "org-1" }); + expect(screen.getByText("Still needed before discovery: Federation Rule ID.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Next/ })).toBeDisabled(); + + fillFederationIds({ "Federation Rule ID": " fdrl_abc ", "Service Account ID": "svac_1" }); + expect(screen.queryByText(/Still needed before discovery/)).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: /Next/ })); + + await waitFor(() => + expect(credentialUpdateCall).toHaveBeenCalledWith("test-access-token", "anthropic-wif", { + credential_name: "anthropic-wif", + credential_values: { + anthropic_organization_id: "org-1", + anthropic_federation_rule_id: "fdrl_abc", + anthropic_service_account_id: "svac_1", + }, + credential_info: { custom_llm_provider: "anthropic" }, + }), + ); + expect(credentialUpdateCall).toHaveBeenCalledTimes(1); + expect(await screen.findByText("claude-3-opus")).toBeInTheDocument(); + expect(discoverProviderModelsCall).toHaveBeenCalledTimes(1); + }); + + it("keeps the ids from the JWKS step across Back, a credential re-save and the return trip", async () => { + discoverProviderModelsCall.mockRejectedValueOnce(new Error("Authentication failed")); + discoverProviderModelsCall.mockResolvedValueOnce({ models: ["claude-3-opus"] }); + const { user } = await setup(); + + await saveInternalIssuerCredential(user, "anthropic-wif"); + fillFederationIds({ "Organization ID": "org-1", "Federation Rule ID": "fdrl_abc", "Service Account ID": "svac_1" }); + await user.click(screen.getByRole("button", { name: /Next/ })); + expect(await screen.findByText("Discovery failed")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /Back/ })); + expect(await screen.findByText("Register this JWKS with Anthropic")).toBeInTheDocument(); + expect(screen.getByLabelText("Federation Rule ID")).toHaveValue("fdrl_abc"); + expect(screen.getByLabelText("Service Account ID")).toHaveValue("svac_1"); + + await user.click(screen.getByRole("button", { name: /Back/ })); + expect(await screen.findByLabelText("Organization ID")).toHaveValue("org-1"); + expect(screen.getByLabelText("Federation Rule ID")).toHaveValue("fdrl_abc"); + expect(screen.getByLabelText("Service Account ID")).toHaveValue("svac_1"); + expect(screen.getByLabelText("Workspace ID")).toHaveValue(""); + + credentialUpdateCall.mockClear(); + await user.click(screen.getByRole("button", { name: "Save changes" })); + expect(await screen.findByText("Register this JWKS with Anthropic")).toBeInTheDocument(); + expect(credentialUpdateCall).toHaveBeenCalledWith("test-access-token", "anthropic-wif", { + credential_name: "anthropic-wif", + credential_values: { + ...INTERNAL_ISSUER_CREATE_VALUES, + anthropic_organization_id: "org-1", + anthropic_federation_rule_id: "fdrl_abc", + anthropic_service_account_id: "svac_1", + }, + credential_info: { custom_llm_provider: "anthropic" }, + }); + expect(screen.getByLabelText("Federation Rule ID")).toHaveValue("fdrl_abc"); + expect(screen.queryByText(/Still needed before discovery/)).not.toBeInTheDocument(); + + credentialUpdateCall.mockClear(); + await user.click(screen.getByRole("button", { name: /Next/ })); + expect(await screen.findByText("claude-3-opus")).toBeInTheDocument(); + expect(credentialUpdateCall).not.toHaveBeenCalled(); + }); + + it("lets a failed discovery be fixed by adding the Workspace ID on the JWKS step, PATCHing only that id", async () => { + discoverProviderModelsCall.mockRejectedValueOnce(new Error("Model discovery failed: HTTP 401")); + discoverProviderModelsCall.mockResolvedValueOnce({ models: ["claude-3-opus"] }); + const { user } = await setup(); + + await saveInternalIssuerCredential(user, "anthropic-wif"); + fillFederationIds({ "Organization ID": "org-1", "Federation Rule ID": "fdrl_abc" }); + await user.click(screen.getByRole("button", { name: /Next/ })); + expect(await screen.findByText("Model discovery failed: HTTP 401")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /Back/ })); + fillFederationIds({ "Workspace ID": "wrkspc_1" }); + credentialUpdateCall.mockClear(); + await user.click(screen.getByRole("button", { name: /Next/ })); + + await waitFor(() => + expect(credentialUpdateCall).toHaveBeenCalledWith("test-access-token", "anthropic-wif", { + credential_name: "anthropic-wif", + credential_values: { anthropic_workspace_id: "wrkspc_1" }, + credential_info: { custom_llm_provider: "anthropic" }, + }), + ); + expect(await screen.findByText("claude-3-opus")).toBeInTheDocument(); + expect(discoverProviderModelsCall).toHaveBeenCalledTimes(2); + }); + + it("deletes an id cleared on the JWKS step instead of leaving the saved value in place", async () => { discoverProviderModelsCall.mockResolvedValue({ models: ["claude-3-opus"] }); const { user } = await setup(); await chooseProvider(user, "Anthropic"); await user.type(screen.getByLabelText("Credential name"), "anthropic-wif"); await user.click(screen.getByRole("button", { name: /Next/ })); - await chooseSelectOption( user, await screen.findByRole("combobox", { name: "Authentication method" }), "Workload Identity Federation (LiteLLM-signed)", ); - - fireEvent.change(await screen.findByLabelText("Organization ID"), { target: { value: "org-1" } }); - fireEvent.change(screen.getByLabelText("Issuer URL"), { target: { value: "https://proxy.example.com" } }); + fireEvent.change(await screen.findByLabelText("Issuer URL"), { target: { value: "https://proxy.example.com" } }); fireEvent.change(screen.getByLabelText("Issuer Subject"), { target: { value: "litellm-proxy" } }); fireEvent.change(screen.getByLabelText("Signing Key Reference"), { target: { value: "os.environ/SIGNING_KEY" } }); - expect(screen.getByLabelText("Federation Rule ID")).toHaveValue(""); - + fireEvent.change(screen.getByLabelText("Organization ID"), { target: { value: "org-1" } }); + fireEvent.change(screen.getByLabelText("Federation Rule ID"), { target: { value: "fdrl_abc" } }); + fireEvent.change(screen.getByLabelText("Workspace ID"), { target: { value: "wrkspc_stale" } }); await user.click(screen.getByRole("button", { name: "Save credential" })); + await screen.findByText("Register this JWKS with Anthropic"); + expect(screen.getByLabelText("Workspace ID")).toHaveValue("wrkspc_stale"); - // The rule id is only readable off the Anthropic Console once the JWKS below is registered, - // and the JWKS only exists once the credential is saved, so saving must not demand it first. - await waitFor(() => - expect(credentialCreateCall).toHaveBeenCalledWith("test-access-token", { - credential_name: "anthropic-wif", - credential_values: { - anthropic_organization_id: "org-1", - anthropic_issuer_url: "https://proxy.example.com", - anthropic_issuer_subject: "litellm-proxy", - anthropic_issuer_signing_key_ref: "os.environ/SIGNING_KEY", - anthropic_identity_source: "internal_issuer", - }, - credential_info: { custom_llm_provider: "anthropic" }, - }), - ); - - expect(await screen.findByText("Register this JWKS with Anthropic")).toBeInTheDocument(); - expect(getCredentialJwksCall).toHaveBeenCalledWith("test-access-token", "anthropic-wif"); - - fireEvent.change(screen.getByLabelText("Federation Rule ID"), { target: { value: "rule-abc" } }); + fillFederationIds({ "Workspace ID": "" }); await user.click(screen.getByRole("button", { name: /Next/ })); + const workspaceDeletion = { + credential_name: "anthropic-wif", + credential_values: {}, + credential_info: { custom_llm_provider: "anthropic" }, + credential_values_to_delete: ["anthropic_workspace_id"], + }; await waitFor(() => - expect(credentialUpdateCall).toHaveBeenCalledWith("test-access-token", "anthropic-wif", { - credential_name: "anthropic-wif", - credential_values: { anthropic_federation_rule_id: "rule-abc" }, - credential_info: { custom_llm_provider: "anthropic" }, - }), + expect(credentialUpdateCall).toHaveBeenCalledWith("test-access-token", "anthropic-wif", workspaceDeletion), ); expect(await screen.findByText("claude-3-opus")).toBeInTheDocument(); }); @@ -375,8 +502,7 @@ describe("AddProviderPanel", () => { await screen.findByRole("combobox", { name: "Authentication method" }), "Workload Identity Federation (LiteLLM-signed)", ); - fireEvent.change(await screen.findByLabelText("Organization ID"), { target: { value: "org-1" } }); - fireEvent.change(screen.getByLabelText("Issuer URL"), { target: { value: "https://proxy.example.com" } }); + fireEvent.change(await screen.findByLabelText("Issuer URL"), { target: { value: "https://proxy.example.com" } }); fireEvent.change(screen.getByLabelText("Issuer Subject"), { target: { value: "litellm-proxy" } }); fireEvent.change(screen.getByLabelText("Signing Key Reference"), { target: { value: "os.environ/SIGNING_KEY" } }); await user.click(screen.getByRole("button", { name: "Save credential" })); @@ -418,8 +544,7 @@ describe("AddProviderPanel", () => { await screen.findByRole("combobox", { name: "Authentication method" }), "Workload Identity Federation (LiteLLM-signed)", ); - fireEvent.change(await screen.findByLabelText("Organization ID"), { target: { value: "org-1" } }); - fireEvent.change(screen.getByLabelText("Issuer URL"), { target: { value: "https://proxy.example.com" } }); + fireEvent.change(await screen.findByLabelText("Issuer URL"), { target: { value: "https://proxy.example.com" } }); fireEvent.change(screen.getByLabelText("Issuer Subject"), { target: { value: "litellm-proxy" } }); fireEvent.change(screen.getByLabelText("Signing Key Reference"), { target: { value: "os.environ/SIGNING_KEY" } }); await user.click(screen.getByRole("button", { name: "Save credential" })); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/AddProviderPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/AddProviderPanel.tsx index e23fed77204..602c3593d83 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/AddProviderPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/AddProviderPanel.tsx @@ -1,7 +1,7 @@ "use client"; import React from "react"; -import { useForm, FormProvider } from "react-hook-form"; +import { useForm, useWatch, FormProvider } from "react-hook-form"; import { useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; @@ -45,6 +45,7 @@ import { type DiscoveredModelRow, type ModelGroupAliasMap, } from "./wizardLogic"; +import { federationIdsUpdate, readFederationIds, withFederationIds } from "./anthropicFederation"; import ReviewModelsStep from "./ReviewModelsStep"; import { DiscoverStep, JwksStep, ProviderStep, ResultsStep } from "./WizardSteps"; @@ -92,7 +93,6 @@ export default function AddProviderPanel() { const [credentialName, setCredentialName] = React.useState(""); const [savedCredential, setSavedCredential] = React.useState<{ name: string; provider: string } | null>(null); const [savedValues, setSavedValues] = React.useState>({}); - const [federationRuleId, setFederationRuleId] = React.useState(""); const [jwks, setJwks] = React.useState(null); const [jwksError, setJwksError] = React.useState(null); const [discoveryError, setDiscoveryError] = React.useState(null); @@ -105,6 +105,7 @@ export default function AddProviderPanel() { const form = useForm({ mode: "onChange" }); const registry = useMountRegistry(); + const federationIds = readFederationIds(useWatch({ control: form.control })); const providerOptions: SearchSelectOption[] = React.useMemo( () => @@ -168,9 +169,6 @@ export default function AddProviderPanel() { } setSavedValues(values); setSavedCredential({ name: credentialName, provider: litellmProvider }); - setFederationRuleId( - typeof values.anthropic_federation_rule_id === "string" ? values.anthropic_federation_rule_id : "", - ); queryClient.invalidateQueries({ queryKey: ["credentials"] }); toast.success(`Credential "${credentialName}" saved`); if (values.anthropic_identity_source === ANTHROPIC_INTERNAL_ISSUER_DISCRIMINATOR) { @@ -196,18 +194,24 @@ export default function AddProviderPanel() { } }; - const confirmFederationRuleId = async () => { + const confirmFederationIds = async () => { if (!accessToken) return; - if (federationRuleId !== savedValues.anthropic_federation_rule_id) { + const ids = readFederationIds(form.getValues()); + const update = federationIdsUpdate(savedValues, ids); + if (update !== null) { + const updatePayload = { + credential_name: credentialName, + credential_values: update.credential_values, + credential_info: { custom_llm_provider: litellmProvider }, + ...(update.credential_values_to_delete.length > 0 + ? { credential_values_to_delete: [...update.credential_values_to_delete] } + : {}), + }; try { - await credentialUpdateCall(accessToken, credentialName, { - credential_name: credentialName, - credential_values: { anthropic_federation_rule_id: federationRuleId }, - credential_info: { custom_llm_provider: litellmProvider }, - }); - setSavedValues((prev) => ({ ...prev, anthropic_federation_rule_id: federationRuleId })); + await credentialUpdateCall(accessToken, credentialName, updatePayload); + setSavedValues((prev) => withFederationIds(prev, ids)); } catch (error) { - toast.fromError(`Failed to save the federation rule id: ${extractProxyErrorMessage(error)}`); + toast.fromError(`Failed to save the federation ids: ${extractProxyErrorMessage(error)}`); return; } } @@ -342,10 +346,10 @@ export default function AddProviderPanel() { form.setValue(key, value, { shouldDirty: true })} onBack={() => goTo("credential")} - onNext={() => void confirmFederationRuleId()} + onNext={() => void confirmFederationIds()} /> )} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/WizardSteps.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/WizardSteps.tsx index 11e80990616..21faaf29115 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/WizardSteps.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/WizardSteps.tsx @@ -4,7 +4,7 @@ import React from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; -import { Field, FieldLabel } from "@/components/ui/field"; +import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"; import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect"; import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert"; import CopyButton from "@/components/shared/CopyButton"; @@ -12,6 +12,12 @@ import { AlertTriangle, ArrowLeft, ArrowRight, Loader2 } from "lucide-react"; import { Providers } from "@/components/provider_info_helpers"; import type { AnthropicJwks } from "@/components/networking"; import type { CreationResult } from "./wizardLogic"; +import { + ANTHROPIC_FEDERATION_FIELDS, + missingFederationFields, + type AnthropicFederationIds, + type AnthropicFederationKey, +} from "./anthropicFederation"; const CREATION_RESULT_CLASS_NAME: Record = { failed: "text-destructive", @@ -72,8 +78,8 @@ export const ProviderStep: React.FC = ({ interface JwksStepProps { jwks: AnthropicJwks | null; jwksError: string | null; - federationRuleId: string; - onFederationRuleIdChange: (value: string) => void; + federationIds: AnthropicFederationIds; + onFederationIdChange: (key: AnthropicFederationKey, value: string) => void; onBack: () => void; onNext: () => void; } @@ -81,52 +87,65 @@ interface JwksStepProps { export const JwksStep: React.FC = ({ jwks, jwksError, - federationRuleId, - onFederationRuleIdChange, + federationIds, + onFederationIdChange, onBack, onNext, -}) => ( - - - - Register this JWKS with Anthropic - - Register this public JWKS as the inline issuer for your federation rule in the Anthropic Console, then paste - the resulting Federation Rule ID below. - - - {jwksError && ( - - - Could not load JWKS - {jwksError} +}) => { + const missing = missingFederationFields(federationIds); + return ( + + + + Register this JWKS with Anthropic + + In the Claude Console, open Settings {">"} Workload identity, click Connect workload, choose Custom OIDC and + paste this JWKS as the inline key set, using the Issuer URL and Subject from the previous step. Once the + rule and its service account exist, copy their ids below. Everything entered here is saved to this + credential before discovery runs. + - )} - {jwks && ( -
- -
{JSON.stringify(jwks, null, 2)}
+ {jwksError && ( + + + Could not load JWKS + {jwksError} + + )} + {jwks && ( +
+ +
{JSON.stringify(jwks, null, 2)}
+
+ )} + {ANTHROPIC_FEDERATION_FIELDS.map((field) => ( + + {field.label} + onFederationIdChange(field.key, e.target.value)} + /> + {field.hint} + + ))} + {missing.length > 0 && ( +

Still needed before discovery: {missing.join(", ")}.

+ )} +
+ +
- )} - - Federation Rule ID - onFederationRuleIdChange(e.target.value)} - /> - -
- - -
- - -); + + + ); +}; interface DiscoverStepProps { isDiscovering: boolean; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/anthropicFederation.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/anthropicFederation.test.ts new file mode 100644 index 00000000000..60633487eb5 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/anthropicFederation.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { + federationIdsUpdate, + missingFederationFields, + readFederationIds, + withFederationIds, +} from "./anthropicFederation"; + +const ALL_IDS = { + anthropic_organization_id: "org-1", + anthropic_federation_rule_id: "fdrl_1", + anthropic_service_account_id: "svac_1", + anthropic_workspace_id: "wrkspc_1", +}; + +const ids = (overrides: Partial> = {}) => readFederationIds({ ...ALL_IDS, ...overrides }); + +describe("readFederationIds", () => { + it("reads the four ids, trimming pasted whitespace and treating anything else as blank", () => { + const formValues = { + anthropic_organization_id: " org-1 ", + anthropic_federation_rule_id: undefined, + anthropic_service_account_id: 42, + api_key: "unrelated", + }; + const onlyOrganization = { + anthropic_organization_id: "org-1", + anthropic_federation_rule_id: "", + anthropic_service_account_id: "", + anthropic_workspace_id: "", + }; + expect(readFederationIds(formValues)).toEqual(onlyOrganization); + }); +}); + +describe("missingFederationFields", () => { + it("names only the organization and federation rule when everything is blank", () => { + expect(missingFederationFields(readFederationIds({}))).toEqual(["Organization ID", "Federation Rule ID"]); + }); + + it("does not require the service account or workspace ids", () => { + expect(missingFederationFields(ids({ anthropic_service_account_id: "", anthropic_workspace_id: "" }))).toEqual([]); + }); + + it("treats whitespace as blank", () => { + expect(missingFederationFields(ids({ anthropic_federation_rule_id: " " }))).toEqual(["Federation Rule ID"]); + }); +}); + +describe("federationIdsUpdate", () => { + it("is null when the entered ids match the saved credential", () => { + expect( + federationIdsUpdate( + { anthropic_organization_id: "org-1", anthropic_issuer_url: "https://proxy.example.com" }, + ids({ anthropic_federation_rule_id: "", anthropic_service_account_id: "", anthropic_workspace_id: "" }), + ), + ).toBeNull(); + }); + + it("sends only the ids that changed and keeps the untouched saved ones out of the payload", () => { + expect( + federationIdsUpdate( + { anthropic_organization_id: "org-1" }, + ids({ anthropic_service_account_id: "", anthropic_workspace_id: "" }), + ), + ).toEqual({ credential_values: { anthropic_federation_rule_id: "fdrl_1" }, credential_values_to_delete: [] }); + }); + + it("deletes an id the operator cleared after it was saved instead of merging over it", () => { + expect( + federationIdsUpdate( + { anthropic_organization_id: "org-1", anthropic_federation_rule_id: "fdrl_1", anthropic_workspace_id: "w" }, + ids({ anthropic_service_account_id: "", anthropic_workspace_id: "" }), + ), + ).toEqual({ credential_values: {}, credential_values_to_delete: ["anthropic_workspace_id"] }); + }); + + it("ignores whitespace-only differences against the saved value", () => { + expect(federationIdsUpdate({ anthropic_organization_id: " org-1" }, ids())).toEqual({ + credential_values: { + anthropic_federation_rule_id: "fdrl_1", + anthropic_service_account_id: "svac_1", + anthropic_workspace_id: "wrkspc_1", + }, + credential_values_to_delete: [], + }); + }); +}); + +describe("withFederationIds", () => { + it("replaces the saved ids with the entered ones, dropping cleared ids and keeping other fields", () => { + const saved = { anthropic_organization_id: "old", anthropic_workspace_id: "w", anthropic_issuer_url: "u" }; + const merged = { + anthropic_issuer_url: "u", + anthropic_organization_id: "org-1", + anthropic_federation_rule_id: "fdrl_1", + anthropic_service_account_id: "svac_1", + }; + expect(withFederationIds(saved, ids({ anthropic_workspace_id: "" }))).toEqual(merged); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/anthropicFederation.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/anthropicFederation.ts new file mode 100644 index 00000000000..a31d0ebbaba --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/anthropicFederation.ts @@ -0,0 +1,82 @@ +export const ANTHROPIC_FEDERATION_FIELDS = [ + { + key: "anthropic_organization_id", + label: "Organization ID", + required: true, + hint: "The UUID under Settings > Organization in the Claude Console.", + }, + { + key: "anthropic_federation_rule_id", + label: "Federation Rule ID", + required: true, + hint: "The fdrl_... id on the rule's detail page under Settings > Workload identity.", + }, + { + key: "anthropic_service_account_id", + label: "Service Account ID", + required: false, + hint: + "The svac_... id of the service account the rule targets, on the same page. Anthropic lists it as required, " + + "though the exchange works without it when the rule targets a single service account, so fill it in whenever " + + "you have it.", + }, + { + key: "anthropic_workspace_id", + label: "Workspace ID", + required: false, + hint: + "Required only when the rule is enabled in more than one workspace (for example All workspaces); Anthropic " + + "then rejects the exchange with a 401 logged as workspace_id_required. Use the wrkspc_... id under " + + "Settings > Workspaces, or the literal default. Leave blank when the rule is enabled in a single workspace.", + }, +] as const; + +export type AnthropicFederationKey = (typeof ANTHROPIC_FEDERATION_FIELDS)[number]["key"]; + +export type AnthropicFederationIds = Readonly>; + +export interface FederationIdsUpdate { + readonly credential_values: Readonly>>; + readonly credential_values_to_delete: readonly AnthropicFederationKey[]; +} + +const trimmed = (value: unknown): string => (typeof value === "string" ? value.trim() : ""); + +export const readFederationIds = (values: Readonly>): AnthropicFederationIds => + Object.fromEntries(ANTHROPIC_FEDERATION_FIELDS.map((field) => [field.key, trimmed(values[field.key])])) as Record< + AnthropicFederationKey, + string + >; + +export const missingFederationFields = (ids: AnthropicFederationIds): readonly string[] => + ANTHROPIC_FEDERATION_FIELDS.filter((field) => field.required && ids[field.key] === "").map((field) => field.label); + +/** + * The PATCH that brings a saved credential in line with the ids entered on the Register issuer + * step, or null when every id already matches what was saved. Ids the operator cleared since the + * save are deleted rather than merged over, the same rule the Authentication step applies. + */ +export const federationIdsUpdate = ( + saved: Readonly>, + ids: AnthropicFederationIds, +): FederationIdsUpdate | null => { + const changed = ANTHROPIC_FEDERATION_FIELDS.map((field) => field.key).filter( + (key) => ids[key] !== trimmed(saved[key]), + ); + if (changed.length === 0) { + return null; + } + return { + credential_values: Object.fromEntries(changed.filter((key) => ids[key] !== "").map((key) => [key, ids[key]])), + credential_values_to_delete: changed.filter((key) => ids[key] === ""), + }; +}; + +export const withFederationIds = ( + saved: Readonly>, + ids: AnthropicFederationIds, +): Readonly> => + Object.fromEntries([ + ...Object.entries(saved).filter(([key]) => !(key in ids)), + ...Object.entries(ids).filter(([, value]) => value !== ""), + ]);