Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>` 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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 16 additions & 1 deletion src/agent/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -86,6 +87,7 @@ import {
providerRequiresSecretKey,
resolveConfiguredProvider,
resolveOpenRouterProviderOnly,
resolveProviderAuthToken,
resolveProviderBaseUrl,
resolveProviderLocation,
resolveProviderRegion,
Expand Down Expand Up @@ -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 <token>` 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,
});
}
Expand Down
63 changes: 59 additions & 4 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 <token>`. 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}
Expand Down Expand Up @@ -284,6 +292,7 @@ export const PROVIDER_CONFIGS: Record<OpenWikiProvider, ProviderConfig> = {
},
anthropic: {
apiKeyEnvKey: ANTHROPIC_API_KEY_ENV_KEY,
authTokenEnvKey: ANTHROPIC_AUTH_TOKEN_ENV_KEY,
baseUrlEnvKey: ANTHROPIC_BASE_URL_ENV_KEY,
label: "Anthropic",
modelOptions: [
Expand Down Expand Up @@ -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";
}
Expand All @@ -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,
Expand All @@ -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]) {
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
62 changes: 62 additions & 0 deletions test/constants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
providerRequiresSecretKey,
resolveConfiguredProvider,
resolveOpenRouterProviderOnly,
resolveProviderAuthToken,
resolveProviderBaseUrl,
resolveProviderLocation,
resolveProviderRegion,
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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",
Expand Down
76 changes: 76 additions & 0 deletions test/create-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | undefined>;

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];
Expand Down