fix: add Anthropic OAuth support for Pro/Max plan users#213
fix: add Anthropic OAuth support for Pro/Max plan users#213phil-02 wants to merge 1 commit intodavis7dotsh:mainfrom
Conversation
|
@phil-02 is attempting to deploy a commit to the davis7dotsh Team on Vercel. A member of the Team first needs to authorize it. |
| return async (input, init) => { | ||
| // Refresh token if expired or missing | ||
| if (!auth.access || auth.expires < Date.now()) { | ||
| await refreshToken(); | ||
| } |
There was a problem hiding this 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:
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.| // 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; | ||
| }); | ||
| } |
There was a problem hiding this 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:
// 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.e0a296e to
e1a7c6c
Compare
apps/server/src/providers/auth.ts
Outdated
| return; | ||
| } | ||
| let text = decoder.decode(value, { stream: true }); | ||
| text = text.replace(/"name"\s*:\s*"mcp_([^"]+)"/g, '"name": "$1"'); |
There was a problem hiding this 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.
| 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.e1a7c6c to
48f601b
Compare
| 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)); | ||
| } | ||
| }); |
There was a problem hiding this 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:
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.48f601b to
a570841
Compare
| if (normalizedProviderId === 'anthropic' && !options.skipAuth) { | ||
| const auth = await getCredentials('anthropic'); | ||
| if (auth?.type === 'oauth') { | ||
| providerOptions.apiKey = ''; | ||
| providerOptions.fetch = createAnthropicOAuthFetch(auth as OAuthAuth); | ||
| } | ||
| } |
There was a problem hiding this 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:
// 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.a570841 to
3c35f34
Compare
| 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 | ||
| })); | ||
| } |
There was a problem hiding this 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:
| 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.3c35f34 to
e64efe0
Compare
apps/server/src/providers/auth.ts
Outdated
| 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 }; |
There was a problem hiding this 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:
- Thread A reads stale credentials from disk and calls
createAnthropicOAuthFetchwith them, setting the module state to stale - Thread A triggers token refresh, which completes and updates the module state to a fresh token (line 229)
- Thread B concurrently reads the same stale credentials from disk (file hasn't been updated yet)
- Thread B calls
createAnthropicOAuthFetchwith stale credentials and overwrites the module state back to stale - 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.e64efe0 to
8f475d8
Compare
- 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
8f475d8 to
9205669
Compare
| const ensureProviderConnected = async () => { | ||
| const isAuthed = await isAuthenticated(config.provider); | ||
| const requiresAuth = config.provider !== 'opencode' && config.provider !== 'openai-compat'; | ||
| const requiresAuth = config.provider !== 'openai-compat'; |
There was a problem hiding this 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.
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.| const response = await fetch(requestInput, { | ||
| ...init, | ||
| body, | ||
| headers: requestHeaders | ||
| }); |
There was a problem hiding this 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:
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.|
Closing in favour of a clean resubmission. |
Summary
Fixes #211 —
btca askfails at "creating collection" with a crypticEffect.tryPromiseerror when usingprovider: anthropicwith a Pro/Max plan OAuth token fromopencode auth.Root Cause
Four bugs compounding:
anthropicprovider only acceptedtype: "api"auth — OAuth credentials stored byopencode auth --provider anthropicwere not recognised, causingisAuthenticated()to returnfalseandgetModel()to throw or passapiKey: undefinedto@ai-sdk/anthropic.provider: opencodesilently bypassed auth checks —requiresAuthwas alwaysfalseforopencode, so missing credentials never surfaced as aProviderNotConnectedError. Instead, theundefinedAPI key caused a runtime failure deep inside collection creation, producing the opaque"An error occurred in Effect.tryPromise"message.parseErrorResponseswallowed real server error messages — used the bareEffect.tryPromise(() => res.json())form, which produces the default"An error occurred in Effect.tryPromise"string instead of the actual server error whenres.json()fails.ProviderOptionshad nofetchfield — no way to inject a custom fetch into the Anthropic SDK factory.Changes
apps/server/src/providers/auth.ts'oauth'toPROVIDER_AUTH_TYPES.anthropicgetProviderAuthHintforanthropicto mention OAuthcreateAnthropicOAuthFetch(initialAuth: OAuthAuth)— a custom fetch factory that:Authorization: Bearer <access_token>and removesx-api-keyPOST https://console.anthropic.com/v1/oauth/tokenand persists refreshed tokens back toauth.jsonoauth-2025-04-20,interleaved-thinking-2025-05-14)user-agent: claude-cli/2.1.2 (external, cli)?beta=trueto/v1/messagesrequestsOpenCode→Claude Code) to pass Anthropic's OAuth endpoint filtersmcp_in requests and strips the prefix transparently in streaming responsesapps/server/src/providers/model.tsgetCredentials,createAnthropicOAuthFetch,OAuthAuthfromauth.tsgetModel(), whenprovider === 'anthropic'and credentials aretype: 'oauth', setapiKey: ''and inject the OAuth fetch intoproviderOptionsapps/server/src/providers/registry.tsfetch?: typeof globalThis.fetchtoProviderOptionsfor extensibilityapps/server/src/agent/service.tsopencodefrom therequiresAuthexemption so missingopencodecredentials surface as a clearProviderNotConnectedErrorinstead of a cryptic Effect errorapps/cli/src/client/index.tsparseErrorResponseto useEffect.tryPromise({ try, catch })instead of the bare form, preserving real server error messagesTesting
Tested end-to-end with
provider: anthropicand an OAuth token fromopencode auth --provider anthropic(Anthropic Pro plan):Greptile Summary
This PR adds Anthropic OAuth support for Pro/Max plan users by wiring in a custom
fetchfactory that handles Bearer-token injection, automatic token refresh, required beta headers, system-prompt sanitisation, andmcp_-prefix tool name round-tripping for SSE streams. It also fixesparseErrorResponseto preserve real server error messages, and surfaces missing-credentials errors for theopencodeprovider more cleanly.Key points from the review:
opencodeprovider auth requirement (service.ts): Removingopencodefrom therequiresAuthbypass means existing users configured withprovider: opencodeand no API key will now get aProviderNotConnectedErroron every request. If the opencode gateway is currently accessible without a key for some users, this will silently break their setup.auth.ts): When the request URL is rewritten (to append?beta=true) and the originalinputis aRequestobject, a newRequestis built from it — embedding the original body stream — and then the outerfetchcall additionally passes abodyoverride. 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 oninitfor body and headers would be safer..mdfiles were found in the changeset.Confidence Score: 2/5
opencodeprovider breaking change is intentional and understood by all current users.opencodeusers without credentials, and a runtime-dependent "body already used" risk in the OAuth fetch — are both in high-traffic code paths. Theopencodechange 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) andapps/server/src/providers/auth.ts(Request body handling in OAuth fetch).Important Files Changed
opencodefrom therequiresAuthexemption is a breaking change for existing users who rely on the provider without credentials; needs confirmation before merging.fetchfield toProviderOptions. No issues.{ try, catch }form ofEffect.tryPromiseensuresres.json()failures fall through toonFailurecleanly instead of producing a generic Effect error string.Last reviewed commit: 9205669