mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
91f9f3b5df
commit
a9424cd43d
6 changed files with 444 additions and 16 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
119
packages/ai/test/anthropic-federation-sdk.test.ts
Normal file
119
packages/ai/test/anthropic-federation-sdk.test.ts
Normal 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" }]);
|
||||
});
|
||||
});
|
||||
192
packages/ai/test/anthropic-federation.test.ts
Normal file
192
packages/ai/test/anthropic-federation.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 `!`:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue