Skip to content

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

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

fix: add Anthropic OAuth support for Pro/Max plan users#213
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, causing isAuthenticated() to return false and getModel() to throw or pass 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 when res.json() fails.

  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(initialAuth: OAuthAuth) — a custom fetch factory that:
    • Injects Authorization: Bearer <access_token> and removes x-api-key
    • Handles automatic token refresh via POST https://console.anthropic.com/v1/oauth/token and persists refreshed tokens back to auth.json
    • Merges required OAuth beta flags (oauth-2025-04-20, interleaved-thinking-2025-05-14)
    • Sets user-agent: claude-cli/2.1.2 (external, cli)
    • Appends ?beta=true to /v1/messages requests
    • Sanitises system prompts (OpenCodeClaude Code) to pass Anthropic's OAuth endpoint filters
    • Prefixes tool names with mcp_ in requests and strips the prefix transparently in streaming responses

The OAuth fetch implementation is based on opencode-anthropic-auth@0.0.13 from the opencode project (credit: anomalyco/opencode).

apps/server/src/providers/model.ts

  • Import getCredentials, createAnthropicOAuthFetch, OAuthAuth from auth.ts
  • In getModel(), when provider === 'anthropic' and credentials are type: 'oauth', set apiKey: '' and inject the OAuth fetch into providerOptions

apps/server/src/providers/registry.ts

  • Add fetch?: typeof globalThis.fetch to ProviderOptions for extensibility

apps/server/src/agent/service.ts

  • Remove opencode from the requiresAuth exemption so missing opencode credentials surface as a clear ProviderNotConnectedError instead of a cryptic Effect error

apps/cli/src/client/index.ts

  • Fix parseErrorResponse to use Effect.tryPromise({ try, catch }) instead of the bare form, preserving real server error messages

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 by wiring in a custom fetch factory that handles Bearer-token injection, automatic token refresh, required beta headers, system-prompt sanitisation, and mcp_-prefix tool name round-tripping for SSE streams. It also fixes parseErrorResponse to preserve real server error messages, and surfaces missing-credentials errors for the opencode provider more cleanly.

Key points from the review:

  • Breaking change — opencode provider auth requirement (service.ts): Removing opencode from the requiresAuth bypass means existing users configured with provider: opencode and no API key will now get a ProviderNotConnectedError on every request. If the opencode gateway is currently accessible without a key for some users, this will silently break their setup.
  • Potential "body already used" issue (auth.ts): When the request URL is rewritten (to append ?beta=true) and the original input is a Request object, a new Request is built from it — embedding the original body stream — and then the outer fetch call additionally passes a body override. Some runtimes may raise a "body already used" error when both paths reference the same stream. Constructing a plain URL string for the rewritten URL and relying solely on init for body and headers would be safer.
  • No plan .md files were found in the changeset.
  • The SSE line-buffering, double-prefix guard, and module-level refresh deduplication all look correct.

Confidence Score: 2/5

  • Not safe to merge without confirming the opencode provider breaking change is intentional and understood by all current users.
  • The two flagged issues — a confirmed behavioral regression for opencode users without credentials, and a runtime-dependent "body already used" risk in the OAuth fetch — are both in high-traffic code paths. The opencode change in particular is a silent breaking change for anyone currently relying on that provider without authentication.
  • apps/server/src/agent/service.ts (breaking auth change) and apps/server/src/providers/auth.ts (Request body handling in OAuth fetch).

Important Files Changed

Filename Overview
apps/server/src/providers/auth.ts Large new OAuth fetch factory: deduplication and line-buffering are solid, but a potential "body already used" issue exists when constructing a new Request from an existing one and then overriding the body in the outer fetch init.
apps/server/src/agent/service.ts Removing opencode from the requiresAuth exemption is a breaking change for existing users who rely on the provider without credentials; needs confirmation before merging.
apps/server/src/providers/model.ts Clean injection of OAuth fetch; reads credentials fresh on each call so no stale-state risk at this layer. Looks correct.
apps/server/src/providers/registry.ts Trivial addition of optional fetch field to ProviderOptions. No issues.
apps/cli/src/client/index.ts Correct fix — using the { try, catch } form of Effect.tryPromise ensures res.json() failures fall through to onFailure cleanly instead of producing a generic Effect error string.

Last reviewed commit: 9205669

Greptile also left 2 inline comments on this PR.

@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 +239 to +243
return async (input, init) => {
// Refresh token if expired or missing
if (!auth.access || auth.expires < Date.now()) {
await refreshToken();
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Race condition in concurrent token refresh

If multiple requests arrive simultaneously with an expired token, every one of them will pass the auth.expires < Date.now() check and independently call refreshToken(). Since OAuth refresh tokens are typically single-use, only the first refresh will succeed; the rest will attempt to use the already-invalidated token and fail.

Consider storing an in-flight promise and re-using it to prevent concurrent refresh attempts:

let refreshPromise: Promise<void> | null = null;

// inside the returned fetch:
if (!auth.access || auth.expires < Date.now()) {
  if (!refreshPromise) {
    refreshPromise = refreshToken().finally(() => { refreshPromise = null; });
  }
  await refreshPromise;
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/server/src/providers/auth.ts
Line: 239-243

Comment:
**Race condition in concurrent token refresh**

If multiple requests arrive simultaneously with an expired token, every one of them will pass the `auth.expires < Date.now()` check and independently call `refreshToken()`. Since OAuth refresh tokens are typically single-use, only the first refresh will succeed; the rest will attempt to use the already-invalidated token and fail.

Consider storing an in-flight promise and re-using it to prevent concurrent refresh attempts:

```ts
let refreshPromise: Promise<void> | null = null;

// inside the returned fetch:
if (!auth.access || auth.expires < Date.now()) {
  if (!refreshPromise) {
    refreshPromise = refreshToken().finally(() => { refreshPromise = null; });
  }
  await refreshPromise;
}
```

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

Comment on lines +284 to +295
// Sanitize system prompt — Anthropic's OAuth endpoint blocks "OpenCode"
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.

String-format system prompts not sanitised

The sanitisation only runs when parsed.system is an array. However, buildSystemPrompt() in agent/loop.ts returns a string, which the AI SDK sends to the Anthropic API as a plain string { system: "..." }. If the system prompt contains "OpenCode", it will be forwarded as-is and Anthropic's OAuth endpoint filter will still block the request.

Add handling for string-format system prompts:

// Handle both string and array system prompts
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)) {
    // existing array handling...
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/server/src/providers/auth.ts
Line: 284-295

Comment:
**String-format system prompts not sanitised**

The sanitisation only runs when `parsed.system` is an array. However, `buildSystemPrompt()` in `agent/loop.ts` returns a string, which the AI SDK sends to the Anthropic API as a plain string `{ system: "..." }`. If the system prompt contains "OpenCode", it will be forwarded as-is and Anthropic's OAuth endpoint filter will still block the request.

Add handling for string-format system prompts:

```ts
// Handle both string and array system prompts
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)) {
    // existing array handling...
}
```

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 e0a296e to e1a7c6c Compare March 6, 2026 16:54
return;
}
let text = decoder.decode(value, { stream: true });
text = text.replace(/"name"\s*:\s*"mcp_([^"]+)"/g, '"name": "$1"');
Copy link
Contributor

Choose a reason for hiding this comment

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

The regex /"name"\s*:\s*"mcp_([^"]+)"/g applied to raw streaming response text can silently corrupt tool output. If a tool's result contains JSON with a "name" field whose value starts with mcp_ (e.g., {"name": "mcp_filesystem"}), that string will be incorrectly transformed before the SDK parses the streaming events.

The regex is intentionally broad to handle all streaming event types, but consider scoping it more narrowly (e.g., only within specific SSE event structures like "type": "content_block_start" or "type": "tool_use") to prevent it from matching user data inside tool result payloads.

Suggested change
text = text.replace(/"name"\s*:\s*"mcp_([^"]+)"/g, '"name": "$1"');
// Transform streaming response: strip mcp_ prefix from tool names in structured event fields only
if (response.body) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
const encoder = new TextEncoder();
const stream = new ReadableStream({
async pull(controller) {
const { done, value } = await reader.read();
if (done) {
controller.close();
return;
}
let text = decoder.decode(value, { stream: true });
// Only strip mcp_ prefix from tool_use and content_block_start events
text = text.replace(/"type"\s*:\s*"(content_block_start|tool_use)"([^}]*)"name"\s*:\s*"mcp_([^"]+)"/g, '"type": "$1"$2"name": "$3"');
controller.enqueue(encoder.encode(text));
}
});
return new Response(stream, {
status: response.status,
statusText: response.statusText,
headers: response.headers
});
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/server/src/providers/auth.ts
Line: 382

Comment:
The regex `/"name"\s*:\s*"mcp_([^"]+)"/g` applied to raw streaming response text can silently corrupt tool output. If a tool's result contains JSON with a `"name"` field whose value starts with `mcp_` (e.g., `{"name": "mcp_filesystem"}`), that string will be incorrectly transformed before the SDK parses the streaming events.

The regex is intentionally broad to handle all streaming event types, but consider scoping it more narrowly (e.g., only within specific SSE event structures like `"type": "content_block_start"` or `"type": "tool_use"`) to prevent it from matching user data inside tool result payloads.

```suggestion
// Transform streaming response: strip mcp_ prefix from tool names in structured event fields only
if (response.body) {
  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async pull(controller) {
      const { done, value } = await reader.read();
      if (done) {
        controller.close();
        return;
      }
      let text = decoder.decode(value, { stream: true });
      // Only strip mcp_ prefix from tool_use and content_block_start events
      text = text.replace(/"type"\s*:\s*"(content_block_start|tool_use)"([^}]*)"name"\s*:\s*"mcp_([^"]+)"/g, '"type": "$1"$2"name": "$3"');
      controller.enqueue(encoder.encode(text));
    }
  });

  return new Response(stream, {
    status: response.status,
    statusText: response.statusText,
    headers: response.headers
  });
}
```

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 e1a7c6c to 48f601b Compare March 6, 2026 17:05
Comment on lines +390 to +401
const stream = new ReadableStream({
async pull(controller) {
const { done, value } = await reader.read();
if (done) {
controller.close();
return;
}
const text = decoder.decode(value, { stream: true });
const transformed = text.split('\n').map(stripMcpPrefix).join('\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.

SSE chunk boundary can prevent mcp_ prefix stripping

The streaming reader processes raw byte chunks which are not guaranteed to align with SSE line boundaries. If a data line like data: {"type":"content_block_start","content_block":{"type":"tool_use","name":"mcp_read_file",...}} is split across two consecutive reader.read() chunks, neither partial chunk will contain the full "type":"tool_use" or "type":"content_block_start" string. stripMcpPrefix will return both partials unchanged, and the AI SDK will see mcp_read_file instead of read_file, failing to dispatch the tool call.

A robust fix is to buffer incomplete lines across chunk reads:

const stream = new ReadableStream({
    async pull(controller) {
        let buffer = '';
        while (true) {
            const { done, value } = await reader.read();
            if (done) {
                if (buffer) {
                    controller.enqueue(encoder.encode(stripMcpPrefix(buffer)));
                }
                controller.close();
                return;
            }
            buffer += decoder.decode(value, { stream: true });
            const lines = buffer.split('\n');
            buffer = lines.pop() ?? ''; // last element may be incomplete
            const transformed = lines.map(stripMcpPrefix).join('\n') + '\n';
            controller.enqueue(encoder.encode(transformed));
        }
    }
});
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/server/src/providers/auth.ts
Line: 390-401

Comment:
**SSE chunk boundary can prevent mcp_ prefix stripping**

The streaming reader processes raw byte chunks which are not guaranteed to align with SSE line boundaries. If a data line like `data: {"type":"content_block_start","content_block":{"type":"tool_use","name":"mcp_read_file",...}}` is split across two consecutive `reader.read()` chunks, neither partial chunk will contain the full `"type":"tool_use"` or `"type":"content_block_start"` string. `stripMcpPrefix` will return both partials unchanged, and the AI SDK will see `mcp_read_file` instead of `read_file`, failing to dispatch the tool call.

A robust fix is to buffer incomplete lines across chunk reads:

```ts
const stream = new ReadableStream({
    async pull(controller) {
        let buffer = '';
        while (true) {
            const { done, value } = await reader.read();
            if (done) {
                if (buffer) {
                    controller.enqueue(encoder.encode(stripMcpPrefix(buffer)));
                }
                controller.close();
                return;
            }
            buffer += decoder.decode(value, { stream: true });
            const lines = buffer.split('\n');
            buffer = lines.pop() ?? ''; // last element may be incomplete
            const transformed = lines.map(stripMcpPrefix).join('\n') + '\n';
            controller.enqueue(encoder.encode(transformed));
        }
    }
});
```

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 48f601b to a570841 Compare March 6, 2026 17:46
Comment on lines +126 to +132
if (normalizedProviderId === 'anthropic' && !options.skipAuth) {
const auth = await getCredentials('anthropic');
if (auth?.type === 'oauth') {
providerOptions.apiKey = '';
providerOptions.fetch = createAnthropicOAuthFetch(auth as OAuthAuth);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Concurrent OAuth fetch closures bypass single-use token protection

createAnthropicOAuthFetch() is instantiated on every getModel() call, creating a new closure with its own auth snapshot and refreshPromise = null state. The within-closure deduplication (lines 244-250 of auth.ts) only protects concurrent requests within the same closure.

If two concurrent code paths call getModel('anthropic') with an expired token, each receives a separate fetch closure with independent refreshPromise state — both will independently call refreshToken(). Since Anthropic refresh tokens are single-use, only the first refresh succeeds; the second fails.

Impact: Under concurrent load with expired Anthropic OAuth tokens, the second request fails even though token refresh was already in-flight.

Consider moving the OAuth fetch instance (or at minimum the refreshPromise and mutable auth reference) to a module-level cache keyed by provider, so all getModel() calls share refresh state:

// module-level cache
const oauthFetchCache = new Map<string, ReturnType<typeof createAnthropicOAuthFetch>>();

// inside getModel()
if (normalizedProviderId === 'anthropic' && !options.skipAuth) {
    const auth = await getCredentials('anthropic');
    if (auth?.type === 'oauth') {
        providerOptions.apiKey = '';
        if (!oauthFetchCache.has('anthropic')) {
            oauthFetchCache.set('anthropic', createAnthropicOAuthFetch(auth as OAuthAuth));
        }
        providerOptions.fetch = oauthFetchCache.get('anthropic')!;
    }
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/server/src/providers/model.ts
Line: 126-132

Comment:
**Concurrent OAuth fetch closures bypass single-use token protection**

`createAnthropicOAuthFetch()` is instantiated on every `getModel()` call, creating a new closure with its own `auth` snapshot and `refreshPromise = null` state. The within-closure deduplication (lines 244-250 of auth.ts) only protects concurrent requests *within the same closure*. 

If two concurrent code paths call `getModel('anthropic')` with an expired token, each receives a separate fetch closure with independent `refreshPromise` state — both will independently call `refreshToken()`. Since Anthropic refresh tokens are single-use, only the first refresh succeeds; the second fails.

**Impact**: Under concurrent load with expired Anthropic OAuth tokens, the second request fails even though token refresh was already in-flight.

Consider moving the OAuth fetch instance (or at minimum the `refreshPromise` and mutable `auth` reference) to a module-level cache keyed by provider, so all `getModel()` calls share refresh state:

```ts
// module-level cache
const oauthFetchCache = new Map<string, ReturnType<typeof createAnthropicOAuthFetch>>();

// inside getModel()
if (normalizedProviderId === 'anthropic' && !options.skipAuth) {
    const auth = await getCredentials('anthropic');
    if (auth?.type === 'oauth') {
        providerOptions.apiKey = '';
        if (!oauthFetchCache.has('anthropic')) {
            oauthFetchCache.set('anthropic', createAnthropicOAuthFetch(auth as OAuthAuth));
        }
        providerOptions.fetch = oauthFetchCache.get('anthropic')!;
    }
}
```

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 a570841 to 3c35f34 Compare March 6, 2026 18:05
Comment on lines +317 to +322
if (parsed.tools && Array.isArray(parsed.tools)) {
parsed.tools = (parsed.tools as Array<{ name?: string }>).map((tool) => ({
...tool,
name: tool.name ? `${TOOL_PREFIX}${tool.name}` : tool.name
}));
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Tool names with existing mcp_ prefix get double-prefixed

Tool definitions are prefixed unconditionally. If any registered tool already begins with mcp_ (e.g. from an external MCP server registered under that name), it becomes mcp_mcp_<name> in the request. The response-stripping regex peels off only one mcp_ level, so the SDK sees mcp_<name> — which gets prefixed again on the next turn. The name diverges from the tool definition across turns and tool calls break silently.

A guard against double-prefixing fixes this:

Suggested change
if (parsed.tools && Array.isArray(parsed.tools)) {
parsed.tools = (parsed.tools as Array<{ name?: string }>).map((tool) => ({
...tool,
name: tool.name ? `${TOOL_PREFIX}${tool.name}` : tool.name
}));
}
if (parsed.tools && Array.isArray(parsed.tools)) {
parsed.tools = (parsed.tools as Array<{ name?: string }>).map((tool) => ({
...tool,
name: tool.name && !tool.name.startsWith(TOOL_PREFIX)
? `${TOOL_PREFIX}${tool.name}`
: tool.name
}));
}

Apply the same guard to the tool_use block transform in the messages loop below.

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

Comment:
**Tool names with existing `mcp_` prefix get double-prefixed**

Tool definitions are prefixed unconditionally. If any registered tool already begins with `mcp_` (e.g. from an external MCP server registered under that name), it becomes `mcp_mcp_<name>` in the request. The response-stripping regex peels off only one `mcp_` level, so the SDK sees `mcp_<name>` — which gets prefixed again on the next turn. The name diverges from the tool definition across turns and tool calls break silently.

A guard against double-prefixing fixes this:

```suggestion
			if (parsed.tools && Array.isArray(parsed.tools)) {
				parsed.tools = (parsed.tools as Array<{ name?: string }>).map((tool) => ({
					...tool,
					name: tool.name && !tool.name.startsWith(TOOL_PREFIX)
						? `${TOOL_PREFIX}${tool.name}`
						: tool.name
				}));
			}
```

Apply the same guard to the `tool_use` block transform in the messages loop below.

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 3c35f34 to e64efe0 Compare March 6, 2026 20:02
export const createAnthropicOAuthFetch = (initialAuth: OAuthAuth): typeof globalThis.fetch => {
// Seed the module-level singleton with the latest credentials on each call.
// This ensures the token is up to date if auth.json was refreshed externally.
_oauthAuth = { ...initialAuth };
Copy link
Contributor

Choose a reason for hiding this comment

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

Stale credentials can overwrite a freshly refreshed token

createAnthropicOAuthFetch unconditionally overwrites _oauthAuth with initialAuth every time it's called. Under concurrent load with an expired token, this causes a race condition:

  1. Thread A reads stale credentials from disk and calls createAnthropicOAuthFetch with them, setting the module state to stale
  2. Thread A triggers token refresh, which completes and updates the module state to a fresh token (line 229)
  3. Thread B concurrently reads the same stale credentials from disk (file hasn't been updated yet)
  4. Thread B calls createAnthropicOAuthFetch with stale credentials and overwrites the module state back to stale
  5. On next use, the fresh token is lost and the single-use refresh token has already been consumed, causing permanent auth failure until server restart

Fix by adding a timestamp check to prevent overwriting with older credentials: only update the module state if the passed-in token is newer or if the state is empty.

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

Comment:
**Stale credentials can overwrite a freshly refreshed token**

`createAnthropicOAuthFetch` unconditionally overwrites `_oauthAuth` with `initialAuth` every time it's called. Under concurrent load with an expired token, this causes a race condition:

1. Thread A reads stale credentials from disk and calls `createAnthropicOAuthFetch` with them, setting the module state to stale
2. Thread A triggers token refresh, which completes and updates the module state to a fresh token (line 229)
3. Thread B concurrently reads the same stale credentials from disk (file hasn't been updated yet)
4. Thread B calls `createAnthropicOAuthFetch` with stale credentials and overwrites the module state back to stale
5. On next use, the fresh token is lost and the single-use refresh token has already been consumed, causing permanent auth failure until server restart

Fix by adding a timestamp check to prevent overwriting with older credentials: only update the module state if the passed-in token is newer or if the state is empty.

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 e64efe0 to 8f475d8 Compare March 6, 2026 20:13
- Allow 'oauth' as a valid auth type for the anthropic provider so that
  credentials stored by 'opencode auth --provider anthropic' (Pro/Max OAuth
  flow) are recognised by isAuthenticated() and getAuthStatus()
- Add createAnthropicOAuthFetch() which injects a custom fetch into
  @ai-sdk/anthropic to handle Bearer token auth without an API key.
  Implementation mirrors opencode-anthropic-auth@0.0.13 (credit: anomalyco/opencode):
    - Authorization: Bearer <access_token> header injection
    - Automatic token refresh via POST console.anthropic.com/v1/oauth/token
    - Persists refreshed tokens back to auth.json via setCredentials()
    - Merges required OAuth beta flags (oauth-2025-04-20, interleaved-thinking-2025-05-14)
    - Sets user-agent: claude-cli/2.1.2 (external, cli)
    - Appends ?beta=true to /v1/messages requests
    - Sanitises system prompts (OpenCode -> Claude Code) to pass server filters
    - Prefixes tool names with mcp_ in requests, strips prefix in streaming responses
- Add fetch?: typeof globalThis.fetch to ProviderOptions for extensibility
- Fix silent auth bypass: provider 'opencode' was exempt from requiresAuth,
  causing missing credentials to surface as a cryptic Effect.tryPromise error
  deep in collection creation rather than a clear ProviderNotConnectedError
- Fix parseErrorResponse swallowing real server error messages by using
  Effect.tryPromise({ try, catch }) instead of the bare single-argument form

Closes davis7dotsh#211
@phil-02 phil-02 force-pushed the fix/anthropic-oauth-pro-max-plan branch from 8f475d8 to 9205669 Compare March 6, 2026 20:19
const ensureProviderConnected = async () => {
const isAuthed = await isAuthenticated(config.provider);
const requiresAuth = config.provider !== 'opencode' && config.provider !== 'openai-compat';
const requiresAuth = config.provider !== 'openai-compat';
Copy link
Contributor

Choose a reason for hiding this comment

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

Breaking change for existing opencode users

Removing opencode from the requiresAuth exemption means any user currently configured with provider: opencode who hasn't set OPENCODE_API_KEY or run opencode auth --provider opencode will immediately hit a ProviderNotConnectedError at startup. Previously those users would get a cryptic downstream error — but they were still able to attempt a request.

If the opencode gateway (https://opencode.ai/zen/v1) is publicly accessible without a key, or if some users have legitimately been relying on this bypass, their working setups will silently break after this update.

Before removing the exemption, it's worth confirming whether the opencode gateway requires authentication for all model routes, and whether existing users should be given a migration notice.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/server/src/agent/service.ts
Line: 129

Comment:
**Breaking change for existing `opencode` users**

Removing `opencode` from the `requiresAuth` exemption means any user currently configured with `provider: opencode` who hasn't set `OPENCODE_API_KEY` or run `opencode auth --provider opencode` will immediately hit a `ProviderNotConnectedError` at startup. Previously those users would get a cryptic downstream error — but they were still able to attempt a request.

If the opencode gateway (`https://opencode.ai/zen/v1`) is publicly accessible without a key, or if some users have legitimately been relying on this bypass, their working setups will silently break after this update.

Before removing the exemption, it's worth confirming whether the opencode gateway requires authentication for all model routes, and whether existing users should be given a migration notice.

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

Comment on lines +383 to +387
const response = await fetch(requestInput, {
...init,
body,
headers: requestHeaders
});
Copy link
Contributor

Choose a reason for hiding this comment

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

Headers from transformed Request object are not carried forward

When input is a Request object (not a plain URL or string) and the URL is rewritten at line 376:

requestInput = input instanceof Request ? new Request(requestUrl.toString(), input) : requestUrl;

requestInput becomes a new Request built from the original. But the final fetch call overrides headers with requestHeaders — which was built from the original input.headers and init.headers. This part is fine.

The real problem: new Request(requestUrl.toString(), input) copies input.body into requestInput. Then the outer fetch call also passes body (the transformed version) as a separate body field in the init object:

const response = await fetch(requestInput, {
    ...init,
    body,        // transformed body
    headers: requestHeaders
});

The body in the options object takes precedence over the body baked into requestInput, so for the actual POST body this is fine. However, if the browser/Bun fetch implementation attempts to read requestInput.body before the init.body override is applied, a "body already used" error is possible in some runtimes when the same stream is referenced twice.

Consider constructing requestInput as a plain URL string in all cases and relying entirely on init for the body and headers, which is unambiguous:

const finalUrl = requestUrl ?? (typeof input === 'string' ? input : input instanceof URL ? input : input.url);
const response = await fetch(finalUrl.toString(), { ...init, body, headers: requestHeaders });
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/server/src/providers/auth.ts
Line: 383-387

Comment:
**Headers from transformed `Request` object are not carried forward**

When `input` is a `Request` object (not a plain URL or string) and the URL is rewritten at line 376:
```ts
requestInput = input instanceof Request ? new Request(requestUrl.toString(), input) : requestUrl;
```
`requestInput` becomes a new `Request` built from the original. But the final `fetch` call overrides headers with `requestHeaders` — which was built from the *original* `input.headers` and `init.headers`. This part is fine.

The real problem: `new Request(requestUrl.toString(), input)` copies `input.body` into `requestInput`. Then the outer fetch call *also* passes `body` (the transformed version) as a separate `body` field in the init object:
```ts
const response = await fetch(requestInput, {
    ...init,
    body,        // transformed body
    headers: requestHeaders
});
```
The `body` in the options object takes precedence over the body baked into `requestInput`, so for the actual POST body this is fine. However, if the browser/Bun `fetch` implementation attempts to read `requestInput.body` before the `init.body` override is applied, a "body already used" error is possible in some runtimes when the same stream is referenced twice.

Consider constructing `requestInput` as a plain URL string in all cases and relying entirely on `init` for the body and headers, which is unambiguous:
```ts
const finalUrl = requestUrl ?? (typeof input === 'string' ? input : input instanceof URL ? input : input.url);
const response = await fetch(finalUrl.toString(), { ...init, body, headers: requestHeaders });
```

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

@phil-02
Copy link
Author

phil-02 commented Mar 6, 2026

Closing in favour of a clean resubmission.

@phil-02 phil-02 closed this Mar 6, 2026
@phil-02 phil-02 deleted the fix/anthropic-oauth-pro-max-plan branch March 6, 2026 20:25
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