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
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.).
Expand Down Expand Up @@ -182,6 +214,7 @@ All configurable parameters can be overridden via environment variables. If Anth

| Variable | Description | Default |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 README env-var table priority is inverted

The table entry for ANTHROPIC_BASE_URL says "Takes precedence over config file," but the code and the priority section above it both say the opposite: provider.anthropic.options.baseURL wins over the env var. A user reading only the table will believe they can override a config-file URL with the env var, try it, and find it silently doesn't work.

Prompt To Fix With AI
This is a comment left during a code review.
Path: README.md
Line: 215

Comment:
**README env-var table priority is inverted**

The table entry for `ANTHROPIC_BASE_URL` says "Takes precedence over config file," but the code and the priority section above it both say the opposite: `provider.anthropic.options.baseURL` wins over the env var. A user reading only the table will believe they can override a config-file URL with the env var, try it, and find it silently doesn't work.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Cursor Fix in Claude Code Fix in Codex

| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `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` |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 The description incorrectly says the env var takes precedence over the config file. The actual priority (reflected both in the code and the priority list above the table) is config file > env var > default.

Suggested change
| `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_BASE_URL` | Custom base URL for Anthropic API requests (e.g., proxy or custom endpoint). Overridden by `provider.anthropic.options.baseURL` in `opencode.json` when both are set. | `https://api.anthropic.com/v1` |
Prompt To Fix With AI
This is a comment left during a code review.
Path: README.md
Line: 217

Comment:
The description incorrectly says the env var takes precedence over the config file. The actual priority (reflected both in the code and the priority list above the table) is config file > env var > default.

```suggestion
| `ANTHROPIC_BASE_URL`                | Custom base URL for Anthropic API requests (e.g., proxy or custom endpoint). Overridden by `provider.anthropic.options.baseURL` in `opencode.json` when both are set.                 | `https://api.anthropic.com/v1`                                                                          |
```

How can I resolve this? If you propose a fix, please make it concise.

Fix in Cursor Fix in Claude Code Fix in Codex

| `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` |
Expand Down
282 changes: 282 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { cost?: unknown }> }

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<string, { cost?: unknown }> }

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
}
}
})
})
7 changes: 6 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment on lines +321 to +328

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Custom headers from provider.options.headers are documented but not forwarded

The README and PR description prominently state that provider.anthropic.options.headers entries are "automatically forwarded to all API requests" (useful for X-Gateway-Key etc.). However, only provider.options?.baseURL is consumed here — provider.options?.headers is never read. buildRequestHeaders takes its input only from the init argument supplied by the framework, not from provider config headers. Any user who configures headers in opencode.json expecting them to reach the proxy will receive a silent failure.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/index.ts
Line: 321-328

Comment:
**Custom headers from `provider.options.headers` are documented but not forwarded**

The README and PR description prominently state that `provider.anthropic.options.headers` entries are "automatically forwarded to all API requests" (useful for `X-Gateway-Key` etc.). However, only `provider.options?.baseURL` is consumed here — `provider.options?.headers` is never read. `buildRequestHeaders` takes its input only from the `init` argument supplied by the framework, not from provider config headers. Any user who configures headers in `opencode.json` expecting them to reach the proxy will receive a silent failure.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Cursor Fix in Claude Code Fix in Codex

async fetch(input: RequestInfo | URL, init?: RequestInit) {
const latest = getCachedCredentials()
if (!latest) {
Expand Down