fix: add Anthropic OAuth support for Pro/Max plan users#214
fix: add Anthropic OAuth support for Pro/Max plan users#214phil-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. |
| 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; | ||
| }); | ||
| } |
There was a problem hiding this 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:
| 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.
apps/server/src/providers/auth.ts
Outdated
|
|
||
| // Refresh token if expired or missing — deduplicate concurrent refresh calls | ||
| // since Anthropic refresh tokens are single-use | ||
| if (!auth?.access || auth.expires < Date.now()) { |
There was a problem hiding this 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:
| 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.0865157 to
7bc3b91
Compare
| 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)); | ||
| } | ||
| }); |
There was a problem hiding this 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:
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.
apps/server/src/providers/auth.ts
Outdated
| 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 | ||
| }); |
There was a problem hiding this 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.
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.7bc3b91 to
b3f9b36
Compare
| 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 | ||
| }); | ||
| }; |
There was a problem hiding this 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:
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.There was a problem hiding this comment.
Fixed in 17dd97d — AbortSignal.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
b3f9b36 to
17dd97d
Compare
|
Note on the This PR removes // before
const requiresAuth = config.provider !== 'opencode' && config.provider !== 'openai-compat';
// after
const requiresAuth = config.provider !== 'openai-compat';The original motivation was that missing However, if the |
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 byisAuthenticated()orgetAuthStatus(), sogetModel()threw or passedapiKey: 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.ProviderOptionshad nofetchfield — no way to inject a custom fetch into the Anthropic SDK factory.Changes
apps/server/src/providers/auth.tsAdd
'oauth'toPROVIDER_AUTH_TYPES.anthropicUpdate
getProviderAuthHintforanthropicto mention OAuthAdd
createAnthropicOAuthFetch()— a stateless custom fetch factory that reads credentials fresh fromauth.jsonon 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: Bearerinjection,x-api-keyremovalPOST https://console.anthropic.com/v1/oauth/token, persisted back toauth.jsonuser-agent: claude-cli/2.1.2 (external, cli)?beta=trueappended to/v1/messagesstringandArrayformats (OpenCode→Claude Code)mcp_in requests (guarded against double-prefixing for MCP server tools)mcp_prefix stripped from responses via a line-buffered SSE transform scoped totool_use/content_block_startevent lines only (avoids corrupting tool result payloads, handles chunk boundaries correctly)apps/server/src/providers/model.tsgetModel(), whenprovider === 'anthropic'and credentials aretype: 'oauth', setapiKey: ''and inject the OAuth fetch viacreateAnthropicOAuthFetch()apps/server/src/providers/registry.tsfetch?: typeof globalThis.fetchtoProviderOptionsapps/server/src/agent/service.tsopencodefrom therequiresAuthexemptionapps/cli/src/client/index.tsparseErrorResponseto useEffect.tryPromise({ try, catch })formTesting
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, fixing a compounding set of bugs that caused
btca askto fail with a cryptic error when using an OAuth token fromopencode auth --provider anthropic. The core addition iscreateAnthropicOAuthFetch()inauth.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-sensitiveOpenCode→Claude Code), Zod-validated token responses, andmcp_tool name prefixing/stripping across the SSE response stream with cancel-safe streaming.Key changes:
auth.ts: Adds'oauth'toPROVIDER_AUTH_TYPES.anthropicand implements the full OAuth fetch pipeline with Zod validation, 30-second expiry buffer, 10-secondAbortSignal.timeout, and a cancel-safe streaming transform.model.ts: Injects the OAuth fetch and clearsapiKeywhen Anthropic credentials areoauthtype.service.ts: Removesopencodefrom therequiresAuthexemption — a breaking change for anyopencodeusers 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 optionalfetchfield toProviderOptions.client/index.ts: FixesparseErrorResponseto use the{ try, catch }form ofEffect.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
opencodeprovider auth gating is a breaking change that requires verification before release.Effect.tryPromisefix is correct. The main risk is theopencodeexemption 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.opencodeprovider without local credentials before release.Comments Outside Diff (1)
apps/server/src/agent/service.ts, line 127-136 (link)Breaking change for
opencodeprovider usersRemoving
opencodefrom therequiresAuthexemption (line 129) means any user withprovider: opencodein their config who lacks both anOPENCODE_API_KEYenvironment variable and anapi-type entry inauth.jsonwill now receive aProviderNotConnectedErrorinstead of proceeding.Looking at
auth.tsline 22,opencodeonly 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:
OPENCODE_API_KEYenv var orbtca connect -p opencode)Last reviewed commit: 17dd97d
Context used: