From a9424cd43d242f32d31a3638bb7fe1305c20f72a Mon Sep 17 00:00:00 2001 From: Phil Freo Date: Wed, 30 Sep 2026 14:58:37 -0400 Subject: [PATCH] feat(ai): Anthropic workload identity federation (#10242) Adds Anthropic workload identity federation from the SDK env vars (ANTHROPIC_FEDERATION_RULE_ID, ANTHROPIC_ORGANIZATION_ID, ANTHROPIC_IDENTITY_TOKEN_FILE, optional ANTHROPIC_SERVICE_ACCOUNT_ID and ANTHROPIC_WORKSPACE_ID). Keys and ANTHROPIC_AUTH_TOKEN keep precedence. The federation client is reused across requests so the SDK's token cache avoids a token exchange per request, and the SDK's own credential chain is disabled so pi's auth resolver stays the only source of credentials. closes #10177 --- packages/ai/src/api/anthropic-messages.ts | 107 ++++++++-- packages/ai/src/env-api-keys.ts | 5 + packages/ai/src/providers/anthropic.ts | 35 +++- .../ai/test/anthropic-federation-sdk.test.ts | 119 +++++++++++ packages/ai/test/anthropic-federation.test.ts | 192 ++++++++++++++++++ packages/coding-agent/docs/providers.md | 2 + 6 files changed, 444 insertions(+), 16 deletions(-) create mode 100644 packages/ai/test/anthropic-federation-sdk.test.ts create mode 100644 packages/ai/test/anthropic-federation.test.ts diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index b0a4c915a..4a66e34c2 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -1,4 +1,4 @@ -import Anthropic from "@anthropic-ai/sdk"; +import Anthropic, { type ClientOptions } from "@anthropic-ai/sdk"; import type { BetaStopReason, BetaThinkingDroppedInputTransformation, @@ -10,6 +10,13 @@ import type { BetaRawMessageStreamEvent as RawMessageStreamEvent, BetaRefusalStopDetails as RefusalStopDetails, } from "@anthropic-ai/sdk/resources/beta/messages/messages.js"; +import { + ANTHROPIC_FEDERATION_RULE_ID_ENV, + ANTHROPIC_IDENTITY_TOKEN_FILE_ENV, + ANTHROPIC_ORGANIZATION_ID_ENV, + ANTHROPIC_SERVICE_ACCOUNT_ID_ENV, + ANTHROPIC_WORKSPACE_ID_ENV, +} from "../env-api-keys.ts"; import { calculateCost } from "../models.ts"; import type { Api, @@ -308,18 +315,69 @@ function hasHeader(headers: ProviderHeaders | undefined, name: string): boolean return false; } -function assertRequestAuth(provider: string, apiKey: string | undefined, headers: ProviderHeaders | undefined): void { - if (apiKey) return; - if ( +function hasRequestAuth(apiKey: string | undefined, headers: ProviderHeaders | undefined): boolean { + return ( + !!apiKey || hasHeader(headers, "authorization") || hasHeader(headers, "x-api-key") || hasHeader(headers, "cf-aig-authorization") - ) { - return; - } - throw new Error(`No API key for provider: ${provider}`); + ); } +function assertRequestAuth(provider: string, apiKey: string | undefined, headers: ProviderHeaders | undefined): void { + if (!hasRequestAuth(apiKey, headers)) throw new Error(`No API key for provider: ${provider}`); +} + +/** + * Anthropic SDK client that never runs the SDK's own credential chain + * (ANTHROPIC_PROFILE config files, federation env vars). Without this, every + * client built with `apiKey: null, authToken: null` for header-owned auth would + * also resolve and exchange SDK credentials behind pi's auth resolver. + */ +class PiAnthropic extends Anthropic { + protected override _shouldResolveDefaultCredentials(): boolean { + return false; + } +} + +type AnthropicFederationConfig = NonNullable; + +/** + * Workload identity federation config from the ANTHROPIC_* variables the + * Anthropic SDK documents; the SDK performs the token exchange and refresh. + * Only for the anthropic provider, since the exchange is an Anthropic API + * endpoint, and only when no key or auth header was resolved. + */ +function getAnthropicFederation( + model: Model<"anthropic-messages">, + apiKey: string | undefined, + headers: ProviderHeaders | undefined, + env: ProviderEnv | undefined, +): AnthropicFederationConfig | undefined { + if (model.provider !== "anthropic" || hasRequestAuth(apiKey, headers)) return undefined; + const federationRuleId = getProviderEnvValue(ANTHROPIC_FEDERATION_RULE_ID_ENV, env); + const organizationId = getProviderEnvValue(ANTHROPIC_ORGANIZATION_ID_ENV, env); + const identityTokenFile = getProviderEnvValue(ANTHROPIC_IDENTITY_TOKEN_FILE_ENV, env); + if (!federationRuleId || !organizationId || !identityTokenFile) return undefined; + return { + organization_id: organizationId, + workspace_id: getProviderEnvValue(ANTHROPIC_WORKSPACE_ID_ENV, env), + authentication: { + type: "oidc_federation", + federation_rule_id: federationRuleId, + service_account_id: getProviderEnvValue(ANTHROPIC_SERVICE_ACCOUNT_ID_ENV, env), + identity_token: { source: "file", path: identityTokenFile }, + }, + }; +} + +/** + * The SDK caches the federated access token per client, but pi creates a client + * per request. Keep one client for the current federation config and fetch, and + * clone it per request with `withOptions()`, which shares the token cache. + */ +let federationClient: { key: string; fetch: typeof globalThis.fetch | undefined; client: Anthropic } | undefined; + interface ServerSentEvent { event: string | null; data: string; @@ -553,7 +611,8 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( isOAuth = false; } else { const apiKey = options?.apiKey; - assertRequestAuth(model.provider, apiKey, options?.headers); + const federation = getAnthropicFederation(model, apiKey, options?.headers, options?.env); + if (!federation) assertRequestAuth(model.provider, apiKey, options?.headers); let copilotDynamicHeaders: Record | undefined; if (model.provider === "github-copilot") { @@ -574,6 +633,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( options?.fetch, copilotDynamicHeaders, cacheSessionId, + federation, ); client = created.client; isOAuth = created.isOAuthToken; @@ -872,7 +932,9 @@ export const streamSimple: StreamFunction<"anthropic-messages", SimpleStreamOpti context: TranscriptContext, options?: SimpleStreamOptions, ): AssistantMessageEventStream => { - assertRequestAuth(model.provider, options?.apiKey, options?.headers); + if (!getAnthropicFederation(model, options?.apiKey, options?.headers, options?.env)) { + assertRequestAuth(model.provider, options?.apiKey, options?.headers); + } const base = { ...buildBaseOptions(model, context, options, options?.apiKey), @@ -926,10 +988,11 @@ function createClient( fetch?: typeof globalThis.fetch, dynamicHeaders?: Record, sessionId?: string, + federation?: AnthropicFederationConfig, ): { client: Anthropic; isOAuthToken: boolean } { // Copilot: Bearer auth. if (model.provider === "github-copilot") { - const client = new Anthropic({ + const client = new PiAnthropic({ apiKey: null, authToken: apiKey ?? null, baseURL: model.baseUrl, @@ -951,7 +1014,7 @@ function createClient( // OAuth: Bearer auth, Claude Code identity headers if (apiKey && isOAuthToken(apiKey)) { - const client = new Anthropic({ + const client = new PiAnthropic({ apiKey: null, authToken: apiKey, baseURL: model.baseUrl, @@ -972,7 +1035,7 @@ function createClient( return { client, isOAuthToken: true }; } - // API key or header-owned auth. + // API key, header-owned auth, or workload identity federation. const compat = getAnthropicCompat(model); const sessionAffinityHeaders: ProviderHeaders = {}; if (sessionId && compat.sendSessionAffinityHeaders) { @@ -988,7 +1051,23 @@ function createClient( model.headers, optionsHeaders, ); - const client = new Anthropic({ + if (federation) { + const key = JSON.stringify([model.baseUrl, federation]); + if (federationClient?.key !== key || federationClient.fetch !== fetch) { + const client = new PiAnthropic({ + apiKey: null, + authToken: null, + config: federation, + baseURL: model.baseUrl, + dangerouslyAllowBrowser: true, + fetch, + }); + federationClient = { key, fetch, client }; + } + return { client: federationClient.client.withOptions({ defaultHeaders }), isOAuthToken: false }; + } + + const client = new PiAnthropic({ apiKey: apiKey ?? null, authToken: null, baseURL: model.baseUrl, diff --git a/packages/ai/src/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index 4dae7b202..467ed0772 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -29,6 +29,11 @@ import { getProviderEnvValue } from "./utils/provider-env.ts"; export const ANTHROPIC_AUTH_TOKEN_ENV = "ANTHROPIC_AUTH_TOKEN"; export const ANTHROPIC_OAUTH_TOKEN_ENV = "ANTHROPIC_OAUTH_TOKEN"; export const ANTHROPIC_API_KEY_ENV = "ANTHROPIC_API_KEY"; +export const ANTHROPIC_FEDERATION_RULE_ID_ENV = "ANTHROPIC_FEDERATION_RULE_ID"; +export const ANTHROPIC_ORGANIZATION_ID_ENV = "ANTHROPIC_ORGANIZATION_ID"; +export const ANTHROPIC_SERVICE_ACCOUNT_ID_ENV = "ANTHROPIC_SERVICE_ACCOUNT_ID"; +export const ANTHROPIC_IDENTITY_TOKEN_FILE_ENV = "ANTHROPIC_IDENTITY_TOKEN_FILE"; +export const ANTHROPIC_WORKSPACE_ID_ENV = "ANTHROPIC_WORKSPACE_ID"; let cachedVertexAdcCredentialsExists: boolean | null = null; diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 675735396..0f7e8b6e9 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -2,8 +2,18 @@ import { anthropicMessagesApi } from "../api/anthropic-messages.lazy.ts"; import { lazyOAuth } from "../auth/helpers.ts"; import { loadAnthropicOAuth } from "../auth/oauth/load.ts"; import type { ApiKeyAuth } from "../auth/types.ts"; -import { ANTHROPIC_API_KEY_ENV, ANTHROPIC_AUTH_TOKEN_ENV, ANTHROPIC_OAUTH_TOKEN_ENV } from "../env-api-keys.ts"; +import { + ANTHROPIC_API_KEY_ENV, + ANTHROPIC_AUTH_TOKEN_ENV, + ANTHROPIC_FEDERATION_RULE_ID_ENV, + ANTHROPIC_IDENTITY_TOKEN_FILE_ENV, + ANTHROPIC_OAUTH_TOKEN_ENV, + ANTHROPIC_ORGANIZATION_ID_ENV, + ANTHROPIC_SERVICE_ACCOUNT_ID_ENV, + ANTHROPIC_WORKSPACE_ID_ENV, +} from "../env-api-keys.ts"; import { createProvider, type Provider } from "../models.ts"; +import type { ProviderEnv } from "../types.ts"; import { ANTHROPIC_MODELS } from "./anthropic.models.ts"; function anthropicApiKeyAuth(): ApiKeyAuth { @@ -35,7 +45,28 @@ function anthropicApiKeyAuth(): ApiKeyAuth { signal.throwIfAborted(); if (apiKey) return { auth: { apiKey }, source: envVar }; } - return undefined; + + // Workload identity federation: the Anthropic SDK exchanges the identity + // token for a short-lived access token and refreshes it itself. Last in + // line so keys and ANTHROPIC_AUTH_TOKEN keep winning, as in the SDK. The + // ids are provider config rather than auth, so they travel in `env`. + const federation: ProviderEnv = {}; + for (const envVar of [ + ANTHROPIC_FEDERATION_RULE_ID_ENV, + ANTHROPIC_ORGANIZATION_ID_ENV, + ANTHROPIC_IDENTITY_TOKEN_FILE_ENV, + ]) { + const value = await ctx.env(envVar); + signal.throwIfAborted(); + if (!value) return undefined; + federation[envVar] = value; + } + for (const envVar of [ANTHROPIC_SERVICE_ACCOUNT_ID_ENV, ANTHROPIC_WORKSPACE_ID_ENV]) { + const value = await ctx.env(envVar); + signal.throwIfAborted(); + if (value) federation[envVar] = value; + } + return { auth: {}, env: federation, source: "workload identity federation" }; }, }; } diff --git a/packages/ai/test/anthropic-federation-sdk.test.ts b/packages/ai/test/anthropic-federation-sdk.test.ts new file mode 100644 index 000000000..b0abd65bd --- /dev/null +++ b/packages/ai/test/anthropic-federation-sdk.test.ts @@ -0,0 +1,119 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts"; +import type { Model } from "../src/types.ts"; +import { normalizeContext } from "../src/utils/transcript.ts"; + +// Runs the real Anthropic SDK against a fake fetch to check how often the +// workload identity federation token exchange happens. +// https://github.com/earendil-works/pi/issues/10177 + +function sseResponse(): Response { + const body = [ + `event: message_start\ndata: ${JSON.stringify({ + type: "message_start", + message: { id: "msg_test", usage: { input_tokens: 1, output_tokens: 0 } }, + })}\n`, + `event: message_delta\ndata: ${JSON.stringify({ + type: "message_delta", + delta: { stop_reason: "end_turn" }, + usage: { output_tokens: 1 }, + })}\n`, + `event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n`, + ].join("\n"); + return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } }); +} + +interface RecordedRequest { + path: string; + authorization: string | null; +} + +function createFetch(requests: RecordedRequest[]): typeof globalThis.fetch { + return async (input, init) => { + const request = new Request(input, init); + const path = new URL(request.url).pathname; + requests.push({ path, authorization: request.headers.get("authorization") }); + if (path === "/v1/oauth/token") { + return new Response(JSON.stringify({ access_token: "federated-token", expires_in: 3600 }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + return sseResponse(); + }; +} + +const context = normalizeContext({ + systemPrompt: "System prompt.", + messages: [{ role: "user", content: "Hello", timestamp: Date.now() }], +}); + +const anthropicModel: Model<"anthropic-messages"> = { + id: "claude-test", + name: "Claude Test", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 100000, + maxTokens: 4096, +}; + +let tempDir: string; +let federationEnv: Record; + +beforeAll(() => { + tempDir = mkdtempSync(join(tmpdir(), "pi-anthropic-federation-")); + const identityTokenFile = join(tempDir, "identity.jwt"); + writeFileSync(identityTokenFile, "header.payload.signature"); + federationEnv = { + ANTHROPIC_FEDERATION_RULE_ID: "fdrl_test", + ANTHROPIC_ORGANIZATION_ID: "org-test", + ANTHROPIC_IDENTITY_TOKEN_FILE: identityTokenFile, + }; +}); + +afterAll(() => { + rmSync(tempDir, { recursive: true, force: true }); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("Anthropic workload identity federation with the SDK", () => { + it("exchanges the identity token once across requests", async () => { + const requests: RecordedRequest[] = []; + const fetch = createFetch(requests); + + for (let i = 0; i < 3; i++) { + const message = await streamAnthropic(anthropicModel, context, { env: federationEnv, fetch }).result(); + expect(message.stopReason).toBe("stop"); + } + + expect(requests.filter((request) => request.path === "/v1/oauth/token")).toHaveLength(1); + const messageRequests = requests.filter((request) => request.path === "/v1/messages"); + expect(messageRequests).toHaveLength(3); + for (const request of messageRequests) { + expect(request.authorization).toBe("Bearer federated-token"); + } + }); + + it("does not run the SDK credential chain for header-owned auth", async () => { + for (const [name, value] of Object.entries(federationEnv)) vi.stubEnv(name, value); + const requests: RecordedRequest[] = []; + + const message = await streamAnthropic(anthropicModel, context, { + headers: { Authorization: "Bearer auth-token" }, + fetch: createFetch(requests), + }).result(); + + expect(message.stopReason).toBe("stop"); + expect(requests).toEqual([{ path: "/v1/messages", authorization: "Bearer auth-token" }]); + }); +}); diff --git a/packages/ai/test/anthropic-federation.test.ts b/packages/ai/test/anthropic-federation.test.ts new file mode 100644 index 000000000..d404fa0ed --- /dev/null +++ b/packages/ai/test/anthropic-federation.test.ts @@ -0,0 +1,192 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts"; +import { createModels } from "../src/models.ts"; +import { anthropicProvider } from "../src/providers/anthropic.ts"; +import type { Model } from "../src/types.ts"; +import { normalizeContext } from "../src/utils/transcript.ts"; + +const mockState = vi.hoisted(() => ({ + constructorOpts: undefined as Record | undefined, + createParams: undefined as Record | undefined, +})); + +vi.mock("@anthropic-ai/sdk", () => { + function createSseResponse(): Response { + const body = [ + `event: message_start\ndata: ${JSON.stringify({ + type: "message_start", + message: { id: "msg_test", usage: { input_tokens: 1, output_tokens: 0 } }, + })}\n`, + `event: message_delta\ndata: ${JSON.stringify({ + type: "message_delta", + delta: { stop_reason: "end_turn" }, + usage: { output_tokens: 1 }, + })}\n`, + `event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n`, + ].join("\n"); + return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } }); + } + + class FakeAnthropic { + opts: Record; + constructor(opts: Record) { + this.opts = opts; + mockState.constructorOpts = opts; + } + withOptions(options: Record) { + return new FakeAnthropic({ ...this.opts, ...options }); + } + beta = { + messages: { + create: (params: Record) => { + mockState.createParams = params; + return { asResponse: async () => createSseResponse() }; + }, + }, + }; + } + + return { default: FakeAnthropic }; +}); + +const neverAbortedSignal = new AbortController().signal; + +const federationEnv = { + ANTHROPIC_FEDERATION_RULE_ID: "fdrl_test", + ANTHROPIC_ORGANIZATION_ID: "org-test", + ANTHROPIC_SERVICE_ACCOUNT_ID: "svac_test", + ANTHROPIC_IDENTITY_TOKEN_FILE: "/tmp/identity.jwt", +}; + +const expectedConfig = { + organization_id: "org-test", + workspace_id: undefined, + authentication: { + type: "oidc_federation", + federation_rule_id: "fdrl_test", + service_account_id: "svac_test", + identity_token: { source: "file", path: "/tmp/identity.jwt" }, + }, +}; + +const context = normalizeContext({ + systemPrompt: "System prompt.", + messages: [{ role: "user", content: "Hello", timestamp: Date.now() }], +}); + +const anthropicModel: Model<"anthropic-messages"> = { + id: "claude-test", + name: "Claude Test", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 100000, + maxTokens: 4096, +}; + +function resolveWithEnv(env: Record) { + return anthropicProvider().auth.apiKey?.resolve({ + ctx: { env: async (name) => env[name], fileExists: async () => false }, + signal: neverAbortedSignal, + }); +} + +afterEach(() => { + mockState.constructorOpts = undefined; + mockState.createParams = undefined; +}); + +// https://github.com/earendil-works/pi/issues/10177 +describe("Anthropic workload identity federation", () => { + it("resolves the federation variables as provider env with no request auth", async () => { + expect(await resolveWithEnv(federationEnv)).toEqual({ + auth: {}, + env: federationEnv, + source: "workload identity federation", + }); + }); + + it("passes ANTHROPIC_WORKSPACE_ID through when set", async () => { + const result = await resolveWithEnv({ ...federationEnv, ANTHROPIC_WORKSPACE_ID: "wrkspc_test" }); + expect(result?.env?.ANTHROPIC_WORKSPACE_ID).toBe("wrkspc_test"); + }); + + it("is not configured when a federation variable is missing", async () => { + const { ANTHROPIC_IDENTITY_TOKEN_FILE: _omitted, ...partial } = federationEnv; + expect(await resolveWithEnv(partial)).toBeUndefined(); + }); + + it("treats ANTHROPIC_SERVICE_ACCOUNT_ID as optional, like the SDK", async () => { + const { ANTHROPIC_SERVICE_ACCOUNT_ID: _omitted, ...partial } = federationEnv; + expect(await resolveWithEnv(partial)).toEqual({ + auth: {}, + env: partial, + source: "workload identity federation", + }); + + await streamAnthropic(anthropicModel, context, { env: partial }).result(); + expect(mockState.constructorOpts?.config).toEqual({ + ...expectedConfig, + authentication: { ...expectedConfig.authentication, service_account_id: undefined }, + }); + }); + + it("keeps API key and auth token precedence over federation", async () => { + expect(await resolveWithEnv({ ...federationEnv, ANTHROPIC_API_KEY: "api-key" })).toEqual({ + auth: { apiKey: "api-key" }, + source: "ANTHROPIC_API_KEY", + }); + expect(await resolveWithEnv({ ...federationEnv, ANTHROPIC_AUTH_TOKEN: "auth-token" })).toEqual({ + auth: { headers: { Authorization: "Bearer auth-token" } }, + source: "ANTHROPIC_AUTH_TOKEN", + }); + }); + + it("hands the SDK a federation config instead of a key", async () => { + await streamAnthropic(anthropicModel, context, { env: federationEnv }).result(); + + expect(mockState.constructorOpts?.apiKey).toBeNull(); + expect(mockState.constructorOpts?.authToken).toBeNull(); + expect(mockState.constructorOpts?.config).toEqual(expectedConfig); + const headers = mockState.constructorOpts?.defaultHeaders as Record; + expect(headers.Authorization).toBeUndefined(); + expect(mockState.createParams?.betas ?? []).not.toContain("oauth-2025-04-20"); + }); + + it("threads authContext federation variables through Models", async () => { + const models = createModels({ + authContext: { + env: async (name) => federationEnv[name as keyof typeof federationEnv], + fileExists: async () => false, + }, + }); + models.setProvider(anthropicProvider()); + + await models.streamSimple(anthropicModel, context).result(); + + expect(mockState.constructorOpts?.apiKey).toBeNull(); + expect(mockState.constructorOpts?.config).toEqual(expectedConfig); + }); + + it("lets an explicit API key win over federation env", async () => { + await streamAnthropic(anthropicModel, context, { apiKey: "explicit-key", env: federationEnv }).result(); + + expect(mockState.constructorOpts?.apiKey).toBe("explicit-key"); + expect(mockState.constructorOpts?.config).toBeUndefined(); + }); + + it("does not federate other anthropic-messages providers", async () => { + const kimi: Model<"anthropic-messages"> = { + ...anthropicModel, + provider: "kimi-coding", + baseUrl: "https://api.kimi.com/coding", + }; + const message = await streamAnthropic(kimi, context, { env: federationEnv }).result(); + + expect(message.stopReason).toBe("error"); + expect(mockState.constructorOpts).toBeUndefined(); + }); +}); diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index 898a1c9f4..022656d3d 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -68,6 +68,8 @@ This table covers providers with a single primary API-key variable. Providers th Anthropic also recognizes `ANTHROPIC_OAUTH_TOKEN` as an API credential and `ANTHROPIC_AUTH_TOKEN` as bearer authentication. +With no key or token set, Anthropic uses workload identity federation when `ANTHROPIC_FEDERATION_RULE_ID`, `ANTHROPIC_ORGANIZATION_ID` and `ANTHROPIC_IDENTITY_TOKEN_FILE` are set: the Anthropic SDK exchanges the identity token for a short-lived access token and refreshes it itself (re-reading the identity token file, so keep that file fresh for long sessions). `ANTHROPIC_SERVICE_ACCOUNT_ID` and `ANTHROPIC_WORKSPACE_ID` are passed through when set. + ## Load an API key from a command To use a secret manager without writing the resolved key to disk, set a provider's `key` in `auth.json` to a command prefixed with `!`: