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
This commit is contained in:
Phil Freo 2026-09-30 14:58:37 -04:00 • committed by GitHub
parent 91f9f3b5df
commit a9424cd43d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 444 additions and 16 deletions

View file

@ -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<ClientOptions["config"]>;
/**
* 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<string, string> | 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<string, string>,
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,

View file

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

View file

@ -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" };
},
};
}

View file

@ -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<string, string>;
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" }]);
});
});

View file

@ -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<string, unknown> | undefined,
createParams: undefined as Record<string, unknown> | 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<string, unknown>;
constructor(opts: Record<string, unknown>) {
this.opts = opts;
mockState.constructorOpts = opts;
}
withOptions(options: Record<string, unknown>) {
return new FakeAnthropic({ ...this.opts, ...options });
}
beta = {
messages: {
create: (params: Record<string, unknown>) => {
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<string, string>) {
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<string, string | null>;
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();
});
});

View file

@ -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 `!`: