diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index deab3dc98..7c7dcd0fe 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -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 diff --git a/packages/ai/src/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index e6fa23dca..342b0c94c 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -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 = { @@ -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. diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index ce0b5b9d6..a3d73c8a5 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -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), diff --git a/packages/ai/test/anthropic-auth-token.test.ts b/packages/ai/test/anthropic-auth-token.test.ts new file mode 100644 index 000000000..47c41e53a --- /dev/null +++ b/packages/ai/test/anthropic-auth-token.test.ts @@ -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 | 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 { + constructor(opts: Record) { + mockState.constructorOpts = opts; + } + messages = { + create: (params: Record) => { + 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; + 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; + 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; + 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; + expect(headers.Authorization).toBe("Bearer explicit-token"); + }); +}); diff --git a/packages/ai/test/env-api-keys.test.ts b/packages/ai/test/env-api-keys.test.ts index b7c864329..76b1f69c9 100644 --- a/packages/ai/test/env-api-keys.test.ts +++ b/packages/ai/test/env-api-keys.test.ts @@ -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"); + }); }); diff --git a/packages/ai/test/providers.test.ts b/packages/ai/test/providers.test.ts index e3a7a9387..c16b89cca 100644 --- a/packages/ai/test/providers.test.ts +++ b/packages/ai/test/providers.test.ts @@ -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"); }); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index aecab9071..0a3215890 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index c194579c5..1e9b36b4d 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -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 diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index ac69f6ff0..a688528ce 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -405,7 +405,7 @@ export class AgentSession { } private async _getRequiredRequestAuth(model: Model): Promise<{ - apiKey: string; + apiKey?: string; headers?: Record; env?: Record; }> { @@ -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 | undefined; let env: Record | 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)); } diff --git a/packages/coding-agent/test/suite/agent-session-compaction.test.ts b/packages/coding-agent/test/suite/agent-session-compaction.test.ts index 33649135e..db1f117dc 100644 --- a/packages/coding-agent/test/suite/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/suite/agent-session-compaction.test.ts @@ -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);