diff --git a/README.md b/README.md index d9d02067..4649ae16 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,21 @@ ANTHROPIC_API_KEY=your-key ANTHROPIC_BASE_URL=https://your-gateway.example.com/anthropic ``` +Some gateways authenticate with an `Authorization: Bearer ` header rather +than Anthropic's `x-api-key`. For those, set `ANTHROPIC_AUTH_TOKEN` instead of +`ANTHROPIC_API_KEY`. When `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` are +both set, `ANTHROPIC_API_KEY` is not required, and OpenWiki sends the token as a +bearer header without an `x-api-key`: + +```bash +OPENWIKI_PROVIDER=anthropic +ANTHROPIC_AUTH_TOKEN=your-gateway-token +ANTHROPIC_BASE_URL=https://your-gateway.example.com/anthropic +``` + +If you set both `ANTHROPIC_API_KEY` and `ANTHROPIC_AUTH_TOKEN`, OpenWiki sends +both the `x-api-key` and the bearer header. + The `openai` provider likewise supports an alternative, OpenAI-compatible endpoint (for example a self-hosted or proxied gateway) via `OPENAI_BASE_URL`, set alongside `OPENAI_API_KEY`. Baseten, Fireworks, and NVIDIA NIM can be routed diff --git a/package.json b/package.json index 447cbe11..12349001 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@anthropic-ai/sdk": "^0.103.0", "@anthropic-ai/vertex-sdk": "^0.19.0", "@aws-sdk/client-bedrock-runtime": "^3.1080.0", "@langchain/anthropic": "^1.5.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5911a757..efff8f1a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@anthropic-ai/sdk': + specifier: ^0.103.0 + version: 0.103.0(zod@4.4.3) '@anthropic-ai/vertex-sdk': specifier: ^0.19.0 version: 0.19.0(zod@4.4.3) diff --git a/src/agent/index.ts b/src/agent/index.ts index 168144e1..be7c8ae8 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { chmod, mkdir } from "node:fs/promises"; import path from "node:path"; +import { Anthropic } from "@anthropic-ai/sdk"; import { AnthropicVertex } from "@anthropic-ai/vertex-sdk"; import { ChatAnthropic } from "@langchain/anthropic"; import { ChatBedrockConverse } from "@langchain/aws"; @@ -86,6 +87,7 @@ import { providerRequiresSecretKey, resolveConfiguredProvider, resolveOpenRouterProviderOnly, + resolveProviderAuthToken, resolveProviderBaseUrl, resolveProviderLocation, resolveProviderRegion, @@ -675,10 +677,23 @@ export function createModel( if (provider === "anthropic") { const baseURL = resolveProviderBaseUrl(provider); + const apiKey = getProviderApiKey(provider); + const authToken = resolveProviderAuthToken(provider); return new ChatAnthropic(modelId, { - apiKey: getProviderApiKey(provider), + ...(apiKey ? { apiKey } : {}), ...(baseURL ? { anthropicApiUrl: baseURL } : {}), + // Send an `Authorization: Bearer ` header for gateways that + // authenticate that way instead of with `x-api-key`. + ...(authToken ? { clientOptions: { authToken } } : {}), + // ChatAnthropic throws "Anthropic API key not found" unless an apiKey or + // a createClient hook is supplied. With only an auth token configured, + // supply the hook so the token alone builds the client — and, because no + // apiKey is set, the SDK omits the `x-api-key` header entirely rather + // than sending a bogus one alongside the bearer token. + ...(authToken && !apiKey + ? { createClient: (options) => new Anthropic(options) } + : {}), ...retryOptions, }); } diff --git a/src/constants.ts b/src/constants.ts index 32574b06..3f22bcf1 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -22,6 +22,7 @@ export const OPENAI_CHATGPT_EMAIL_ENV_KEY = "OPENAI_CHATGPT_EMAIL"; export const OPENAI_CHATGPT_PLAN_ENV_KEY = "OPENAI_CHATGPT_PLAN"; export const ANTHROPIC_API_KEY_ENV_KEY = "ANTHROPIC_API_KEY"; export const ANTHROPIC_BASE_URL_ENV_KEY = "ANTHROPIC_BASE_URL"; +export const ANTHROPIC_AUTH_TOKEN_ENV_KEY = "ANTHROPIC_AUTH_TOKEN"; export const OPENROUTER_API_KEY_ENV_KEY = "OPENROUTER_API_KEY"; export const OPENWIKI_OPENROUTER_PROVIDER_ONLY_ENV_KEY = "OPENWIKI_OPENROUTER_PROVIDER_ONLY"; @@ -141,6 +142,13 @@ type ProviderConfig = { * pasted-key setup step with a browser login and store tokens instead. */ authMethod?: ProviderAuthMethod; + /** + * Environment variable holding a bearer auth token sent as + * `Authorization: Bearer `. Some gateways (reached via + * {@link ProviderConfig.baseUrlEnvKey}) authenticate this way instead of with + * an `x-api-key`, so a base URL + auth token can stand in for the API key. + */ + authTokenEnvKey?: string; baseURL?: string; /** * Environment variable that, when set, overrides {@link ProviderConfig.baseURL} @@ -284,6 +292,7 @@ export const PROVIDER_CONFIGS: Record = { }, anthropic: { apiKeyEnvKey: ANTHROPIC_API_KEY_ENV_KEY, + authTokenEnvKey: ANTHROPIC_AUTH_TOKEN_ENV_KEY, baseUrlEnvKey: ANTHROPIC_BASE_URL_ENV_KEY, label: "Anthropic", modelOptions: [ @@ -358,6 +367,42 @@ export function getProviderAuthMethod( return getProviderConfig(provider).authMethod ?? "api-key"; } +export function getProviderAuthTokenEnvKey( + provider: OpenWikiProvider, +): string | undefined { + return getProviderConfig(provider).authTokenEnvKey; +} + +/** + * Resolves the provider's bearer auth token (sent as `Authorization: Bearer`), + * trimmed. Returns `undefined` when the provider has no auth-token env var or + * the variable is unset/blank. + */ +export function resolveProviderAuthToken( + provider: OpenWikiProvider, + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + const authTokenEnvKey = getProviderConfig(provider).authTokenEnvKey; + const token = authTokenEnvKey ? env[authTokenEnvKey]?.trim() : undefined; + + return token ? token : undefined; +} + +/** + * Whether the provider can authenticate with a base URL + bearer auth token in + * place of an API key — e.g. a gateway that expects `Authorization: Bearer` + * rather than `x-api-key`. + */ +export function providerHasAuthTokenCredentials( + provider: OpenWikiProvider, + env: NodeJS.ProcessEnv = process.env, +): boolean { + return ( + resolveProviderAuthToken(provider, env) !== undefined && + resolveProviderBaseUrl(provider, env) !== undefined + ); +} + export function providerUsesOAuth(provider: OpenWikiProvider): boolean { return getProviderAuthMethod(provider) === "oauth"; } @@ -381,8 +426,11 @@ export function getProviderLocationEnvKey( /** * Returns the first required-but-unset environment variable for a provider * (its API key, or its cloud project for providers that authenticate without - * one), or `null` when the provider has everything it needs to run. Base URL - * requirements are checked separately via {@link providerRequiresBaseUrl}. + * one), or `null` when the provider has everything it needs to run. Providers + * with an {@link ProviderConfig.authTokenEnvKey} can satisfy the API-key + * requirement with a base URL + auth token instead (see + * {@link providerHasAuthTokenCredentials}). Base URL requirements are checked + * separately via {@link providerRequiresBaseUrl}. */ export function getMissingProviderEnvKey( provider: OpenWikiProvider, @@ -391,7 +439,12 @@ export function getMissingProviderEnvKey( const config = getProviderConfig(provider); if (config.apiKeyEnvKey && !env[config.apiKeyEnvKey]) { - return config.apiKeyEnvKey; + // A base URL + bearer auth token can authenticate in place of the API key + // (e.g. a gateway that expects `Authorization: Bearer`), so the API key is + // only required when that pair is absent. + if (!providerHasAuthTokenCredentials(provider, env)) { + return config.apiKeyEnvKey; + } } if (config.projectEnvKey && !env[config.projectEnvKey]) { @@ -641,7 +694,9 @@ export function resolveConfiguredProvider( ? "openai-compatible" : env[OPENROUTER_API_KEY_ENV_KEY] ? "openrouter" - : env[ANTHROPIC_API_KEY_ENV_KEY] + : env[ANTHROPIC_API_KEY_ENV_KEY] || + (env[ANTHROPIC_AUTH_TOKEN_ENV_KEY] && + env[ANTHROPIC_BASE_URL_ENV_KEY]) ? "anthropic" : env[BASETEN_API_KEY_ENV_KEY] ? "baseten" diff --git a/src/env.ts b/src/env.ts index cc35a6c6..c3a0d936 100644 --- a/src/env.ts +++ b/src/env.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { ANTHROPIC_API_KEY_ENV_KEY, + ANTHROPIC_AUTH_TOKEN_ENV_KEY, ANTHROPIC_BASE_URL_ENV_KEY, BASETEN_API_KEY_ENV_KEY, BASETEN_BASE_URL_ENV_KEY, @@ -104,6 +105,7 @@ export const MANAGED_ENV_KEYS = [ OPENAI_COMPATIBLE_API_KEY_ENV_KEY, OPENAI_COMPATIBLE_BASE_URL_ENV_KEY, ANTHROPIC_API_KEY_ENV_KEY, + ANTHROPIC_AUTH_TOKEN_ENV_KEY, ANTHROPIC_BASE_URL_ENV_KEY, GEMINI_API_KEY_ENV_KEY, GOOGLE_CLOUD_PROJECT_ENV_KEY, diff --git a/test/constants.test.ts b/test/constants.test.ts index 41de9c72..b13ae808 100644 --- a/test/constants.test.ts +++ b/test/constants.test.ts @@ -28,6 +28,7 @@ import { providerRequiresSecretKey, resolveConfiguredProvider, resolveOpenRouterProviderOnly, + resolveProviderAuthToken, resolveProviderBaseUrl, resolveProviderLocation, resolveProviderRegion, @@ -142,6 +143,23 @@ describe("resolveConfiguredProvider", () => { ); }); + test("selects anthropic from a base URL + auth token (no API key)", () => { + expect( + resolveConfiguredProvider({ + ANTHROPIC_AUTH_TOKEN: "t", + ANTHROPIC_BASE_URL: "https://gateway.example/anthropic", + }), + ).toBe("anthropic"); + }); + + test("does NOT select anthropic from an auth token without a base URL", () => { + // The bearer token only stands in for the key when paired with a gateway + // base URL, so a bare token falls through to the default provider. + expect(resolveConfiguredProvider({ ANTHROPIC_AUTH_TOKEN: "t" })).toBe( + DEFAULT_PROVIDER, + ); + }); + test("falls back to the default provider when nothing is configured", () => { expect(resolveConfiguredProvider({})).toBe(DEFAULT_PROVIDER); }); @@ -213,6 +231,27 @@ describe("resolveProviderBaseUrl", () => { }); }); +describe("resolveProviderAuthToken", () => { + test("returns the trimmed anthropic auth token", () => { + expect( + resolveProviderAuthToken("anthropic", { ANTHROPIC_AUTH_TOKEN: " tok " }), + ).toBe("tok"); + }); + + test("returns undefined when unset or blank", () => { + expect(resolveProviderAuthToken("anthropic", {})).toBeUndefined(); + expect( + resolveProviderAuthToken("anthropic", { ANTHROPIC_AUTH_TOKEN: " " }), + ).toBeUndefined(); + }); + + test("returns undefined for providers without an auth-token env key", () => { + expect( + resolveProviderAuthToken("openai", { ANTHROPIC_AUTH_TOKEN: "tok" }), + ).toBeUndefined(); + }); +}); + describe("resolveProviderRetryAttempts", () => { test("uses the OpenWiki default when no override is set", () => { expect(resolveProviderRetryAttempts({})).toBe( @@ -378,6 +417,29 @@ describe("getMissingProviderEnvKey", () => { ).toBeNull(); }); + test("waives the anthropic API key when a base URL + auth token are set", () => { + expect( + getMissingProviderEnvKey("anthropic", { + ANTHROPIC_AUTH_TOKEN: "t", + ANTHROPIC_BASE_URL: "https://gateway.example/anthropic", + }), + ).toBeNull(); + }); + + test("still requires the anthropic API key when the auth token lacks a base URL", () => { + expect( + getMissingProviderEnvKey("anthropic", { ANTHROPIC_AUTH_TOKEN: "t" }), + ).toBe("ANTHROPIC_API_KEY"); + }); + + test("still requires the anthropic API key when the base URL lacks an auth token", () => { + expect( + getMissingProviderEnvKey("anthropic", { + ANTHROPIC_BASE_URL: "https://gateway.example/anthropic", + }), + ).toBe("ANTHROPIC_API_KEY"); + }); + test("reports the missing GCP project for gemini-enterprise", () => { expect(getMissingProviderEnvKey("gemini-enterprise", {})).toBe( "GOOGLE_CLOUD_PROJECT", diff --git a/test/create-model.test.ts b/test/create-model.test.ts index d4520b13..a51cb6aa 100644 --- a/test/create-model.test.ts +++ b/test/create-model.test.ts @@ -138,6 +138,82 @@ describe("createModel gemini (AI Studio)", () => { }); }); +describe("createModel anthropic auth token", () => { + const API_KEY = "ANTHROPIC_API_KEY"; + const AUTH_TOKEN = "ANTHROPIC_AUTH_TOKEN"; + const BASE_URL = "ANTHROPIC_BASE_URL"; + let saved: Record; + + beforeEach(() => { + saved = { + [API_KEY]: process.env[API_KEY], + [AUTH_TOKEN]: process.env[AUTH_TOKEN], + [BASE_URL]: process.env[BASE_URL], + }; + delete process.env[API_KEY]; + delete process.env[AUTH_TOKEN]; + delete process.env[BASE_URL]; + }); + + afterEach(() => { + for (const [key, value] of Object.entries(saved)) { + restoreEnv(key, value); + } + }); + + test("sends only a bearer token (no x-api-key) when the auth token stands alone", () => { + process.env[AUTH_TOKEN] = "gateway-token"; + process.env[BASE_URL] = "https://gateway.example/anthropic"; + + const model = createModel("anthropic", "claude-opus-4-8", 0); + expect(model).toBeInstanceOf(ChatAnthropic); + + const config = model as { + apiKey?: string; + clientOptions?: { authToken?: string }; + createClient?: (options: unknown) => { + apiKey: unknown; + authToken: unknown; + }; + }; + // No API key resolved, so ChatAnthropic never sets one on the client. + expect(config.apiKey).toBeUndefined(); + expect(config.clientOptions?.authToken).toBe("gateway-token"); + + // Exercising the createClient hook proves the underlying SDK client is + // built with the bearer token and a null API key (so no x-api-key header). + const client = config.createClient?.({ authToken: "gateway-token" }); + expect(client?.authToken).toBe("gateway-token"); + expect(client?.apiKey).toBeNull(); + }); + + test("sends both headers when an API key and auth token are both set", () => { + process.env[API_KEY] = "sk-key"; + process.env[AUTH_TOKEN] = "gateway-token"; + process.env[BASE_URL] = "https://gateway.example/anthropic"; + + const model = createModel("anthropic", "claude-opus-4-8", 0); + const config = model as { + apiKey?: string; + clientOptions?: { authToken?: string }; + }; + expect(config.apiKey).toBe("sk-key"); + expect(config.clientOptions?.authToken).toBe("gateway-token"); + }); + + test("uses only the API key when no auth token is set", () => { + process.env[API_KEY] = "sk-key"; + + const model = createModel("anthropic", "claude-opus-4-8", 0); + const config = model as { + apiKey?: string; + clientOptions?: { authToken?: string }; + }; + expect(config.apiKey).toBe("sk-key"); + expect(config.clientOptions?.authToken).toBeUndefined(); + }); +}); + function restoreEnv(key: string, value: string | undefined): void { if (value === undefined) { delete process.env[key];