diff --git a/README.md b/README.md index e57f723..9032c21 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,38 @@ The plugin checks these in order: 1. macOS Keychain (all `Claude Code-credentials*` entries — multiple accounts are detected automatically) 2. `~/.claude/.credentials.json` (fallback, works on all platforms) +## Custom base URL / Proxy support + +You can route Anthropic API requests through a proxy or custom endpoint by configuring the `baseURL` option in your `opencode.json`: + +```json +{ + "provider": { + "anthropic": { + "options": { + "baseURL": "https://your-proxy.example.com/v1", + "headers": { + "X-Custom-Header": "value" + } + } + } + }, + "plugin": ["opencode-claude-auth@latest"] +} +``` + +The plugin respects the following priority (highest to lowest): + +1. `provider.anthropic.options.baseURL` from `opencode.json` +2. `ANTHROPIC_BASE_URL` environment variable +3. Default: `https://api.anthropic.com/v1` + +Any custom headers you define in `provider.anthropic.options.headers` are automatically forwarded to all API requests. This is useful for: + +- Using an HTTP proxy or API gateway +- Adding authentication headers (e.g., `X-Gateway-Key`) +- Routing through a load balancer or monitoring proxy + ## Multiple accounts (macOS) If you have [multiple Claude Code accounts](https://gist.github.com/KMJ-007/0979814968722051620461ab2aa01bf2) authenticated on macOS, the plugin detects all of them from the Keychain automatically. Each account is labeled by its subscription tier (Claude Pro, Claude Max, etc.). @@ -182,6 +214,7 @@ All configurable parameters can be overridden via environment variables. If Anth | Variable | Description | Default | | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `ANTHROPIC_BASE_URL` | Custom base URL for Anthropic API requests (e.g., proxy or custom endpoint). Takes precedence over config file. | `https://api.anthropic.com/v1` | | `ANTHROPIC_CLI_VERSION` | Claude CLI version for user-agent and billing headers | `2.1.80` | | `ANTHROPIC_USER_AGENT` | Full User-Agent string (overrides CLI version) | `claude-cli/{version} (external, cli)` | | `ANTHROPIC_BETA_FLAGS` | Comma-separated beta feature flags | `claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,prompt-caching-scope-2026-01-05` | diff --git a/src/index.test.ts b/src/index.test.ts index 91a2ab9..15c5a6c 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1201,3 +1201,285 @@ describe("refreshIfNeeded — token expiry", () => { ) }) }) + +describe("custom baseURL support via provider.options.baseURL and ANTHROPIC_BASE_URL env", () => { + it("uses default baseURL when no config or env var is set", async () => { + const originalSetInterval = globalThis.setInterval + const originalHome = process.env.HOME + const originalFetch = globalThis.fetch + const originalEnv = process.env.ANTHROPIC_BASE_URL + + delete process.env.ANTHROPIC_BASE_URL + const tempHome = await mkdtemp(join(tmpdir(), "opencode-claude-auth-baseurl-")) + process.env.HOME = tempHome + Date.now = () => 1_700_000_000_000 + globalThis.setInterval = (() => ({ unref() {} })) as unknown as typeof setInterval + + let forwardedInput: RequestInfo | URL | undefined + + try { + const { helpersModule } = await loadHelpersWithCountingKeychain( + Date.now() + 10 * 60_000, + ) + globalThis.fetch = (async (input: RequestInfo | URL) => { + forwardedInput = input + return new Response("ok") + }) as typeof fetch + + const plugin = await helpersModule.default({} as never) + const typedPlugin = plugin as { auth?: { loader?: TestAuthLoader } } + const authConfig = await typedPlugin.auth!.loader!( + async () => ({ + type: "oauth", + refresh: "refresh", + access: "access", + expires: Date.now() + 60_000, + }), + { models: {} }, + ) + + await authConfig.fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + body: JSON.stringify({ model: "claude-haiku-4-5", messages: [] }), + }) + + assert.equal( + String(forwardedInput), + "https://api.anthropic.com/v1/messages?beta=true", + "Should use default baseURL when no override is set", + ) + } finally { + rmSync(tempHome, { recursive: true, force: true }) + globalThis.setInterval = originalSetInterval + globalThis.fetch = originalFetch + if (typeof originalHome === "string") { + process.env.HOME = originalHome + } else { + delete process.env.HOME + } + if (typeof originalEnv === "string") { + process.env.ANTHROPIC_BASE_URL = originalEnv + } else { + delete process.env.ANTHROPIC_BASE_URL + } + } + }) + + it("uses provider.options.baseURL when provided", async () => { + const originalSetInterval = globalThis.setInterval + const originalHome = process.env.HOME + const originalFetch = globalThis.fetch + + const tempHome = await mkdtemp(join(tmpdir(), "opencode-claude-auth-baseurl-")) + process.env.HOME = tempHome + Date.now = () => 1_700_000_000_000 + globalThis.setInterval = (() => ({ unref() {} })) as unknown as typeof setInterval + + try { + const { helpersModule } = await loadHelpersWithCountingKeychain( + Date.now() + 10 * 60_000, + ) + globalThis.fetch = (async () => new Response("ok")) as typeof fetch + + const plugin = await helpersModule.default({} as never) + const typedPlugin = plugin as { auth?: { loader?: TestAuthLoader } } + + const customProvider = { + models: { "claude-haiku-4-5": { cost: 0 } }, + options: { baseURL: "https://proxy.example.com/v1" }, + } as unknown as { models: Record } + + const authConfigWithBaseURL = await typedPlugin.auth!.loader!( + async () => ({ + type: "oauth", + refresh: "refresh", + access: "access", + expires: Date.now() + 60_000, + }), + customProvider, + ) + + assert.equal( + (authConfigWithBaseURL as { baseURL?: string }).baseURL, + "https://proxy.example.com/v1", + "Should use provider.options.baseURL when provided", + ) + } finally { + rmSync(tempHome, { recursive: true, force: true }) + globalThis.setInterval = originalSetInterval + globalThis.fetch = originalFetch + if (typeof originalHome === "string") { + process.env.HOME = originalHome + } else { + delete process.env.HOME + } + } + }) + + it("uses ANTHROPIC_BASE_URL env var when set and no provider.options.baseURL", async () => { + const originalSetInterval = globalThis.setInterval + const originalHome = process.env.HOME + const originalFetch = globalThis.fetch + const originalEnv = process.env.ANTHROPIC_BASE_URL + + process.env.ANTHROPIC_BASE_URL = "https://gateway.example.com/anthropic/v1" + const tempHome = await mkdtemp(join(tmpdir(), "opencode-claude-auth-baseurl-")) + process.env.HOME = tempHome + Date.now = () => 1_700_000_000_000 + globalThis.setInterval = (() => ({ unref() {} })) as unknown as typeof setInterval + + try { + const { helpersModule } = await loadHelpersWithCountingKeychain( + Date.now() + 10 * 60_000, + ) + globalThis.fetch = (async () => new Response("ok")) as typeof fetch + + const plugin = await helpersModule.default({} as never) + const typedPlugin = plugin as { auth?: { loader?: TestAuthLoader } } + const authConfig = await typedPlugin.auth!.loader!( + async () => ({ + type: "oauth", + refresh: "refresh", + access: "access", + expires: Date.now() + 60_000, + }), + { models: {} }, + ) + + assert.equal( + (authConfig as { baseURL?: string }).baseURL, + "https://gateway.example.com/anthropic/v1", + "Should use ANTHROPIC_BASE_URL when env var is set", + ) + } finally { + rmSync(tempHome, { recursive: true, force: true }) + globalThis.setInterval = originalSetInterval + globalThis.fetch = originalFetch + if (typeof originalHome === "string") { + process.env.HOME = originalHome + } else { + delete process.env.HOME + } + if (typeof originalEnv === "string") { + process.env.ANTHROPIC_BASE_URL = originalEnv + } else { + delete process.env.ANTHROPIC_BASE_URL + } + } + }) + + it("prioritizes provider.options.baseURL over ANTHROPIC_BASE_URL env var", async () => { + const originalSetInterval = globalThis.setInterval + const originalHome = process.env.HOME + const originalFetch = globalThis.fetch + const originalEnv = process.env.ANTHROPIC_BASE_URL + + process.env.ANTHROPIC_BASE_URL = "https://gateway.example.com/anthropic/v1" + const tempHome = await mkdtemp(join(tmpdir(), "opencode-claude-auth-baseurl-")) + process.env.HOME = tempHome + Date.now = () => 1_700_000_000_000 + globalThis.setInterval = (() => ({ unref() {} })) as unknown as typeof setInterval + + try { + const { helpersModule } = await loadHelpersWithCountingKeychain( + Date.now() + 10 * 60_000, + ) + globalThis.fetch = (async () => new Response("ok")) as typeof fetch + + const plugin = await helpersModule.default({} as never) + const typedPlugin = plugin as { auth?: { loader?: TestAuthLoader } } + + const customProvider = { + models: { "claude-haiku-4-5": { cost: 0 } }, + options: { baseURL: "https://config-priority.example.com/v1" }, + } as unknown as { models: Record } + + const authConfig = await typedPlugin.auth!.loader!( + async () => ({ + type: "oauth", + refresh: "refresh", + access: "access", + expires: Date.now() + 60_000, + }), + customProvider, + ) + + assert.equal( + (authConfig as { baseURL?: string }).baseURL, + "https://config-priority.example.com/v1", + "Should prioritize provider.options.baseURL over ANTHROPIC_BASE_URL", + ) + } finally { + rmSync(tempHome, { recursive: true, force: true }) + globalThis.setInterval = originalSetInterval + globalThis.fetch = originalFetch + if (typeof originalHome === "string") { + process.env.HOME = originalHome + } else { + delete process.env.HOME + } + if (typeof originalEnv === "string") { + process.env.ANTHROPIC_BASE_URL = originalEnv + } else { + delete process.env.ANTHROPIC_BASE_URL + } + } + }) + + it("does not double the /v1 path segment", async () => { + const originalSetInterval = globalThis.setInterval + const originalHome = process.env.HOME + const originalFetch = globalThis.fetch + const originalEnv = process.env.ANTHROPIC_BASE_URL + + process.env.ANTHROPIC_BASE_URL = "https://proxy.example.com/v1" + const tempHome = await mkdtemp(join(tmpdir(), "opencode-claude-auth-baseurl-")) + process.env.HOME = tempHome + Date.now = () => 1_700_000_000_000 + globalThis.setInterval = (() => ({ unref() {} })) as unknown as typeof setInterval + + try { + const { helpersModule } = await loadHelpersWithCountingKeychain( + Date.now() + 10 * 60_000, + ) + globalThis.fetch = (async () => new Response("ok")) as typeof fetch + + const plugin = await helpersModule.default({} as never) + const typedPlugin = plugin as { auth?: { loader?: TestAuthLoader } } + const authConfig = await typedPlugin.auth!.loader!( + async () => ({ + type: "oauth", + refresh: "refresh", + access: "access", + expires: Date.now() + 60_000, + }), + { models: {} }, + ) + + const baseURL = (authConfig as { baseURL?: string }).baseURL + assert.ok( + baseURL && !baseURL.includes("/v1/v1"), + `baseURL should not contain doubled /v1 path: ${baseURL}`, + ) + assert.equal( + baseURL, + "https://proxy.example.com/v1", + "baseURL should be correctly set from ANTHROPIC_BASE_URL env var", + ) + } finally { + rmSync(tempHome, { recursive: true, force: true }) + globalThis.setInterval = originalSetInterval + globalThis.fetch = originalFetch + if (typeof originalHome === "string") { + process.env.HOME = originalHome + } else { + delete process.env.HOME + } + if (typeof originalEnv === "string") { + process.env.ANTHROPIC_BASE_URL = originalEnv + } else { + delete process.env.ANTHROPIC_BASE_URL + } + } + }) +}) diff --git a/src/index.ts b/src/index.ts index d60cfb1..0bb1d61 100644 --- a/src/index.ts +++ b/src/index.ts @@ -318,9 +318,14 @@ const plugin: Plugin = async () => { modelCount: Object.keys(provider.models).length, }) + const effectiveBaseURL = + provider.options?.baseURL || + process.env.ANTHROPIC_BASE_URL || + "https://api.anthropic.com/v1" + return { apiKey: "", - baseURL: "https://api.anthropic.com/v1", + baseURL: effectiveBaseURL, async fetch(input: RequestInfo | URL, init?: RequestInit) { const latest = getCachedCredentials() if (!latest) {