Skip to content

fix: add Anthropic OAuth support for Pro/Max plan users#214

Open
phil-02 wants to merge 1 commit intodavis7dotsh:mainfrom
phil-02:fix/anthropic-oauth-pro-max-plan
Open

fix: add Anthropic OAuth support for Pro/Max plan users#214
phil-02 wants to merge 1 commit intodavis7dotsh:mainfrom
phil-02:fix/anthropic-oauth-pro-max-plan

Conversation

@phil-02
Copy link

@phil-02 phil-02 commented Mar 6, 2026

Summary

Fixes #211btca ask fails at "creating collection" with a cryptic Effect.tryPromise error when using provider: anthropic with a Pro/Max plan OAuth token from opencode auth.

Root Cause

Four bugs compounding:

  1. anthropic provider only accepted type: "api" auth — OAuth credentials stored by opencode auth --provider anthropic were not recognised by isAuthenticated() or getAuthStatus(), so getModel() threw or passed apiKey: undefined to @ai-sdk/anthropic.

  2. provider: opencode silently bypassed auth checksrequiresAuth was always false for opencode, so missing credentials never surfaced as a ProviderNotConnectedError. Instead the undefined API key caused a runtime failure deep inside collection creation, producing the opaque "An error occurred in Effect.tryPromise" message.

  3. parseErrorResponse swallowed real server error messages — used the bare Effect.tryPromise(() => res.json()) form, which produces the default "An error occurred in Effect.tryPromise" string instead of the actual server error.

  4. ProviderOptions had no fetch field — no way to inject a custom fetch into the Anthropic SDK factory.

Changes

apps/server/src/providers/auth.ts

  • Add 'oauth' to PROVIDER_AUTH_TYPES.anthropic

  • Update getProviderAuthHint for anthropic to mention OAuth

  • Add createAnthropicOAuthFetch() — a stateless custom fetch factory that reads credentials fresh from auth.json on every request (no cached token state, so no stale-credential race conditions). Only the in-flight refresh promise is module-level, to deduplicate concurrent callers and prevent consuming the single-use refresh token more than once.

    The fetch function implements the full OAuth request pipeline based on opencode-anthropic-auth@0.0.13 (credit: anomalyco/opencode):

    • Authorization: Bearer injection, x-api-key removal
    • Token refresh via POST https://console.anthropic.com/v1/oauth/token, persisted back to auth.json
    • Required OAuth beta headers merged with any incoming betas
    • user-agent: claude-cli/2.1.2 (external, cli)
    • ?beta=true appended to /v1/messages
    • System prompt sanitisation for both string and Array formats (OpenCodeClaude Code)
    • Tool names prefixed with mcp_ in requests (guarded against double-prefixing for MCP server tools)
    • mcp_ prefix stripped from responses via a line-buffered SSE transform scoped to tool_use / content_block_start event lines only (avoids corrupting tool result payloads, handles chunk boundaries correctly)

apps/server/src/providers/model.ts

  • In getModel(), when provider === 'anthropic' and credentials are type: 'oauth', set apiKey: '' and inject the OAuth fetch via createAnthropicOAuthFetch()

apps/server/src/providers/registry.ts

  • Add fetch?: typeof globalThis.fetch to ProviderOptions

apps/server/src/agent/service.ts

  • Remove opencode from the requiresAuth exemption

apps/cli/src/client/index.ts

  • Fix parseErrorResponse to use Effect.tryPromise({ try, catch }) form

Testing

Tested end-to-end with provider: anthropic and an OAuth token from opencode auth --provider anthropic (Anthropic Pro plan):

$ btca ask -r opencode -q "Does opencode support custom slash commands?"
loading resources...
creating collection...
Yes, OpenCode supports custom slash commands...

Greptile Summary

This PR adds Anthropic OAuth support for Pro/Max plan users, fixing a compounding set of bugs that caused btca ask to fail with a cryptic error when using an OAuth token from opencode auth --provider anthropic. The core addition is createAnthropicOAuthFetch() in auth.ts — a stateless custom fetch factory that handles Bearer token injection, token refresh deduplication with 30-second expiry buffer, required beta headers, system prompt sanitisation (case-sensitive OpenCodeClaude Code), Zod-validated token responses, and mcp_ tool name prefixing/stripping across the SSE response stream with cancel-safe streaming.

Key changes:

  • auth.ts: Adds 'oauth' to PROVIDER_AUTH_TYPES.anthropic and implements the full OAuth fetch pipeline with Zod validation, 30-second expiry buffer, 10-second AbortSignal.timeout, and a cancel-safe streaming transform.
  • model.ts: Injects the OAuth fetch and clears apiKey when Anthropic credentials are oauth type.
  • service.ts: Removes opencode from the requiresAuth exemption — a breaking change for any opencode users who rely on the provider without a locally-configured API key. Requires verification that no supported deployment topology depends on auth-free access.
  • registry.ts: Adds optional fetch field to ProviderOptions.
  • client/index.ts: Fixes parseErrorResponse to use the { try, catch } form of Effect.tryPromise, resolving the opaque error message surfacing.

The OAuth implementation is well-structured and includes all previously-noted safeguards (expiry buffer, cancel handler, response validation, refresh timeout).

Confidence Score: 4/5

  • Safe to merge for Anthropic OAuth functionality, but the opencode provider auth gating is a breaking change that requires verification before release.
  • The OAuth implementation is solid with proper timeout handling, Zod validation, cancel-safety, and expiry buffering. The Effect.tryPromise fix is correct. The main risk is the opencode exemption removal, which gates users who previously had auth-free access. This must be confirmed as intentional before merging. No issues found with the overall implementation quality.
  • Verify that no existing deployment topology relies on opencode provider without local credentials before release.

Comments Outside Diff (1)

  1. apps/server/src/agent/service.ts, line 127-136 (link)

    Breaking change for opencode provider users

    Removing opencode from the requiresAuth exemption (line 129) means any user with provider: opencode in their config who lacks both an OPENCODE_API_KEY environment variable and an api-type entry in auth.json will now receive a ProviderNotConnectedError instead of proceeding.

    Looking at auth.ts line 22, opencode only supports 'api' auth type — there is no OAuth or wellknown flow for it. If any deployments rely on the opencode provider in auth-free mode (e.g., self-hosted server handling auth transparently), this change breaks them.

    Before merging, confirm:

    • No supported opencode deployment topology intentionally runs without a local key, OR
    • Update release notes with a migration path (OPENCODE_API_KEY env var or btca connect -p opencode)

Last reviewed commit: 17dd97d

Context used:

  • Context used - Keep the comments simple and clear. Focus heavily ... (source)

@vercel
Copy link

vercel bot commented Mar 6, 2026

@phil-02 is attempting to deploy a commit to the davis7dotsh Team on Vercel.

A member of the Team first needs to authorize it.

Comment on lines +306 to +320
if (parsed.system && typeof parsed.system === 'string') {
parsed.system = parsed.system
.replace(/OpenCode/g, 'Claude Code')
.replace(/opencode/gi, 'Claude');
} else if (parsed.system && Array.isArray(parsed.system)) {
parsed.system = (parsed.system as Array<{ type: string; text?: string }>).map((item) => {
if (item.type === 'text' && item.text) {
return {
...item,
text: item.text.replace(/OpenCode/g, 'Claude Code').replace(/opencode/gi, 'Claude')
};
}
return item;
});
}
Copy link
Contributor

Choose a reason for hiding this comment

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

The case-insensitive /opencode/gi replacement will silently corrupt any user-provided content that legitimately references the word "opencode". For example, a user running btca ask on the opencode repository itself would have system prompt content like "the opencode project" rewritten to "the Claude project", subtly changing the meaning before it reaches the API.

The first replacement (/OpenCode/g) is already case-sensitive and handles the specific display name. The second, case-insensitive pass causes the problem. Consider narrowing to case-sensitive-only:

Suggested change
if (parsed.system && typeof parsed.system === 'string') {
parsed.system = parsed.system
.replace(/OpenCode/g, 'Claude Code')
.replace(/opencode/gi, 'Claude');
} else if (parsed.system && Array.isArray(parsed.system)) {
parsed.system = (parsed.system as Array<{ type: string; text?: string }>).map((item) => {
if (item.type === 'text' && item.text) {
return {
...item,
text: item.text.replace(/OpenCode/g, 'Claude Code').replace(/opencode/gi, 'Claude')
};
}
return item;
});
}
if (parsed.system && typeof parsed.system === 'string') {
parsed.system = parsed.system.replace(/OpenCode/g, 'Claude Code');
} else if (parsed.system && Array.isArray(parsed.system)) {
parsed.system = (parsed.system as Array<{ type: string; text?: string }>).map((item) => {
if (item.type === 'text' && item.text) {
return {
...item,
text: item.text.replace(/OpenCode/g, 'Claude Code')
};
}
return item;
});
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/server/src/providers/auth.ts
Line: 306-320

Comment:
The case-insensitive `/opencode/gi` replacement will silently corrupt any user-provided content that legitimately references the word "opencode". For example, a user running `btca ask` on the opencode repository itself would have system prompt content like `"the opencode project"` rewritten to `"the Claude project"`, subtly changing the meaning before it reaches the API.

The first replacement (`/OpenCode/g`) is already case-sensitive and handles the specific display name. The second, case-insensitive pass causes the problem. Consider narrowing to case-sensitive-only:

```suggestion
				if (parsed.system && typeof parsed.system === 'string') {
					parsed.system = parsed.system.replace(/OpenCode/g, 'Claude Code');
				} else if (parsed.system && Array.isArray(parsed.system)) {
					parsed.system = (parsed.system as Array<{ type: string; text?: string }>).map((item) => {
						if (item.type === 'text' && item.text) {
							return {
								...item,
								text: item.text.replace(/OpenCode/g, 'Claude Code')
							};
						}
						return item;
					});
```

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


// Refresh token if expired or missing — deduplicate concurrent refresh calls
// since Anthropic refresh tokens are single-use
if (!auth?.access || auth.expires < Date.now()) {
Copy link
Contributor

Choose a reason for hiding this comment

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

The expiry check uses the exact expiry timestamp with no buffer. Any in-flight request that starts just before expiry will likely receive a 401 from Anthropic because the token expires while the request is being processed (especially during streaming, which can take several seconds).

A small safety buffer (e.g., 30 seconds) prevents this race condition:

Suggested change
if (!auth?.access || auth.expires < Date.now()) {
if (!auth?.access || auth.expires - 30_000 < Date.now()) {
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/server/src/providers/auth.ts
Line: 252

Comment:
The expiry check uses the exact expiry timestamp with no buffer. Any in-flight request that starts just before expiry will likely receive a `401` from Anthropic because the token expires while the request is being processed (especially during streaming, which can take several seconds).

A small safety buffer (e.g., 30 seconds) prevents this race condition:

```suggestion
		if (!auth?.access || auth.expires - 30_000 < Date.now()) {
```

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

@phil-02 phil-02 force-pushed the fix/anthropic-oauth-pro-max-plan branch from 0865157 to 7bc3b91 Compare March 6, 2026 20:36
Comment on lines +414 to +433
const stream = new ReadableStream({
async pull(controller) {
const { done, value } = await reader.read();
if (done) {
// Flush any remaining buffered content
if (lineBuffer) {
controller.enqueue(encoder.encode(stripMcpPrefix(lineBuffer)));
lineBuffer = '';
}
controller.close();
return;
}
const text = lineBuffer + decoder.decode(value, { stream: true });
const lines = text.split('\n');
// Hold back the last element — it may be an incomplete line
lineBuffer = lines.pop() ?? '';
const transformed = lines.map(stripMcpPrefix).join('\n') + '\n';
controller.enqueue(encoder.encode(transformed));
}
});
Copy link
Contributor

Choose a reason for hiding this comment

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

ReadableStream missing cancel handler — resource leak

The transform stream has no cancel method. When the consumer cancels mid-stream (user aborts, request timeout, AbortSignal fires), the ReadableStream spec will silently drop the pull invocation, but the inner reader obtained from response.body.getReader() is never cancelled. This leaves the underlying HTTP connection open until the server closes it, leaking connections on every user abort.

Add a cancel handler to release the upstream reader:

const stream = new ReadableStream({
    async pull(controller) {
        // ... existing pull logic
    },
    cancel() {
        reader.cancel();
    }
});
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/server/src/providers/auth.ts
Line: 414-433

Comment:
**`ReadableStream` missing `cancel` handler — resource leak**

The transform stream has no `cancel` method. When the consumer cancels mid-stream (user aborts, request timeout, `AbortSignal` fires), the `ReadableStream` spec will silently drop the `pull` invocation, but the inner `reader` obtained from `response.body.getReader()` is never cancelled. This leaves the underlying HTTP connection open until the server closes it, leaking connections on every user abort.

Add a `cancel` handler to release the upstream reader:

```ts
const stream = new ReadableStream({
    async pull(controller) {
        // ... existing pull logic
    },
    cancel() {
        reader.cancel();
    }
});
```

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

Comment on lines +215 to +226
const json = (await response.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
};
await setCredentials('anthropic', {
type: 'oauth',
access: json.access_token,
refresh: json.refresh_token,
expires: Date.now() + json.expires_in * 1000,
accountId: auth.accountId
});
Copy link
Contributor

Choose a reason for hiding this comment

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

Token refresh response is unvalidated

The response JSON is cast directly to { access_token, refresh_token, expires_in } with no runtime validation. If the Anthropic token endpoint returns an unexpected shape (e.g. a rate-limit or error response body), setCredentials will persist undefined values for access, refresh, and expires into auth.json, permanently corrupting the stored credentials. The user would be locked out until they re-run opencode auth --provider anthropic.

Consider validating the parsed response against a Zod schema (similar to how readAuthFile validates auth.json) before calling setCredentials, and throwing a descriptive error if the shape doesn't match.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/server/src/providers/auth.ts
Line: 215-226

Comment:
**Token refresh response is unvalidated**

The response JSON is cast directly to `{ access_token, refresh_token, expires_in }` with no runtime validation. If the Anthropic token endpoint returns an unexpected shape (e.g. a rate-limit or error response body), `setCredentials` will persist `undefined` values for `access`, `refresh`, and `expires` into `auth.json`, permanently corrupting the stored credentials. The user would be locked out until they re-run `opencode auth --provider anthropic`.

Consider validating the parsed response against a Zod schema (similar to how `readAuthFile` validates `auth.json`) before calling `setCredentials`, and throwing a descriptive error if the shape doesn't match.

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

@phil-02 phil-02 force-pushed the fix/anthropic-oauth-pro-max-plan branch from 7bc3b91 to b3f9b36 Compare March 7, 2026 01:01
Comment on lines +200 to +233
const _refreshAnthropicToken = async (): Promise<void> => {
const auth = await getCredentials('anthropic');
if (!auth || auth.type !== 'oauth') throw new Error('Anthropic OAuth credentials not found');
const response = await fetch(ANTHROPIC_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: auth.refresh,
client_id: ANTHROPIC_CLIENT_ID
})
});
if (!response.ok) {
throw new Error(`Anthropic token refresh failed: ${response.status}`);
}
const TokenResponseSchema = z.object({
access_token: z.string(),
refresh_token: z.string(),
expires_in: z.number()
});
const parsed = TokenResponseSchema.safeParse(await response.json());
if (!parsed.success) {
throw new Error(
`Anthropic token refresh returned unexpected response shape: ${parsed.error.message}`
);
}
await setCredentials('anthropic', {
type: 'oauth',
access: parsed.data.access_token,
refresh: parsed.data.refresh_token,
expires: Date.now() + parsed.data.expires_in * 1000,
accountId: auth.accountId
});
};
Copy link
Contributor

Choose a reason for hiding this comment

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

Missing timeout on Anthropic token refresh

The _refreshAnthropicToken function calls the Anthropic OAuth token endpoint without a timeout. If the endpoint is slow or unreachable, _oauthRefreshPromise will hang indefinitely, stalling all in-flight OAuth requests for the lifetime of the process.

Add an AbortSignal.timeout() to limit the blast radius:

const response = await fetch(ANTHROPIC_TOKEN_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ ... }),
    signal: AbortSignal.timeout(10_000) // 10 seconds
});
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/server/src/providers/auth.ts
Line: 200-233

Comment:
**Missing timeout on Anthropic token refresh**

The `_refreshAnthropicToken` function calls the Anthropic OAuth token endpoint without a timeout. If the endpoint is slow or unreachable, `_oauthRefreshPromise` will hang indefinitely, stalling all in-flight OAuth requests for the lifetime of the process.

Add an `AbortSignal.timeout()` to limit the blast radius:

```ts
const response = await fetch(ANTHROPIC_TOKEN_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ ... }),
    signal: AbortSignal.timeout(10_000) // 10 seconds
});
```

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

Copy link
Author

Choose a reason for hiding this comment

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

Fixed in 17dd97dAbortSignal.timeout(10_000) was added to the token refresh fetch in the latest commit.

Allows btca to authenticate using the OAuth token stored by
'opencode auth --provider anthropic', so users on the Anthropic
Pro/Max plan can use btca without a separate API key.

Changes
-------

apps/server/src/providers/auth.ts
- Add 'oauth' to PROVIDER_AUTH_TYPES.anthropic so isAuthenticated()
  and getAuthStatus() recognise OAuth credentials
- Update getProviderAuthHint() to mention OAuth as an option
- Add createAnthropicOAuthFetch() — a stateless custom fetch factory
  that reads credentials fresh from auth.json on every request (no
  cached token state, eliminating stale-credential race conditions).
  Only the in-flight refresh promise is module-level, to deduplicate
  concurrent callers and prevent consuming the single-use refresh
  token more than once.
  Behaviour mirrors opencode-anthropic-auth@0.0.13 (credit: anomalyco/opencode):
  - Authorization: Bearer <access_token> injection (x-api-key removed)
  - Token refresh via POST console.anthropic.com/v1/oauth/token,
    persisted back to auth.json via setCredentials()
  - Required OAuth beta headers (oauth-2025-04-20,
    interleaved-thinking-2025-05-14) merged with any incoming betas
  - user-agent: claude-cli/2.1.2 (external, cli)
  - ?beta=true appended to /v1/messages requests
  - System prompt sanitisation for both string and array formats
    (OpenCode -> Claude Code) to pass Anthropic OAuth endpoint filters
  - Tool names prefixed with mcp_ in requests (skipped if already
    prefixed to avoid double-prefixing MCP server tools), stripped
    from responses via line-buffered SSE transform scoped to
    tool_use / content_block_start events only

apps/server/src/providers/model.ts
- Import createAnthropicOAuthFetch and getCredentials from auth.ts
- In getModel(), when provider is 'anthropic' and credentials are
  type 'oauth', set apiKey: '' and inject the OAuth fetch

apps/server/src/providers/registry.ts
- Add fetch?: typeof globalThis.fetch to ProviderOptions

apps/server/src/agent/service.ts
- Remove 'opencode' from the requiresAuth exemption so missing
  credentials surface as ProviderNotConnectedError immediately
  rather than a cryptic Effect error deep in collection creation

apps/cli/src/client/index.ts
- Fix parseErrorResponse to use Effect.tryPromise({ try, catch })
  so real server error messages reach the user

Closes davis7dotsh#211
@phil-02 phil-02 force-pushed the fix/anthropic-oauth-pro-max-plan branch from b3f9b36 to 17dd97d Compare March 7, 2026 01:33
@phil-02
Copy link
Author

phil-02 commented Mar 7, 2026

Note on the requiresAuth change in agent/service.ts

This PR removes opencode from the auth bypass in ensureProviderConnected:

// before
const requiresAuth = config.provider !== 'opencode' && config.provider !== 'openai-compat';
// after
const requiresAuth = config.provider !== 'openai-compat';

The original motivation was that missing opencode credentials produced a cryptic "An error occurred in Effect.tryPromise" error deep in collection creation rather than a clear ProviderNotConnectedError. This change surfaces that error earlier and more clearly.

However, if the opencode bypass was intentional — e.g. the Zen gateway supports anonymous or unauthenticated access — this change would be a breaking behaviour change for users running provider: opencode without a local API key. Could you confirm whether anonymous opencode access was ever intended? Happy to revert that part of the change if so.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

btca ask fails at 'creating collection' with Effect.tryPromise error (v2.0.5, opencode provider)

1 participant