fix(ai): support Anthropic bearer token env (#6148)

Fixes #5871
This commit is contained in:
Armin Ronacher 2026-07-24 15:13:22 +02:00 • committed by GitHub
parent 7df73a00c6
commit 24e5cc04f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 346 additions and 16 deletions

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added `ANTHROPIC_AUTH_TOKEN` bearer authentication for Anthropic-compatible gateways ([#5871](https://github.com/earendil-works/pi/issues/5871))
## [0.82.0] - 2026-07-24
### Breaking Changes

View file

@ -26,6 +26,10 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version
import type { KnownProvider, ProviderEnv } from "./types.ts";
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";
let cachedVertexAdcCredentialsExists: boolean | null = null;
function hasVertexAdcCredentials(env?: ProviderEnv): boolean {
@ -66,9 +70,10 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined {
return ["COPILOT_GITHUB_TOKEN"];
}
// ANTHROPIC_OAUTH_TOKEN takes precedence over ANTHROPIC_API_KEY
// ANTHROPIC_AUTH_TOKEN participates in env discovery/status, but
// getEnvApiKey() skips it because requests must pass it as Authorization: Bearer.
if (provider === "anthropic") {
return ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"];
return [ANTHROPIC_AUTH_TOKEN_ENV, ANTHROPIC_OAUTH_TOKEN_ENV, ANTHROPIC_API_KEY_ENV];
}
const envMap: Record<string, string> = {
@ -139,7 +144,8 @@ export function getEnvApiKey(provider: string, env?: ProviderEnv): string | unde
export function getEnvApiKey(provider: string, env?: ProviderEnv): string | undefined {
const envKeys = findEnvKeys(provider, env);
if (envKeys?.[0]) {
return getProviderEnvValue(envKeys[0], env);
const apiKeyEnv = provider === "anthropic" ? envKeys.find((key) => key !== ANTHROPIC_AUTH_TOKEN_ENV) : envKeys[0];
if (apiKeyEnv) return getProviderEnvValue(apiKeyEnv, env);
}
// Vertex AI supports either an explicit API key or Application Default Credentials.

View file

@ -1,17 +1,47 @@
import { anthropicMessagesApi } from "../api/anthropic-messages.lazy.ts";
import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.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 { createProvider, type Provider } from "../models.ts";
import { ANTHROPIC_MODELS } from "./anthropic.models.ts";
function anthropicApiKeyAuth(): ApiKeyAuth {
return {
name: "Anthropic API key",
login: async (interaction) => ({
type: "api_key",
key: await interaction.prompt({ type: "secret", message: "Enter Anthropic API key" }),
}),
resolve: async ({ ctx, credential }) => {
if (credential?.key) {
return { auth: { apiKey: credential.key }, env: credential.env, source: "stored credential" };
}
const authToken = await ctx.env(ANTHROPIC_AUTH_TOKEN_ENV);
if (authToken) {
return {
auth: { headers: { Authorization: `Bearer ${authToken}` } },
source: ANTHROPIC_AUTH_TOKEN_ENV,
};
}
for (const envVar of [ANTHROPIC_OAUTH_TOKEN_ENV, ANTHROPIC_API_KEY_ENV]) {
const apiKey = await ctx.env(envVar);
if (apiKey) return { auth: { apiKey }, source: envVar };
}
return undefined;
},
};
}
export function anthropicProvider(): Provider<"anthropic-messages"> {
return createProvider({
id: "anthropic",
name: "Anthropic",
baseUrl: "https://api.anthropic.com",
auth: {
// ANTHROPIC_OAUTH_TOKEN takes precedence over ANTHROPIC_API_KEY
apiKey: envApiKeyAuth("Anthropic API key", ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"]),
apiKey: anthropicApiKeyAuth(),
oauth: lazyOAuth({ name: "Anthropic (Claude Pro/Max)", load: loadAnthropicOAuth }),
},
models: Object.values(ANTHROPIC_MODELS),

View file

@ -0,0 +1,183 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts";
import { ANTHROPIC_AUTH_TOKEN_ENV, ANTHROPIC_OAUTH_TOKEN_ENV } from "../src/env-api-keys.ts";
import { createModels } from "../src/models.ts";
import { anthropicProvider } from "../src/providers/anthropic.ts";
import type { Context, Model } from "../src/types.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 {
constructor(opts: Record<string, unknown>) {
mockState.constructorOpts = opts;
}
messages = {
create: (params: Record<string, unknown>) => {
mockState.createParams = params;
return {
asResponse: async () => createSseResponse(),
};
},
};
}
return { default: FakeAnthropic };
});
const context: Context = {
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,
};
afterEach(() => {
mockState.constructorOpts = undefined;
mockState.createParams = undefined;
});
describe("Anthropic auth token env", () => {
it("resolves ANTHROPIC_AUTH_TOKEN as a bearer Authorization header", async () => {
const provider = anthropicProvider();
const auth = await provider.auth.apiKey?.resolve({
ctx: {
env: async (name) =>
({
ANTHROPIC_AUTH_TOKEN: "auth-token",
ANTHROPIC_OAUTH_TOKEN: "oauth-token",
ANTHROPIC_API_KEY: "api-key",
})[name],
fileExists: async () => false,
},
});
expect(auth).toEqual({
auth: { headers: { Authorization: "Bearer auth-token" } },
source: ANTHROPIC_AUTH_TOKEN_ENV,
});
});
it("preserves ANTHROPIC_OAUTH_TOKEN as OAuth-shaped API auth", async () => {
const provider = anthropicProvider();
const auth = await provider.auth.apiKey?.resolve({
ctx: {
env: async (name) =>
({
ANTHROPIC_OAUTH_TOKEN: "oauth-token",
ANTHROPIC_API_KEY: "api-key",
})[name],
fileExists: async () => false,
},
});
expect(auth).toEqual({
auth: { apiKey: "oauth-token" },
source: ANTHROPIC_OAUTH_TOKEN_ENV,
});
});
it("uses Authorization headers without OAuth-mode request shaping", async () => {
const stream = streamAnthropic(anthropicModel, context, {
headers: { Authorization: "Bearer gateway-token" },
});
await stream.result();
expect(mockState.constructorOpts?.apiKey).toBeNull();
expect(mockState.constructorOpts?.authToken).toBeNull();
const headers = mockState.constructorOpts?.defaultHeaders as Record<string, string | null>;
expect(headers.Authorization).toBe("Bearer gateway-token");
expect(headers["anthropic-beta"] ?? "").not.toContain("oauth-2025-04-20");
expect(mockState.createParams?.system).toEqual([expect.objectContaining({ text: "System prompt." })]);
});
it("threads authContext ANTHROPIC_AUTH_TOKEN through request headers", async () => {
const models = createModels({
authContext: {
env: async (name) => (name === "ANTHROPIC_AUTH_TOKEN" ? "ctx-token" : undefined),
fileExists: async () => false,
},
});
models.setProvider(anthropicProvider());
await models.streamSimple(anthropicModel, context).result();
expect(mockState.constructorOpts?.apiKey).toBeNull();
expect(mockState.constructorOpts?.authToken).toBeNull();
const headers = mockState.constructorOpts?.defaultHeaders as Record<string, string>;
expect(headers.Authorization).toBe("Bearer ctx-token");
expect(headers["anthropic-beta"] ?? "").not.toContain("oauth-2025-04-20");
expect(mockState.createParams?.system).toEqual([expect.objectContaining({ text: "System prompt." })]);
});
it("preserves OAuth request shaping for ANTHROPIC_OAUTH_TOKEN", async () => {
const models = createModels({
authContext: {
env: async (name) => (name === "ANTHROPIC_OAUTH_TOKEN" ? "sk-ant-oat-test" : undefined),
fileExists: async () => false,
},
});
models.setProvider(anthropicProvider());
await models.streamSimple(anthropicModel, context).result();
expect(mockState.constructorOpts?.apiKey).toBeNull();
expect(mockState.constructorOpts?.authToken).toBe("sk-ant-oat-test");
const headers = mockState.constructorOpts?.defaultHeaders as Record<string, string>;
expect(headers["anthropic-beta"]).toContain("oauth-2025-04-20");
});
it("lets explicit request headers override ANTHROPIC_AUTH_TOKEN", async () => {
const models = createModels({
authContext: {
env: async (name) => (name === "ANTHROPIC_AUTH_TOKEN" ? "ctx-token" : undefined),
fileExists: async () => false,
},
});
models.setProvider(anthropicProvider());
await models
.streamSimple(anthropicModel, context, { headers: { Authorization: "Bearer explicit-token" } })
.result();
const headers = mockState.constructorOpts?.defaultHeaders as Record<string, string>;
expect(headers.Authorization).toBe("Bearer explicit-token");
});
});

View file

@ -5,6 +5,9 @@ const originalCopilotGitHubToken = process.env.COPILOT_GITHUB_TOKEN;
const originalGhToken = process.env.GH_TOKEN;
const originalGitHubToken = process.env.GITHUB_TOKEN;
const originalZaiCodingCnApiKey = process.env.ZAI_CODING_CN_API_KEY;
const originalAnthropicAuthToken = process.env.ANTHROPIC_AUTH_TOKEN;
const originalAnthropicOauthToken = process.env.ANTHROPIC_OAUTH_TOKEN;
const originalAnthropicApiKey = process.env.ANTHROPIC_API_KEY;
afterEach(() => {
if (originalCopilotGitHubToken === undefined) {
@ -30,6 +33,24 @@ afterEach(() => {
} else {
process.env.ZAI_CODING_CN_API_KEY = originalZaiCodingCnApiKey;
}
if (originalAnthropicAuthToken === undefined) {
delete process.env.ANTHROPIC_AUTH_TOKEN;
} else {
process.env.ANTHROPIC_AUTH_TOKEN = originalAnthropicAuthToken;
}
if (originalAnthropicOauthToken === undefined) {
delete process.env.ANTHROPIC_OAUTH_TOKEN;
} else {
process.env.ANTHROPIC_OAUTH_TOKEN = originalAnthropicOauthToken;
}
if (originalAnthropicApiKey === undefined) {
delete process.env.ANTHROPIC_API_KEY;
} else {
process.env.ANTHROPIC_API_KEY = originalAnthropicApiKey;
}
});
describe("environment API keys", () => {
@ -57,4 +78,39 @@ describe("environment API keys", () => {
expect(findEnvKeys("zai-coding-cn")).toEqual(["ZAI_CODING_CN_API_KEY"]);
expect(getEnvApiKey("zai-coding-cn")).toBe("zai-coding-cn-token");
});
it("reports ANTHROPIC_AUTH_TOKEN but preserves OAuth token API key lookup", () => {
process.env.ANTHROPIC_AUTH_TOKEN = "auth-token";
process.env.ANTHROPIC_OAUTH_TOKEN = "oauth-token";
process.env.ANTHROPIC_API_KEY = "api-key";
expect(findEnvKeys("anthropic")).toEqual(["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"]);
expect(getEnvApiKey("anthropic")).toBe("oauth-token");
});
it("does not return ANTHROPIC_AUTH_TOKEN as an API key", () => {
process.env.ANTHROPIC_AUTH_TOKEN = "auth-token";
delete process.env.ANTHROPIC_OAUTH_TOKEN;
delete process.env.ANTHROPIC_API_KEY;
expect(findEnvKeys("anthropic")).toEqual(["ANTHROPIC_AUTH_TOKEN"]);
expect(getEnvApiKey("anthropic")).toBeUndefined();
});
it("preserves ANTHROPIC_OAUTH_TOKEN as an API key", () => {
delete process.env.ANTHROPIC_AUTH_TOKEN;
process.env.ANTHROPIC_OAUTH_TOKEN = "oauth-token";
delete process.env.ANTHROPIC_API_KEY;
expect(findEnvKeys("anthropic")).toEqual(["ANTHROPIC_OAUTH_TOKEN"]);
expect(getEnvApiKey("anthropic")).toBe("oauth-token");
});
it("falls back to ANTHROPIC_API_KEY for API key lookup", () => {
delete process.env.ANTHROPIC_AUTH_TOKEN;
delete process.env.ANTHROPIC_OAUTH_TOKEN;
process.env.ANTHROPIC_API_KEY = "api-key";
expect(getEnvApiKey("anthropic")).toBe("api-key");
});
});

View file

@ -79,14 +79,29 @@ describe("builtin providers", () => {
}
});
it("resolves anthropic auth from env with OAuth token precedence", async () => {
it("resolves Anthropic bearer auth from env with auth token precedence", async () => {
const models = createModels({
authContext: fakeAuthContext({
ANTHROPIC_AUTH_TOKEN: "auth-token",
ANTHROPIC_OAUTH_TOKEN: "oauth-token",
ANTHROPIC_API_KEY: "api-key",
}),
});
models.setProvider(anthropicProvider());
expect(await models.getAuth("anthropic")).toEqual({
auth: { headers: { Authorization: "Bearer auth-token" } },
source: "ANTHROPIC_AUTH_TOKEN",
});
});
it("preserves Anthropic OAuth token precedence over the API key", async () => {
const models = createModels({
authContext: fakeAuthContext({ ANTHROPIC_API_KEY: "key", ANTHROPIC_OAUTH_TOKEN: "oauth-token" }),
});
models.setProvider(anthropicProvider());
const model = models.getModel("anthropic", "claude-haiku-4-5")!;
const result = await models.getAuth(model.provider);
const result = await models.getAuth("anthropic");
expect(result?.auth.apiKey).toBe("oauth-token");
expect(result?.source).toBe("ANTHROPIC_OAUTH_TOKEN");
});

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed compaction and branch summaries for providers whose authentication resolves entirely to request headers ([#5871](https://github.com/earendil-works/pi/issues/5871))
## [0.82.0] - 2026-07-24
### New Features

View file

@ -333,6 +333,7 @@ ${chalk.bold("Examples:")}
${APP_NAME} --export session.jsonl output.html
${chalk.bold("Environment Variables:")}
ANTHROPIC_AUTH_TOKEN - Anthropic bearer auth token
ANTHROPIC_API_KEY - Anthropic Claude API key
ANTHROPIC_OAUTH_TOKEN - Anthropic OAuth token (alternative to API key)
ANT_LING_API_KEY - Ant Ling API key

View file

@ -405,7 +405,7 @@ export class AgentSession {
}
private async _getRequiredRequestAuth(model: Model<any>): Promise<{
apiKey: string;
apiKey?: string;
headers?: Record<string, string>;
env?: Record<string, string>;
}> {
@ -419,7 +419,7 @@ export class AgentSession {
}
throw error;
}
if (result?.auth.apiKey) {
if (result && (result.auth.apiKey || result.auth.headers)) {
return {
apiKey: result.auth.apiKey,
headers: withoutDeletedHeaders(result.auth.headers),
@ -2057,11 +2057,7 @@ export class AgentSession {
let headers: Record<string, string> | undefined;
let env: Record<string, string> | undefined;
if (this.agent.streamFunction === streamSimple) {
const authResult = await this._modelRuntime.getAuth(this.model);
if (!authResult?.auth.apiKey) return false;
apiKey = authResult.auth.apiKey;
headers = withoutDeletedHeaders(authResult.auth.headers);
env = authResult.env;
({ apiKey, headers, env } = await this._getRequiredRequestAuth(this.model));
} else {
({ apiKey, headers, env } = await this._getSummarizationRequestAuth(this.model));
}

View file

@ -175,6 +175,41 @@ describe("AgentSession compaction characterization", () => {
expect(getStreamCallCount()).toBe(1);
});
it("manually compacts with provider-resolved bearer auth", async () => {
const harness = await createHarness({ withConfiguredAuth: false });
harnesses.push(harness);
const model = harness.getModel();
harness.session.modelRuntime.registerNativeProvider({
id: model.provider,
name: "Faux bearer provider",
auth: {
apiKey: {
name: "Faux bearer token",
resolve: async () => ({
auth: { headers: { Authorization: "Bearer ambient-token" } },
source: "ambient bearer token",
}),
},
},
getModels: () => harness.models,
stream: () => createAssistantMessageEventStream(),
streamSimple: () => createAssistantMessageEventStream(),
});
seedCompactableSession(harness);
harness.setResponses([
(_context, options) => {
expect(options?.apiKey).toBeUndefined();
expect(options?.headers).toEqual({ Authorization: "Bearer ambient-token" });
return fauxAssistantMessage("summary with bearer auth");
},
]);
const result = await harness.session.compact();
expect(result.summary).toContain("summary with bearer auth");
expect(harness.faux.state.callCount).toBe(1);
});
it("persists usage from pi-generated manual compaction", async () => {
const harness = await createHarness({ withConfiguredAuth: false });
harnesses.push(harness);